fix(failover): default off, add cost guard, fix adversarial-review findings

Flip FailoverConfig.Enabled to default false. A version upgrade with no
config change must preserve prior behaviour, and failover changes which
provider serves a request. Update tests, README, and the yaegi-check
probe to opt in explicitly.

Add a mandatory cost guard. A candidate is skipped unless its input AND
output cost per token are each no greater than the primary candidate's,
resolved through the same canonical-then-bare pricing path the real
billing code uses. Unknown pricing is never treated as free: a known
primary against an unknown candidate skips, an unknown primary against a
known non-zero candidate skips, everything else that is equal is
allowed. Not configurable.

Fix four blocking findings from adversarial review of commit 5d62479.

F1: openai-type adapters mutate the request map in place to force
stream usage reporting. Reusing one map across candidates let a later
candidate read back an earlier candidate's own injection and leak a
usage-only SSE chunk to a client that never asked for it. Every
candidate now attempts against a fresh copy of the original request.

F2: a streaming candidate that fails before writing anything still
stages SSE-only response headers. Those headers now get cleared before
every candidate attempt, so they cannot survive into a later
candidate's plain JSON response.

F3: the request-health gate used the retry-shaped isTransient
classification, which treats a non-429 4xx as a successful outcome. A
provider with a dead API key was never skipped and was retried forever.
The gate now reuses failoverEligible's own classification instead.

F4: a shared request context that an earlier candidate exhausted was
attributed to whichever candidate happened to run next, opening a
healthy provider's breaker for a timeout that was not its fault. A
candidate is no longer attempted once the shared context is already
done; the loop stops and reports the last real error instead.

Also fix three should-fix findings from the same review.

F5: a failure arriving while the request-health breaker is already open
but not yet past its cooldown window is now a no-op, instead of
resetting backoff and sliding the open window forward on every such
failure.

F6: the request-health gate now records at most one outcome per
candidate per logical request, not one per same-provider retry attempt,
so retry.attempts alone can no longer open the breaker on a single
request.

F7: every candidate's cache entry is now checked before any candidate
is attempted, so a cached failover response no longer costs a round
trip to a still-broken primary first.

F10: the generic "failing over" log line is now rate-limited to once
per 30 seconds during a sustained outage, reusing the existing logGate
type. The 404 loud-log line stays unthrottled, as ruled.

F9 (optional): skip building the failover candidate pool entirely when
failover is disabled.

Correct an overstated test comment on
TestHandleChat_Failover_DiscoveryUnhealthy_StillTried: it proves alpha
is still tried, not that ordering places it second.

Document, but do not fix here, that this plugin's HTTP client sets no
per-attempt timeout, so a provider that accepts a connection and then
hangs will never trigger failover. This is a pre-existing property of
the shared HTTP client, not specific to failover.

Deferred: F8 (surfacing request-health state on the admin dashboard)
touches admin.go, which a sibling branch is also editing concurrently;
left for a follow-up to avoid a merge conflict in a file this change
does not otherwise need.

Every fix above is proven by a dedicated regression test, and each was
verified by reverting the fix in place and confirming the corresponding
test fails before restoring it.
This commit is contained in:
2026-08-23 01:18:58 +01:00
parent 5d624791fa
commit d45125ee07
6 changed files with 1174 additions and 126 deletions
+728 -16
View File
@@ -262,6 +262,48 @@ func TestRequestHealthTracker_FailedProbe_ReopensWithDoubledBackoff(t *testing.T
assert.True(t, tr.healthy("p"), "must recover once the doubled window has fully elapsed")
}
// TestRequestHealthTracker_FailureWhileOpenBeforeCooldown_IsANoOp is the
// direct regression test for adversarial-review finding F5: a failure
// arriving while the breaker is ALREADY open and its cooldown has not
// yet elapsed must change nothing — not the backoff, not openUntil. An
// earlier version fell through to the closed-state consecutiveFailures++
// branch for this case, which reset backoff to the flat base duration
// and slid openUntil forward on every such failure, so doubling never
// actually happened outside the narrow post-cooldown probe path.
func TestRequestHealthTracker_FailureWhileOpenBeforeCooldown_IsANoOp(t *testing.T) {
t.Parallel()
tr := newRequestHealthTracker()
now := time.Now()
tr.nowFn = func() time.Time { return now }
for i := 0; i < requestBreakerFailureThreshold; i++ {
tr.record("p", false)
}
st := tr.stateFor("p")
st.mu.Lock()
openUntilAfterTrip := st.openUntil
backoffAfterTrip := st.backoff
st.mu.Unlock()
require.Equal(t, requestBreakerOpenDuration, backoffAfterTrip)
// Well before the cooldown elapses: several more failures arrive
// (e.g. other in-flight requests against the same dead provider).
now = now.Add(requestBreakerOpenDuration / 2)
for i := 0; i < 5; i++ {
require.False(t, tr.healthy("p"))
tr.record("p", false)
}
st.mu.Lock()
openUntilAfterExtraFailures := st.openUntil
backoffAfterExtraFailures := st.backoff
failuresAfterExtra := st.consecutiveFailures
st.mu.Unlock()
assert.Equal(t, openUntilAfterTrip, openUntilAfterExtraFailures, "openUntil must not slide forward from failures arriving before the cooldown elapses")
assert.Equal(t, backoffAfterTrip, backoffAfterExtraFailures, "backoff must stay at the base duration, not reset by every failure while already open")
assert.Equal(t, requestBreakerFailureThreshold, failuresAfterExtra, "consecutiveFailures must not keep growing once the breaker is already open")
}
func TestRequestHealthTracker_BackoffCapsAtMax(t *testing.T) {
t.Parallel()
tr := newRequestHealthTracker()
@@ -364,10 +406,18 @@ func TestValidateFailoverConfig_Defaults(t *testing.T) {
t.Parallel()
fc, err := validateFailoverConfig(FailoverConfig{})
require.NoError(t, err)
assert.True(t, fc.enabled, "nil Enabled must default to true — failover is a hot standby with no config change")
assert.False(t, fc.enabled, "nil Enabled must default to false — coordinator ruling: new behavior that can change which provider serves a request goes behind a flag defaulting off")
assert.Equal(t, defaultFailoverMaxAttempts, fc.maxAttempts)
}
func TestValidateFailoverConfig_ExplicitEnable(t *testing.T) {
t.Parallel()
yes := true
fc, err := validateFailoverConfig(FailoverConfig{Enabled: &yes})
require.NoError(t, err)
assert.True(t, fc.enabled)
}
func TestValidateFailoverConfig_ExplicitDisable(t *testing.T) {
t.Parallel()
no := false
@@ -402,9 +452,10 @@ func testGatewayForCandidates(t *testing.T, providerNames []string, fc failoverC
adapters[name] = newFakeAdapter(name)
providers[name] = &ProviderConfig{Models: []string{"shared"}}
}
reg, err := newModelRegistry(adapters, &Config{Providers: providers}, func(string, ...any) {})
cfg := &Config{Providers: providers}
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
require.NoError(t, err)
return &Gateway{registry: reg, failoverHealth: newRequestHealthTracker(), failover: fc}
return &Gateway{registry: reg, failoverHealth: newRequestHealthTracker(), failover: fc, cfg: cfg, name: "test"}
}
func candNames(cands []resolveCandidate) []string {
@@ -475,14 +526,127 @@ func TestOrderedFailoverCandidates_DiscoveryUnhealthy_DeprioritizedNotDropped(t
assert.Equal(t, []string{"beta", "alpha"}, candNames(got), "discovery-unhealthy must be deprioritized (moved after), never dropped from the list")
}
func TestOrderedFailoverCandidates_MaxAttemptsCapsList(t *testing.T) {
// TestOrderedFailoverCandidates_DoesNotCap proves orderedFailoverCandidates
// itself no longer applies maxAttempts — capFailoverCandidates does, as the
// LAST step of runMeteredCall's own sequence, after cost filtering.
func TestOrderedFailoverCandidates_DoesNotCap(t *testing.T) {
t.Parallel()
g := testGatewayForCandidates(t, []string{"alpha", "beta", "gamma"}, failoverConfig{enabled: true, maxAttempts: 2})
primary := resolveCandidate{providerName: "alpha"}
extra := []resolveCandidate{{providerName: "beta"}, {providerName: "gamma"}}
got := g.orderedFailoverCandidates(primary, extra)
assert.Len(t, got, 2, "maxAttempts must cap the total candidate list, primary included")
assert.Equal(t, []string{"alpha", "beta"}, candNames(got))
assert.Equal(t, []string{"alpha", "beta", "gamma"}, candNames(got), "orderedFailoverCandidates must return the full health/discovery-ordered list uncapped")
}
func TestCapFailoverCandidates(t *testing.T) {
t.Parallel()
all := []resolveCandidate{{providerName: "alpha"}, {providerName: "beta"}, {providerName: "gamma"}}
got := capFailoverCandidates(all, 2)
assert.Equal(t, []string{"alpha", "beta"}, candNames(got), "maxAttempts must cap the total candidate list, primary included")
got = capFailoverCandidates(all, 10)
assert.Len(t, got, 3, "a cap larger than the list must not truncate anything")
got = capFailoverCandidates(all, 0)
assert.Len(t, got, 3, "max <= 0 must be a defensive no-op")
}
// --- resolveUnifiedPricing / candidateCostAllowed / filterCandidatesByCost ---
func TestResolveUnifiedPricing_CanonicalFirst_ThenBareFallback(t *testing.T) {
t.Parallel()
overrides := map[string]*ModelPricing{
"beta/shared": {InputPerM: 1, OutputPerM: 2},
"shared": {InputPerM: 5, OutputPerM: 6},
}
p, ok := resolveUnifiedPricing("beta/shared", "shared", overrides)
require.True(t, ok)
assert.Equal(t, ModelPricing{InputPerM: 1, OutputPerM: 2}, p, "an override keyed on the canonical id must win over the bare fallback — the exact case unifiedCostMicros itself resolves this way")
}
func TestResolveUnifiedPricing_FallsBackToBare(t *testing.T) {
t.Parallel()
overrides := map[string]*ModelPricing{"shared": {InputPerM: 5, OutputPerM: 6}}
p, ok := resolveUnifiedPricing("beta/shared", "shared", overrides)
require.True(t, ok)
assert.Equal(t, ModelPricing{InputPerM: 5, OutputPerM: 6}, p)
}
func TestResolveUnifiedPricing_Unknown(t *testing.T) {
t.Parallel()
_, ok := resolveUnifiedPricing("beta/shared", "shared", nil)
assert.False(t, ok, "neither the canonical nor the bare id has a price — this must never be silently treated as zero")
}
func TestResolveUnifiedPricing_BuiltinTable(t *testing.T) {
t.Parallel()
p, ok := resolveUnifiedPricing("openai/gpt-5", "gpt-5", nil)
require.True(t, ok, "the built-in pricing table must still resolve with no operator overrides configured")
assert.Equal(t, builtinPricing["gpt-5"], p)
}
// TestCandidateCostAllowed is the direct regression test for the
// operator's binding ruling: table-driven across every named case,
// including the two-dimension requirement (cheap on one axis, dear on
// the other, must still be rejected).
func TestCandidateCostAllowed(t *testing.T) {
t.Parallel()
cheap := ModelPricing{InputPerM: 1, OutputPerM: 2}
dear := ModelPricing{InputPerM: 3, OutputPerM: 4}
mixedCheapInputDearOutput := ModelPricing{InputPerM: 0.5, OutputPerM: 10}
zero := ModelPricing{}
tests := []struct {
name string
primary, candidate ModelPricing
primaryKnown, candidateKnown bool
want bool
}{
{name: "both known, candidate strictly cheaper on both", primary: dear, primaryKnown: true, candidate: cheap, candidateKnown: true, want: true},
{name: "both known, identical price", primary: cheap, primaryKnown: true, candidate: cheap, candidateKnown: true, want: true},
{name: "both known, candidate strictly dearer on both", primary: cheap, primaryKnown: true, candidate: dear, candidateKnown: true, want: false},
{name: "both known, cheaper input but dearer output must still reject", primary: cheap, primaryKnown: true, candidate: mixedCheapInputDearOutput, candidateKnown: true, want: false},
{name: "both known, dearer input but cheaper output must still reject", primary: mixedCheapInputDearOutput, primaryKnown: true, candidate: cheap, candidateKnown: true, want: false},
{name: "primary known, candidate unknown: never allowed", primary: dear, primaryKnown: true, candidate: ModelPricing{}, candidateKnown: false, want: false},
{name: "primary known-zero, candidate unknown: never allowed", primary: zero, primaryKnown: true, candidate: ModelPricing{}, candidateKnown: false, want: false},
{name: "primary unknown, candidate known non-zero: never allowed", primary: ModelPricing{}, primaryKnown: false, candidate: cheap, candidateKnown: true, want: false},
{name: "primary unknown, candidate known but only input non-zero: never allowed", primary: ModelPricing{}, primaryKnown: false, candidate: ModelPricing{InputPerM: 0.01}, candidateKnown: true, want: false},
{name: "primary unknown, candidate known-zero: allowed", primary: ModelPricing{}, primaryKnown: false, candidate: zero, candidateKnown: true, want: true},
{name: "both unknown: allowed", primary: ModelPricing{}, primaryKnown: false, candidate: ModelPricing{}, candidateKnown: false, want: true},
{name: "both known-zero: allowed", primary: zero, primaryKnown: true, candidate: zero, candidateKnown: true, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := candidateCostAllowed(tt.primary, tt.primaryKnown, tt.candidate, tt.candidateKnown)
assert.Equal(t, tt.want, got)
})
}
}
func TestFilterCandidatesByCost_SkipsPricierKeepsCheaper(t *testing.T) {
t.Parallel()
g := testGatewayForCandidates(t, []string{"alpha", "beta", "gamma"}, failoverConfig{enabled: true, maxAttempts: 3})
g.cfg.Pricing = map[string]*ModelPricing{
"alpha/shared": {InputPerM: 2, OutputPerM: 4}, // primary
"beta/shared": {InputPerM: 3, OutputPerM: 4}, // dearer input -> rejected
"gamma/shared": {InputPerM: 2, OutputPerM: 4}, // equal -> allowed
}
candidates := []resolveCandidate{
{providerName: "alpha", upstreamModel: "shared", canonical: "alpha/shared"},
{providerName: "beta", upstreamModel: "shared", canonical: "beta/shared"},
{providerName: "gamma", upstreamModel: "shared", canonical: "gamma/shared"},
}
got := g.filterCandidatesByCost(candidates, "shared")
assert.Equal(t, []string{"alpha", "gamma"}, candNames(got))
}
func TestFilterCandidatesByCost_FewerThanTwoCandidates_NoOp(t *testing.T) {
t.Parallel()
g := testGatewayForCandidates(t, []string{"alpha"}, failoverConfig{enabled: true, maxAttempts: 3})
candidates := []resolveCandidate{{providerName: "alpha", upstreamModel: "shared", canonical: "alpha/shared"}}
got := g.filterCandidatesByCost(candidates, "shared")
assert.Equal(t, candidates, got)
}
// =====================================================================
@@ -504,6 +668,13 @@ func twoProviderFailoverConfig(srvAlpha, srvBeta *httptest.Server) *Config {
cfg.Users = &UsersConfig{Inline: []*UserConfig{
{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}},
}}
// Explicitly opt in: FailoverConfig.Enabled now defaults to false
// (coordinator ruling) — every test in this file that exercises actual
// failover behavior must opt in on purpose, the same way an operator
// would, rather than relying on a default this feature no longer ships
// with.
yes := true
cfg.Failover = FailoverConfig{Enabled: &yes}
return cfg
}
@@ -570,6 +741,8 @@ func TestHandleChat_Failover_FallsThroughAndServes(t *testing.T) {
}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
yes := true
cfg.Failover = FailoverConfig{Enabled: &yes}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
@@ -699,10 +872,17 @@ func TestHandleChat_Failover_RequestUnhealthy_SkippedWithoutBeingCalled(t *testi
}
// TestHandleChat_Failover_DiscoveryUnhealthy_StillTried proves discovery
// health is an ordering hint, never a hard skip: a provider whose
// DISCOVERY breaker is open must still be attempted (and can still serve
// the response) when it is the only one that actually works. Would FAIL
// if discoveryHealthy were (mis-)used as a skip gate.
// health is never a HARD SKIP: a provider whose DISCOVERY breaker is
// open must still be attempted, and can still serve the response, when
// beta (the only other candidate) fails. Would FAIL if discoveryHealthy
// were used to exclude a candidate outright, but does NOT independently
// prove ordering (deleting the deprioritization partition in
// orderedFailoverCandidates leaves this test passing too, since beta
// fails either way and alpha is tried regardless of position) — the
// ordering claim itself is covered only by
// TestOrderedFailoverCandidates_DiscoveryUnhealthy_DeprioritizedNotDropped
// (adversarial-review correction: this comment previously overstated
// what this test proves).
func TestHandleChat_Failover_DiscoveryUnhealthy_StillTried(t *testing.T) {
alphaSrv := jsonServer(http.StatusOK, successRespBody) // alpha's CHAT endpoint works fine
defer alphaSrv.Close()
@@ -727,10 +907,9 @@ func TestHandleChat_Failover_DiscoveryUnhealthy_StillTried(t *testing.T) {
st.mu.Unlock()
require.False(t, gw.registry.discoveryHealthy("alpha"))
// beta is healthy by every signal but its own chat endpoint 500s —
// ordering puts beta (discovery-healthy) FIRST, so this proves the
// request actually reaches alpha SECOND, not that alpha happened to
// be tried first anyway.
// beta is healthy by every signal but its own chat endpoint 500s, so
// the request can only succeed if alpha is tried at some point in
// the chain despite its open discovery breaker.
body := map[string]any{"model": "shared", "messages": []any{}}
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rec := httptest.NewRecorder()
@@ -864,11 +1043,507 @@ func TestHandleChat_Failover_CachedUnderWinningProviderKey(t *testing.T) {
assert.Equal(t, int64(1), atomic.LoadInt64(&betaCalls), "the second request must hit cache under beta's key, not call beta's backend again")
}
// --- cost guard (operator ruling): never fail over to a pricier candidate ---
// costGuardConfig builds a two-provider (alpha, beta) config exactly like
// twoProviderFailoverConfig but WITHOUT setting cfg.Pricing, so each test
// configures its own overrides for its own case.
func costGuardConfig(srvAlpha, srvBeta *httptest.Server) *Config {
cfg := twoProviderFailoverConfig(srvAlpha, srvBeta)
cfg.Providers["alpha"].Models = []string{"shared"}
cfg.Providers["beta"].Models = []string{"shared"}
return cfg
}
// driveCostGuardRequest posts one chat-completion request for "shared"
// through h and returns the recorder and everything logged to stderr
// while it ran.
func driveCostGuardRequest(t *testing.T, h http.Handler) (*httptest.ResponseRecorder, string) {
t.Helper()
origStderr := captureStderrStart()
body := map[string]any{"model": "shared", "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec, captureStderrStop(origStderr)
}
// TestHandleChat_Failover_CostGuard_SkipsPricierCandidate is the direct
// regression test for the operator's requirement: a candidate priced
// higher than the primary must never serve. Would FAIL if the cost guard
// were reverted (mutation-tested in this branch's own history).
func TestHandleChat_Failover_CostGuard_SkipsPricierCandidate(t *testing.T) {
var betaCalls int64
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := countingServer(http.StatusOK, successRespBody, &betaCalls)
defer betaSrv.Close()
cfg := costGuardConfig(alphaSrv, betaSrv)
cfg.Pricing = map[string]*ModelPricing{
"alpha/shared": {InputPerM: 1, OutputPerM: 2},
"beta/shared": {InputPerM: 1, OutputPerM: 3}, // dearer on output only — still must be rejected
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
rec, logOutput := driveCostGuardRequest(t, h)
assert.Equal(t, http.StatusInternalServerError, rec.Code, "beta is dearer, so alpha's own failure must be returned as-is, body=%s", rec.Body.String())
assert.Equal(t, int64(0), atomic.LoadInt64(&betaCalls), "beta must never be called once the cost guard rejects it")
assert.Contains(t, logOutput, "alpha")
assert.Contains(t, logOutput, "beta")
assert.Contains(t, logOutput, "shared")
}
// TestHandleChat_Failover_CostGuard_AllowsCheaperOrEqualCandidate is the
// mirror positive case: a candidate priced at or below the primary must
// still serve normally.
func TestHandleChat_Failover_CostGuard_AllowsCheaperOrEqualCandidate(t *testing.T) {
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := jsonServer(http.StatusOK, successRespBody)
defer betaSrv.Close()
cfg := costGuardConfig(alphaSrv, betaSrv)
cfg.Pricing = map[string]*ModelPricing{
"alpha/shared": {InputPerM: 2, OutputPerM: 4},
"beta/shared": {InputPerM: 2, OutputPerM: 4}, // exactly equal — allowed
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
rec, _ := driveCostGuardRequest(t, h)
assert.Equal(t, http.StatusOK, rec.Code, "beta is equally priced, so failover must still serve it, body=%s", rec.Body.String())
assert.Equal(t, successRespBody, rec.Body.String())
}
// TestHandleChat_Failover_CostGuard_PrimaryKnown_CandidateUnknown_Skips
// covers the operator's first unknown-pricing ruling: primary priced,
// candidate unpriced — never allowed, since an unknown price cannot be
// proven not-more-expensive.
func TestHandleChat_Failover_CostGuard_PrimaryKnown_CandidateUnknown_Skips(t *testing.T) {
var betaCalls int64
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := countingServer(http.StatusOK, successRespBody, &betaCalls)
defer betaSrv.Close()
cfg := costGuardConfig(alphaSrv, betaSrv)
cfg.Pricing = map[string]*ModelPricing{
"alpha/shared": {InputPerM: 1, OutputPerM: 2},
// beta has no override, and "shared"/"beta/shared" is not in the
// built-in table either — its price is genuinely unknown.
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
rec, _ := driveCostGuardRequest(t, h)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, int64(0), atomic.LoadInt64(&betaCalls))
}
// TestHandleChat_Failover_CostGuard_PrimaryUnknown_CandidateKnownNonZero_Skips
// covers the operator's second unknown-pricing ruling: moving from
// unbilled to billed is itself a spend increase.
func TestHandleChat_Failover_CostGuard_PrimaryUnknown_CandidateKnownNonZero_Skips(t *testing.T) {
var betaCalls int64
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := countingServer(http.StatusOK, successRespBody, &betaCalls)
defer betaSrv.Close()
cfg := costGuardConfig(alphaSrv, betaSrv)
cfg.Pricing = map[string]*ModelPricing{
"beta/shared": {InputPerM: 1, OutputPerM: 2}, // alpha has no override at all: unknown
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
rec, _ := driveCostGuardRequest(t, h)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Equal(t, int64(0), atomic.LoadInt64(&betaCalls))
}
// TestHandleChat_Failover_CostGuard_PrimaryUnknown_CandidateKnownZero_Allows
// covers the ruling's own stated exception: a candidate that is known,
// explicit free can never be more expensive than an unknown amount.
func TestHandleChat_Failover_CostGuard_PrimaryUnknown_CandidateKnownZero_Allows(t *testing.T) {
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := jsonServer(http.StatusOK, successRespBody)
defer betaSrv.Close()
cfg := costGuardConfig(alphaSrv, betaSrv)
cfg.Pricing = map[string]*ModelPricing{
"beta/shared": {InputPerM: 0, OutputPerM: 0}, // explicit, known-zero override
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
rec, _ := driveCostGuardRequest(t, h)
assert.Equal(t, http.StatusOK, rec.Code, "a known-zero candidate can never be more expensive than an unknown primary, body=%s", rec.Body.String())
}
// TestHandleChat_Failover_CostGuard_BothUnknown_Allows and
// TestHandleChat_Failover_CostGuard_BothKnownZero_Allows cover the
// ruling's "equal, so allowed" cases.
func TestHandleChat_Failover_CostGuard_BothUnknown_Allows(t *testing.T) {
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := jsonServer(http.StatusOK, successRespBody)
defer betaSrv.Close()
cfg := costGuardConfig(alphaSrv, betaSrv) // no cfg.Pricing at all
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
rec, _ := driveCostGuardRequest(t, h)
assert.Equal(t, http.StatusOK, rec.Code, "both unknown must be treated as equal, body=%s", rec.Body.String())
}
func TestHandleChat_Failover_CostGuard_BothKnownZero_Allows(t *testing.T) {
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := jsonServer(http.StatusOK, successRespBody)
defer betaSrv.Close()
cfg := costGuardConfig(alphaSrv, betaSrv)
cfg.Pricing = map[string]*ModelPricing{
"alpha/shared": {InputPerM: 0, OutputPerM: 0},
"beta/shared": {InputPerM: 0, OutputPerM: 0},
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
rec, _ := driveCostGuardRequest(t, h)
assert.Equal(t, http.StatusOK, rec.Code, "both known-zero must be treated as equal, body=%s", rec.Body.String())
}
// TestHandleChat_Failover_CostGuard_PerProviderPricingOverride_RealShape is
// the requested production-shape case: the SAME bare model id
// (a real, built-in-priced model — "gpt-5") served by two providers,
// where alpha has NO override (resolves via the built-in table) and beta
// has an operator-set per-provider override making it dearer on the
// canonical "beta/gpt-5" key. Proves the guard resolves pricing through
// the exact canonical-then-bare path unifiedCostMicros itself uses, not
// a parallel, simplified notion of price — the whole point of ruling 1.
func TestHandleChat_Failover_CostGuard_PerProviderPricingOverride_RealShape(t *testing.T) {
const realModel = "gpt-5"
var betaCalls int64
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := countingServer(http.StatusOK, successRespBody, &betaCalls)
defer betaSrv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{
"alpha": {Type: "openai", BaseURL: alphaSrv.URL, APIKey: "k", Models: []string{realModel}},
"beta": {Type: "openai", BaseURL: betaSrv.URL, APIKey: "k", Models: []string{realModel}},
}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
yes := true
cfg.Failover = FailoverConfig{Enabled: &yes}
// Only beta gets a per-provider override — alpha resolves gpt-5's
// price from builtinPricing instead, exactly the real production
// shape a per-provider Pricing override produces.
builtin := builtinPricing[realModel]
cfg.Pricing = map[string]*ModelPricing{
"beta/" + realModel: {InputPerM: builtin.InputPerM * 10, OutputPerM: builtin.OutputPerM * 10},
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
origStderr := captureStderrStart()
body := map[string]any{"model": realModel, "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
logOutput := captureStderrStop(origStderr)
assert.Equal(t, http.StatusInternalServerError, rec.Code, "beta's per-provider override makes it 10x alpha's built-in price, so it must be rejected, body=%s", rec.Body.String())
assert.Equal(t, int64(0), atomic.LoadInt64(&betaCalls))
assert.Contains(t, logOutput, "alpha")
assert.Contains(t, logOutput, "beta")
assert.Contains(t, logOutput, realModel)
}
// --- adversarial-review regressions (F1-F4, F6, F7) ---
// TestHandleChat_Failover_F1_StreamOptionsNotLeakedAcrossCandidates is the
// direct regression test for adversarial-review finding F1: an
// openai-type adapter mutates req in place (chatCompletion forces
// stream_options.include_usage=true onto it BEFORE it ever calls the
// upstream, provider_openai.go), so reusing one shared req map across
// candidates let a later candidate read back an earlier candidate's own
// forced injection and misreport clientAskedUsage=true to a client that
// never asked for streamed usage — leaking the gateway's own
// deliberately-suppressed usage-only SSE chunk. alpha fails before ever
// reaching the network (its own mutation to its OWN req copy still
// happens first, exactly like the real bug), beta serves a real SSE
// stream containing a usage-only chunk the client never asked for.
func TestHandleChat_Failover_F1_StreamOptionsNotLeakedAcrossCandidates(t *testing.T) {
const contentChunk = "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n"
const usageOnlyChunk = "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n"
const doneMarker = "data: [DONE]\n\n"
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fl, ok := w.(http.Flusher)
require.True(t, ok)
for _, chunk := range []string{contentChunk, usageOnlyChunk, doneMarker} {
_, _ = w.Write([]byte(chunk))
fl.Flush()
}
}))
defer betaSrv.Close()
cfg := twoProviderFailoverConfig(alphaSrv, betaSrv)
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
// The client explicitly does NOT set stream_options at all — it
// never asked for usage in the stream.
body := map[string]any{"model": "shared", "stream": true, "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rw := newRecordingWriter()
h.ServeHTTP(rw, req)
require.Equal(t, http.StatusOK, rw.status)
assert.Contains(t, rw.buf.String(), `"content":"Hi"`, "the real content chunk must still be forwarded")
assert.NotContains(t, rw.buf.String(), `"prompt_tokens":5`, "a client that never asked for stream usage must never receive it, even though ALPHA's own (failed, discarded) attempt forced include_usage=true on ITS OWN copy of the request")
}
// TestHandleChat_Failover_F2_StaleSSEHeadersDoNotSurviveToPlainResponse is
// the direct regression test for adversarial-review finding F2:
// newSSEWriter (sse.go) stages Content-Type/Cache-Control/
// X-Accel-Buffering on sw.Header() the moment a streaming attempt starts,
// before any byte is written. alpha enters that streaming path (a real
// 200 + text/event-stream response) but its connection is severed before
// any complete SSE event is dispatched — an error, satisfying THE HARD
// CONSTRAINT (nothing was actually WRITTEN to the client) — so failover
// proceeds to beta, which answers a plain, non-streaming JSON body.
func TestHandleChat_Failover_F2_StaleSSEHeadersDoNotSurviveToPlainResponse(t *testing.T) {
alphaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
hj, ok := w.(http.Hijacker)
require.True(t, ok)
conn, _, hjErr := hj.Hijack()
if hjErr == nil {
// Closing a hijacked chunked-encoding connection with no
// terminating chunk produces a genuine "unexpected EOF" on
// the client's read side (verified empirically), not a
// clean end-of-stream — readSSE returns that as a real
// error without ever dispatching an event, so
// sw.writeData/sw.WriteHeader is never reached even though
// newSSEWriter already staged its three headers.
_ = conn.Close()
}
}))
defer alphaSrv.Close()
betaSrv := jsonServer(http.StatusOK, successRespBody)
defer betaSrv.Close()
cfg := twoProviderFailoverConfig(alphaSrv, betaSrv)
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
body := map[string]any{"model": "shared", "stream": true, "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String())
assert.Equal(t, successRespBody, rec.Body.String())
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"), "beta's own Content-Type must win, not alpha's stale text/event-stream")
assert.Empty(t, rec.Header().Get("Cache-Control"), "alpha's staged SSE header must never survive into beta's plain JSON response")
assert.Empty(t, rec.Header().Get("X-Accel-Buffering"), "alpha's staged SSE header must never survive into beta's plain JSON response")
}
// TestHandleChat_Failover_F3_401IsAFailure_OpensRequestHealthBreaker is the
// direct regression test for adversarial-review finding F3: a provider
// answering 401 to every request (a dead API key — exactly the "masking
// a broken provider key" risk this feature exists to reduce) must
// eventually be skipped, not retried forever. Would FAIL under the
// reverted isTransient-based classification, since isTransient returns
// false for any non-429 4xx.
func TestHandleChat_Failover_F3_401IsAFailure_OpensRequestHealthBreaker(t *testing.T) {
var alphaCalls, betaCalls int64
alphaSrv := countingServer(http.StatusUnauthorized, `{"error":{"message":"invalid api key","type":"authentication_error"}}`, &alphaCalls)
defer alphaSrv.Close()
betaSrv := countingServer(http.StatusOK, successRespBody, &betaCalls)
defer betaSrv.Close()
cfg := twoProviderFailoverConfig(alphaSrv, betaSrv)
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
body := map[string]any{"model": "shared", "messages": []any{}}
for i := 0; i < requestBreakerFailureThreshold; i++ {
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "every one of these must still fail over to beta and succeed")
}
require.Equal(t, int64(requestBreakerFailureThreshold), atomic.LoadInt64(&alphaCalls))
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, int64(requestBreakerFailureThreshold), atomic.LoadInt64(&alphaCalls), "alpha must be skipped after 3 consecutive 401s, not called a 4th time")
assert.Equal(t, int64(requestBreakerFailureThreshold+1), atomic.LoadInt64(&betaCalls))
}
// TestHandleChat_Failover_F4_SharedDeadlineExhausted_NeverMisattributedToNextCandidate
// is the direct regression test for adversarial-review finding F4: alpha
// burns the ENTIRE shared request context sleeping past its deadline;
// beta must never be dialed at all (an attempt after the deadline is
// already spent would fail near-instantly for a reason that has nothing
// to do with beta's own health), never be recorded as having failed, and
// the client-facing error must name alpha, not beta.
func TestHandleChat_Failover_F4_SharedDeadlineExhausted_NeverMisattributedToNextCandidate(t *testing.T) {
alphaSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond) // well past the request's own 50ms deadline below
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(successRespBody))
}))
defer alphaSrv.Close()
var betaCalls int64
betaSrv := countingServer(http.StatusOK, successRespBody, &betaCalls)
defer betaSrv.Close()
cfg := twoProviderFailoverConfig(alphaSrv, betaSrv)
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
gw, ok := h.(*Gateway)
require.True(t, ok)
gw.limiter.spawn = func(f func()) { f() }
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
strings.NewReader(`{"model":"shared","messages":[{"role":"user","content":"hi"}]}`)).WithContext(ctx)
req.Header.Set("Authorization", "Bearer sk-alice")
req.Header.Set("Content-Type", "application/json")
origStderr := captureStderrStart()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
logOutput := captureStderrStop(origStderr)
assert.Equal(t, int64(0), atomic.LoadInt64(&betaCalls), "beta must never be dialed once the shared deadline is already spent")
assert.True(t, gw.failoverHealth.healthy("beta"), "beta must never be marked unhealthy for a timeout that was alpha's own fault")
now := time.Now()
betaAttempts, _ := gw.limiter.getCounter(kindProvider, "beta", metricProvAttempt, windowDay, now)
assert.Equal(t, int64(0), betaAttempts, "beta's own dashboard attempt counter must not move either")
assert.Contains(t, logOutput, "alpha", "the error must name the provider that actually failed")
}
// TestHandleChat_Failover_F6_RetryAttemptsDoNotOpenBreakerOnOneRequest is
// the direct regression test for adversarial-review finding F6: with
// retry.enabled and attempts:3, ONE client request against a
// permanently-failing provider must count as ONE outcome for the
// request-health gate, not one per raw retry attempt — otherwise a
// single request alone could reach requestBreakerFailureThreshold (3)
// purely from ITS OWN internal retries.
func TestHandleChat_Failover_F6_RetryAttemptsDoNotOpenBreakerOnOneRequest(t *testing.T) {
var alphaCalls int64
alphaSrv := countingServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`, &alphaCalls)
defer alphaSrv.Close()
betaSrv := jsonServer(http.StatusOK, successRespBody)
defer betaSrv.Close()
cfg := twoProviderFailoverConfig(alphaSrv, betaSrv)
cfg.Retry = RetryConfig{Enabled: true, Attempts: 3, Backoff: "1ms"}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
gw, ok := h.(*Gateway)
require.True(t, ok)
body := map[string]any{"model": "shared", "messages": []any{}}
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "must still fail over to beta after alpha's own retries are exhausted")
// retry.attempts=3 means 4 raw HTTP attempts against alpha for this
// ONE client request (retryPolicy's own doc comment, retry.go).
require.Equal(t, int64(4), atomic.LoadInt64(&alphaCalls))
// The request-health gate must still read this as exactly ONE
// failure, not four — well below requestBreakerFailureThreshold (3).
assert.True(t, gw.failoverHealth.healthy("alpha"), "one client request's internal retries must count as one outcome for the request-health gate, not one per raw attempt")
}
// TestHandleChat_Failover_F7_CachedFailoverResponse_NeverHitsDeadPrimary is
// the direct regression test for adversarial-review finding F7: even
// while alpha stays permanently broken, a SECOND identical request must
// be served from beta's cache entry WITHOUT alpha's dead backend being
// hit again on that second request — proving cache lookups run for
// every candidate BEFORE any of them is attempted, not interleaved with
// each candidate's own turn.
func TestHandleChat_Failover_F7_CachedFailoverResponse_NeverHitsDeadPrimary(t *testing.T) {
var alphaCalls, betaCalls int64
alphaSrv := countingServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`, &alphaCalls)
defer alphaSrv.Close()
betaSrv := countingServer(http.StatusOK, successRespBody, &betaCalls)
defer betaSrv.Close()
redisLn := newBehavioralRedisServer(t)
cfg := twoProviderFailoverConfig(alphaSrv, betaSrv)
cfg.Redis = &RedisConfig{Address: redisLn.Addr().String()}
cfg.Cache = CacheConfig{Enabled: true, TTL: "1m", MaxBodyBytes: 1 << 20}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
reqBody := map[string]any{"model": "shared", "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
req1 := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", reqBody)
rec1 := httptest.NewRecorder()
h.ServeHTTP(rec1, req1)
require.Equal(t, http.StatusOK, rec1.Code)
require.Equal(t, int64(1), atomic.LoadInt64(&alphaCalls))
require.Equal(t, int64(1), atomic.LoadInt64(&betaCalls))
req2 := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", reqBody)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req2)
assert.Equal(t, http.StatusOK, rec2.Code)
assert.Equal(t, "hit", rec2.Header().Get("X-Llmgw-Cache"))
assert.Equal(t, int64(1), atomic.LoadInt64(&alphaCalls), "alpha's dead backend must NOT be hit again just to discover beta's cache entry")
assert.Equal(t, int64(1), atomic.LoadInt64(&betaCalls))
}
// TestHandleChat_Failover_SingleProvider_UpstreamError_BehavesExactlyAsBefore
// pins the "single-provider deployment behaves exactly as before"
// requirement explicitly for this branch, with failover left at its
// (enabled) default: with no second candidate to try, an upstream error
// must be returned to the client unchanged, identical to v0.1 behavior.
// (now disabled) default: with no second candidate to try, an upstream
// error must be returned to the client unchanged, identical to v0.1
// behavior, regardless of the enabled switch either way.
func TestHandleChat_Failover_SingleProvider_UpstreamError_BehavesExactlyAsBefore(t *testing.T) {
srv := jsonServer(http.StatusTooManyRequests, `{"error":{"message":"rate limited upstream","type":"rate_limit_error"}}`)
defer srv.Close()
@@ -898,6 +1573,43 @@ func TestHandleChat_Failover_SingleProvider_UpstreamError_BehavesExactlyAsBefore
assert.Equal(t, "upstream_error", out.Error.Type)
}
// TestHandleChat_Failover_DefaultConfig_ByteIdenticalToPreFailover is the
// regression test for the coordinator's default-off ruling: a Config that
// never mentions "failover" at all — the shape of every config written
// before this feature existed, and the shape most operators keep using
// after upgrading without opting in — must behave byte-identically to
// having no failover code at all. Would FAIL if validateFailoverConfig's
// default reverted to enabled.
func TestHandleChat_Failover_DefaultConfig_ByteIdenticalToPreFailover(t *testing.T) {
var betaCalls int64
alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`)
defer alphaSrv.Close()
betaSrv := countingServer(http.StatusOK, successRespBody, &betaCalls)
defer betaSrv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{
"alpha": {Type: "openai", BaseURL: alphaSrv.URL, APIKey: "k", Models: []string{"shared"}},
"beta": {Type: "openai", BaseURL: betaSrv.URL, APIKey: "k", Models: []string{"shared"}},
}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
// cfg.Failover is deliberately left untouched — the zero value, what
// every config predating this feature has.
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
require.NoError(t, err)
body := map[string]any{"model": "shared", "messages": []any{}}
req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code, "an unconfigured Failover block must leave alpha's own 500 unchanged")
assert.Equal(t, int64(0), atomic.LoadInt64(&betaCalls), "beta must never be called when failover was never opted into")
}
// TestHandleChat_Failover_ExplicitlyDisabled_5xxNeverFallsThrough proves
// the on/off switch actually gates the behavior end to end.
func TestHandleChat_Failover_ExplicitlyDisabled_5xxNeverFallsThrough(t *testing.T) {