mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
feat(failover): add cross-provider failover for overlapping models
Add failover.go with the full candidate pipeline. resolveWithCandidates resolves a request exactly like resolve does, then adds every other provider sharing the identical bare model id. orderedFailoverCandidates applies a hard skip for request-path-unhealthy providers, deprioritizes discovery-unhealthy providers without dropping them, fails open when every candidate reads unhealthy, and caps the list at maxAttempts. Add requestHealthTracker, a separate per-pod request-path health signal fed by the same isTransient/isDeadlineExceeded classification recordProviderAttempt already uses. It never widens the existing discovery circuit breaker, per that breaker's own documented scope. Rewrite runMeteredCall (routes_unified.go) to loop over candidates instead of resolving once. Each attempt rebinds the attempt recorder to its own provider, recomputes the cache key before the response is cached, and re-injects the alias echo key an adapter may have deleted on a prior failed attempt. Failover never runs once any byte has reached the client, since statusTrackingWriter already tracks that state. A failure falls through on everything except HTTP 400, matching the brief exactly. A 404 failover logs loudly, naming both providers and the model, since it usually signals a real configuration mistake. Add FailoverConfig (Config.Failover): an Enabled pointer defaulting to true, and a MaxAttempts bound between 1 and 10, defaulting to 3. A single-provider deployment, or one with no overlapping bare model ids, behaves exactly as before regardless of this setting. Add a yaegi-check probe (tools/yaegi-check/main.go) driving two providers under the real interpreter, one failing and one serving, proving the candidate loop runs correctly interpreted. Update README.md with a Failover section and correct the Retry section's stale claim that no cross-provider failover exists. Media routes (images, audio speech, audio transcriptions) do not get failover in this change. Each has its own request-shape handling that needs its own scoped follow-up.
This commit is contained in:
@@ -346,6 +346,31 @@ func run() error {
|
||||
}))
|
||||
defer brkUpstream.Close()
|
||||
|
||||
// failoverAUpstream/failoverBUpstream back exerciseFailover (feat/
|
||||
// failover, below): two providers configured with the IDENTICAL bare
|
||||
// model id (failoverModelID) — the exact shape registry.go's bareWinner
|
||||
// collision handles, and the shape production logs show for whisper-1/
|
||||
// tts-1/tts-1-hd/the embeddings models across openai/openai-audio/
|
||||
// copilot. A always answers 500; B always answers 200. This proves the
|
||||
// failover candidate loop (runMeteredCall, routes_unified.go) falls
|
||||
// through and serves correctly under the REAL interpreter, not merely
|
||||
// compiled — this package's own doc comment is exactly why that
|
||||
// distinction matters on this codebase.
|
||||
failoverAUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"failover-a is down","type":"server_error"}}`))
|
||||
}))
|
||||
defer failoverAUpstream.Close()
|
||||
failoverBHits := new(int64)
|
||||
failoverBUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(failoverBHits, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":"c-failover","object":"chat.completion","model":"` + failoverModelID + `","choices":[{"index":0,"message":{"role":"assistant","content":"served by failover-b"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||
}))
|
||||
defer failoverBUpstream.Close()
|
||||
|
||||
attemptAccountingOverride := `{"breaker":{"failureThreshold":2,"openDuration":"3s","maxOpenDuration":"6s"},` +
|
||||
`"providers":{"openai":{"type":"openai","baseUrl":"` + upstream.URL + `","apiKey":"sk-up","models":["` + testDataWantModel + `","` + builtinLookupModelID + `"]},` +
|
||||
`"brk":{"type":"openai","baseUrl":"` + brkUpstream.URL + `","apiKey":"sk-up","discovery":true,"discoveryInterval":"1ms"},"` +
|
||||
@@ -354,7 +379,9 @@ func run() error {
|
||||
// (exerciseMessagesRoute, below): a real anthropic-type provider,
|
||||
// interpreted end to end through
|
||||
// callAnthropicMessagesPassthrough.
|
||||
`"anthropic":{"type":"anthropic","baseUrl":"` + anthropicProbeUpstream.URL + `","apiKey":"sk-anth","models":["claude-test"]}},` + // #nosec G101 -- test fixture literal, not a real credential
|
||||
`"anthropic":{"type":"anthropic","baseUrl":"` + anthropicProbeUpstream.URL + `","apiKey":"sk-anth","models":["claude-test"]},` + // #nosec G101 -- test fixture literal, not a real credential
|
||||
`"failover-a":{"type":"openai","baseUrl":"` + failoverAUpstream.URL + `","apiKey":"sk-up","models":["` + failoverModelID + `"]},` +
|
||||
`"failover-b":{"type":"openai","baseUrl":"` + failoverBUpstream.URL + `","apiKey":"sk-up","models":["` + failoverModelID + `"]}},` +
|
||||
// modelMeta (feature v0.23): a config-override entry for
|
||||
// testDataWantModel, so exerciseHandler's GET /v1/models
|
||||
// assertion below proves resolveModelMeta's config-override
|
||||
@@ -405,7 +432,7 @@ func run() error {
|
||||
return fmt.Errorf("%s.New's returned value does not implement http.Handler", pkgName)
|
||||
}
|
||||
|
||||
if err := exerciseHandler(handler, builtinLookupContextTokens); err != nil {
|
||||
if err := exerciseHandler(handler, builtinLookupContextTokens, failoverBHits); err != nil {
|
||||
return err
|
||||
}
|
||||
// exerciseBreaker (feat/provider-health, adversarial-review round 2):
|
||||
@@ -429,6 +456,11 @@ func run() error {
|
||||
// the config-override path testDataWantModel already exercises.
|
||||
const builtinLookupModelID = "gpt-4o"
|
||||
|
||||
// failoverModelID is the bare model id both "failover-a" and "failover-b"
|
||||
// (run(), above) are configured with — the run()-owned upstream servers
|
||||
// exerciseFailover (below) drives.
|
||||
const failoverModelID = "failover-shared"
|
||||
|
||||
// mcpProbeServerName is the federated MCP server run() configures against
|
||||
// mcpProbeUpstream, and mcpProbeToolName is a tool id carrying its
|
||||
// "<server>_" prefix so resolveFederatedTool routes POST /mcp's
|
||||
@@ -634,8 +666,8 @@ func exerciseBreaker(handler http.Handler, hits, healthy *int64) error {
|
||||
// for real under Yaegi, not merely that the package's imports resolve.
|
||||
// builtinContextTokens is builtinLookupModelID's own real, extracted
|
||||
// builtinModelMetaTable value (SHOULD-6) — see that constant's own doc
|
||||
// comment.
|
||||
func exerciseHandler(handler http.Handler, builtinContextTokens int) error {
|
||||
// comment. failoverBHits backs exerciseFailover's own call, below.
|
||||
func exerciseHandler(handler http.Handler, builtinContextTokens int, failoverBHits *int64) error {
|
||||
authedReq := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
authedReq.Header.Set("Authorization", "Bearer "+testDataUserAPIKey)
|
||||
authedRec := httptest.NewRecorder()
|
||||
@@ -702,6 +734,10 @@ func exerciseHandler(handler http.Handler, builtinContextTokens int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := exerciseFailover(handler, failoverBHits); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := exerciseFederatedTooLarge(handler); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1013,6 +1049,38 @@ func exerciseFederatedTooLarge(handler http.Handler) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// exerciseFailover drives one real POST /v1/chat/completions against the
|
||||
// interpreted handler for failoverModelID — a bare model id both
|
||||
// "failover-a" (always 500s) and "failover-b" (always 200s) are
|
||||
// configured with — proving the failover candidate loop (runMeteredCall,
|
||||
// routes_unified.go/failover.go) falls through a genuine provider
|
||||
// failure and serves the second provider's response under the REAL
|
||||
// interpreter. Compiled tests already prove this (failover_test.go); this
|
||||
// harness exists because compiled tests have repeatedly passed on this
|
||||
// codebase while the interpreted shape failed (this package's own doc
|
||||
// comment).
|
||||
func exerciseFailover(handler http.Handler, bHits *int64) error {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(
|
||||
`{"model":"`+failoverModelID+`","messages":[{"role":"user","content":"hi"}]}`,
|
||||
))
|
||||
req.Header.Set("Authorization", "Bearer "+testDataUserAPIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
return fmt.Errorf("POST /v1/chat/completions (feat/failover harness): status = %d, want 200 (failover-a's 500 must fall through to failover-b), body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "served by failover-b") {
|
||||
return fmt.Errorf("POST /v1/chat/completions (feat/failover harness): body does not contain failover-b's own content, want the SECOND provider's response: %s", rec.Body.String())
|
||||
}
|
||||
if atomic.LoadInt64(bHits) != 1 {
|
||||
return fmt.Errorf("feat/failover harness: failover-b upstream hit count = %d, want exactly 1", atomic.LoadInt64(bHits))
|
||||
}
|
||||
fmt.Println("yaegi-check: failover fell through from failover-a to failover-b and served its response")
|
||||
return nil
|
||||
}
|
||||
|
||||
func exerciseAttemptAccounting(handler http.Handler) error {
|
||||
chatReq := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(
|
||||
`{"model":"`+testDataWantModel+`","messages":[{"role":"user","content":"hi"}]}`,
|
||||
|
||||
Reference in New Issue
Block a user