mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
fix(terraform): retry the litellm_fallback read-back while replicas catch up
On a proxy with more than one replica behind a load balancer, the read that follows POST /fallback can reach a replica that has not loaded the new fallback yet. It answers 404, the read clears the ID, and Terraform fails the apply with "Root object was present, but now absent" even though the proxy saved the fallback Create and update now read back through retryFallbackRead, which retries with the same backoff as retryModelRead and retryCredentialRead while the read comes back empty, and fails with an error naming the model once it runs out of attempts Fixes #47888
This commit is contained in:
parent
dddbaa803d
commit
403a97ea3b
3 changed files with 112 additions and 1 deletions
|
|
@ -51,6 +51,7 @@ longer signal it.
|
|||
|
||||
### Fixed
|
||||
|
||||
- **fallback**: `litellm_fallback` create and update now retry the read-back while the proxy answers 404, the same way `litellm_model` and `litellm_credential` already do. Behind a load balancer with more than one proxy replica, the read could reach a replica that had not loaded the new fallback yet, so the apply failed with `Root object was present, but now absent` even though the proxy had saved it (#47888). If the fallback is still not found after the retries, the apply now fails with an error naming the model instead
|
||||
- **model**: `additional_drop_params` in `litellm_model.additional_litellm_params` is now sent to the proxy as the deployment's `additional_drop_params` list, the same list a `model_list` entry takes. Before, the provider never sent the list and instead deleted every key it named from `litellm_params`, so a `store` or `vertex_location` listed there was lost and requests still carried the parameters (#38349). The value must be a JSON list of parameter names such as `jsonencode(["store"])`, which `terraform plan` now checks, and `null` entries are skipped as before. When the configuration sets the list, refresh reads the stored list back from the proxy, so a list changed outside Terraform shows up as drift. On a model an older provider created, the proxy has no stored list, so the first plan after upgrading shows the list and every listed key the configuration sets as changes, since that apply writes them. A write-only secret such as `model_api_key_wo` is never shown in a plan
|
||||
- **user**: `litellm_user` updates no longer send `blocked`, which `/user/update` writes into the user table where no such column exists, so every update failed with a 400 (`Could not find field at upsertOneLiteLLM_UserTable.create.blocked`). Create still sends `blocked`, including `false`, so a `default_internal_user_params.blocked` on the proxy cannot block the auto-created key. Changing `blocked` after creation has no effect, since the proxy has no user-level block; use `litellm_key_block` instead
|
||||
- **model**: `litellm_model` refresh now reads the `{"data": [...]}` envelope `/model/info` returns, so `model_info` fields changed outside Terraform show up as drift instead of silently keeping the previous state
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
|
||||
|
|
@ -85,7 +86,37 @@ func upsertLiteLLMFallback(d *schema.ResourceData, m interface{}, action string)
|
|||
}
|
||||
|
||||
d.SetId(d.Get("model").(string))
|
||||
return resourceLiteLLMFallbackRead(d, m)
|
||||
return retryFallbackRead(d, m, 5)
|
||||
}
|
||||
|
||||
// retryFallbackRead reads the fallback back after a write, retrying with backoff while the read
|
||||
// clears the ID. Behind a load balancer the read can reach a proxy replica that has not loaded the
|
||||
// new fallback yet and answers 404 (eventual consistency, same as retryModelRead).
|
||||
func retryFallbackRead(d *schema.ResourceData, m interface{}, maxRetries int) error {
|
||||
delay := 1 * time.Second
|
||||
maxDelay := 10 * time.Second
|
||||
model := d.Id()
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if err := resourceLiteLLMFallbackRead(d, m); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Id() != "" {
|
||||
return nil
|
||||
}
|
||||
d.SetId(model)
|
||||
|
||||
if i < maxRetries-1 {
|
||||
log.Printf("[INFO] Fallback for model %s not found yet (eventual consistency), retrying in %v...", model, delay)
|
||||
time.Sleep(delay)
|
||||
delay *= 2
|
||||
if delay > maxDelay {
|
||||
delay = maxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("fallback for model %s was written but not found after %d read attempts; re-running apply should resolve this", model, maxRetries)
|
||||
}
|
||||
|
||||
func resourceLiteLLMFallbackCreate(d *schema.ResourceData, m interface{}) error {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
|
|
@ -74,6 +76,83 @@ func TestResourceLiteLLMFallbackCreate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResourceLiteLLMFallbackCreate_RetriesReadUntilReplicaHasFallback(t *testing.T) {
|
||||
var reads int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/fallback", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"model":"gpt-4","fallback_models":["claude-3"],"fallback_type":"general","message":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("/fallback/gpt-4", func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&reads, 1) == 1 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"model":"gpt-4","fallback_models":["claude-3"],"fallback_type":"general"}`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := newFallbackTestResourceData(t, "gpt-4", []interface{}{"claude-3"}, "general")
|
||||
|
||||
if err := resourceLiteLLMFallbackCreate(d, client); err != nil {
|
||||
t.Fatalf("expected nil error, got: %v", err)
|
||||
}
|
||||
if d.Id() != "gpt-4" {
|
||||
t.Fatalf("expected ID 'gpt-4' after the second read found the fallback, got %q", d.Id())
|
||||
}
|
||||
if got := atomic.LoadInt32(&reads); got != 2 {
|
||||
t.Fatalf("expected 2 reads (404 then 200), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryFallbackRead_ErrorsAndKeepsIDWhenFallbackNeverAppears(t *testing.T) {
|
||||
var reads int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&reads, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := newFallbackTestResourceData(t, "gpt-4", []interface{}{"claude-3"}, "general")
|
||||
d.SetId("gpt-4")
|
||||
|
||||
err := retryFallbackRead(d, client, 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "gpt-4") {
|
||||
t.Fatalf("expected an error naming the model, got: %v", err)
|
||||
}
|
||||
if d.Id() != "gpt-4" {
|
||||
t.Fatalf("expected ID to stay 'gpt-4' so Terraform keeps tracking the written fallback, got %q", d.Id())
|
||||
}
|
||||
if got := atomic.LoadInt32(&reads); got != 2 {
|
||||
t.Fatalf("expected 2 reads, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryFallbackRead_DoesNotRetryServerErrors(t *testing.T) {
|
||||
var reads int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&reads, 1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "internal server error"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(srv.URL, "test-key", true)
|
||||
d := newFallbackTestResourceData(t, "gpt-4", []interface{}{"claude-3"}, "general")
|
||||
d.SetId("gpt-4")
|
||||
|
||||
if err := retryFallbackRead(d, client, 3); err == nil {
|
||||
t.Fatal("expected error for 500 response, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&reads); got != 1 {
|
||||
t.Fatalf("expected 1 read (no retry on a 500), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceLiteLLMFallbackRead_MapsFields(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/fallback/gpt-4" {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue