Skip to content

Commit 9293db4

Browse files
Bowl42claude
andcommitted
fix(fal): classify 403 balance-exhausted as insufficient_balance, not auth_failure
When a fal (fal.ai) account runs out of credit, fal returns HTTP 403 with a body like `{"detail":"User is locked. Reason: TOP_UP."}` or "...Reason: Exhausted balance. Top up your balance at fal.ai/dashboard/billing.". The fal adapter previously classified ALL 401/403 as Scope=ScopeKey / Reason=auth_failure, which maps to a fixed 1-hour provider-level cooldown (the "bad key, needs human intervention" policy). That is wrong for an out-of-credit state: the key is valid and recovery is self-service — after a top-up the account works again immediately, yet maxx kept the provider cooled for up to an hour. Fix: detect the billing/lock case in the fal doJSON error classifier — a 403 (or 402) whose `detail` matches billing signals (TOP_UP / "Exhausted balance" / "User is locked" / "top up" / insufficient / balance, case-insensitive) — and classify it with a new dedicated CooldownReasonInsufficientBalance. It stays ScopeKey and non-retryable (retrying now won't help), but maps to a short fixed 2-minute cooldown so the provider recovers promptly post top-up, and the reason string in logs/DB is now "insufficient_balance" instead of the misleading "auth_failure". Genuine auth failures (401 always; 403 without billing signals) remain auth_failure / 1h as before. Adds the CooldownReason enum value in both canonical places (domain and the cooldown policy package) plus its 2m FixedDurationPolicy, and focused tests in the fal package (403 TOP_UP/Exhausted vs 401 / plain 403) and the cooldown package (new reason maps short, shorter than auth_failure). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 82a1bd4 commit 9293db4

5 files changed

Lines changed: 198 additions & 18 deletions

File tree

internal/adapter/provider/fal/adapter.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,16 @@ func (a *Adapter) doJSON(ctx context.Context, method, url string, body []byte) (
145145
fmt.Sprintf("fal returned status %d", resp.StatusCode),
146146
)
147147
proxyErr.HTTPStatusCode = resp.StatusCode
148-
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
148+
if isFalBalanceLocked(resp.StatusCode, respBody) {
149+
// fal returns 403 (occasionally 402) with a "User is locked. Reason:
150+
// TOP_UP." / "Exhausted balance." detail when the account runs out of
151+
// credit. The key is valid and recovery is self-service (top up), so
152+
// this must NOT get the heavy 1h auth-failure cooldown — a short
153+
// key-scoped cooldown lets the provider recover promptly post top-up.
154+
proxyErr.Scope = domain.ScopeKey
155+
proxyErr.Reason = domain.CooldownReasonInsufficientBalance
156+
proxyErr.Retryable = false
157+
} else if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
149158
proxyErr.Scope = domain.ScopeKey
150159
proxyErr.Reason = domain.CooldownReasonAuthFailure
151160
proxyErr.Retryable = false
@@ -161,6 +170,36 @@ func (a *Adapter) doJSON(ctx context.Context, method, url string, body []byte) (
161170
return respBody, resp.StatusCode, nil
162171
}
163172

173+
// falBalanceLockSignals are case-insensitive substrings fal uses in its 4xx
174+
// `detail` string when an account is out of credit / locked pending a top-up
175+
// (e.g. `{"detail":"User is locked. Reason: TOP_UP."}` or "...Reason: Exhausted
176+
// balance. Top up your balance at fal.ai/dashboard/billing."). These are a
177+
// billing state, NOT a bad/expired key.
178+
var falBalanceLockSignals = []string{
179+
"top_up",
180+
"top up",
181+
"exhausted balance",
182+
"user is locked",
183+
"insufficient",
184+
"balance",
185+
}
186+
187+
// isFalBalanceLocked reports whether a fal 4xx response is a billing/lock state
188+
// (out of credit) rather than a genuine auth failure. Only 402/403 bodies are
189+
// considered — a 401 is always treated as a real credential problem.
190+
func isFalBalanceLocked(code int, body []byte) bool {
191+
if code != http.StatusForbidden && code != http.StatusPaymentRequired {
192+
return false
193+
}
194+
lower := strings.ToLower(string(body))
195+
for _, sig := range falBalanceLockSignals {
196+
if strings.Contains(lower, sig) {
197+
return true
198+
}
199+
}
200+
return false
201+
}
202+
164203
func isRetryableStatus(code int) bool {
165204
switch code {
166205
case 429, 500, 502, 503, 504:

internal/adapter/provider/fal/adapter_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,3 +640,108 @@ func TestTaskIDIsURLSafe(t *testing.T) {
640640
t.Fatalf("decode round-trip failed: %v", err)
641641
}
642642
}
643+
644+
// ---- Error classification: 403 balance/lock vs genuine auth failure ----
645+
646+
// TestDoJSONBalanceLockedNotAuthFailure guards the real incident: fal returns
647+
// HTTP 403 with a "User is locked. Reason: TOP_UP." / "Exhausted balance." detail
648+
// when an account runs out of credit. That must classify as the lighter
649+
// insufficient_balance reason (short, self-recovering cooldown), NOT the 1h
650+
// auth_failure cooldown, while genuine credential problems still classify as
651+
// auth_failure.
652+
func TestDoJSONBalanceLockedNotAuthFailure(t *testing.T) {
653+
cases := []struct {
654+
name string
655+
status int
656+
body string
657+
wantScope domain.ErrorScope
658+
wantReason domain.CooldownReason
659+
}{
660+
{
661+
name: "403 TOP_UP lock",
662+
status: http.StatusForbidden,
663+
body: `{"detail":"User is locked. Reason: TOP_UP."}`,
664+
wantScope: domain.ScopeKey,
665+
wantReason: domain.CooldownReasonInsufficientBalance,
666+
},
667+
{
668+
name: "403 exhausted balance",
669+
status: http.StatusForbidden,
670+
body: `{"detail":"User is locked. Reason: Exhausted balance. Top up your balance at fal.ai/dashboard/billing."}`,
671+
wantScope: domain.ScopeKey,
672+
wantReason: domain.CooldownReasonInsufficientBalance,
673+
},
674+
{
675+
name: "402 insufficient balance",
676+
status: http.StatusPaymentRequired,
677+
body: `{"detail":"insufficient balance"}`,
678+
wantScope: domain.ScopeKey,
679+
wantReason: domain.CooldownReasonInsufficientBalance,
680+
},
681+
{
682+
name: "403 genuine auth failure (no billing signal)",
683+
status: http.StatusForbidden,
684+
body: `{"detail":"Forbidden: invalid key"}`,
685+
wantScope: domain.ScopeKey,
686+
wantReason: domain.CooldownReasonAuthFailure,
687+
},
688+
{
689+
name: "401 always auth failure",
690+
status: http.StatusUnauthorized,
691+
body: `{"detail":"Unauthorized. Reason: TOP_UP."}`, // even with a billing-ish word, 401 stays auth
692+
wantScope: domain.ScopeKey,
693+
wantReason: domain.CooldownReasonAuthFailure,
694+
},
695+
}
696+
697+
for _, tc := range cases {
698+
t.Run(tc.name, func(t *testing.T) {
699+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
700+
w.Header().Set("Content-Type", "application/json")
701+
w.WriteHeader(tc.status)
702+
_, _ = w.Write([]byte(tc.body))
703+
}))
704+
defer server.Close()
705+
706+
a := newFalAdapter(t)
707+
_, status, err := a.doJSON(t.Context(), http.MethodPost, server.URL+"/fal-ai/flux/dev", []byte(`{}`))
708+
if status != tc.status {
709+
t.Fatalf("status = %d, want %d", status, tc.status)
710+
}
711+
pe, ok := err.(*domain.ProxyError)
712+
if !ok {
713+
t.Fatalf("error = %T (%v), want *domain.ProxyError", err, err)
714+
}
715+
if pe.Scope != tc.wantScope {
716+
t.Fatalf("scope = %q, want %q", pe.Scope, tc.wantScope)
717+
}
718+
if pe.Reason != tc.wantReason {
719+
t.Fatalf("reason = %q, want %q", pe.Reason, tc.wantReason)
720+
}
721+
if pe.Retryable {
722+
t.Fatalf("balance/auth errors must be non-retryable, got Retryable=true")
723+
}
724+
})
725+
}
726+
}
727+
728+
func TestIsFalBalanceLocked(t *testing.T) {
729+
cases := []struct {
730+
code int
731+
body string
732+
want bool
733+
}{
734+
{http.StatusForbidden, `{"detail":"User is locked. Reason: TOP_UP."}`, true},
735+
{http.StatusForbidden, `{"detail":"Exhausted balance."}`, true},
736+
{http.StatusForbidden, `{"detail":"TOP UP your balance"}`, true},
737+
{http.StatusPaymentRequired, `{"detail":"insufficient funds"}`, true},
738+
{http.StatusForbidden, `{"detail":"invalid api key"}`, false},
739+
{http.StatusUnauthorized, `{"detail":"User is locked. Reason: TOP_UP."}`, false}, // 401 never billing
740+
{http.StatusTooManyRequests, `{"detail":"balance"}`, false}, // wrong status
741+
}
742+
for _, tc := range cases {
743+
if got := isFalBalanceLocked(tc.code, []byte(tc.body)); got != tc.want {
744+
t.Fatalf("isFalBalanceLocked(%d, %q) = %v, want %v", tc.code, tc.body, got, tc.want)
745+
}
746+
}
747+
}

internal/cooldown/policy.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,16 @@ func (p *ExponentialBackoffPolicy) CalculateCooldown(failureCount int) time.Dura
6262
type CooldownReason string
6363

6464
const (
65-
ReasonServerError CooldownReason = "server_error" // 5xx errors
66-
ReasonNetworkError CooldownReason = "network_error" // Connection timeout, DNS failure, etc.
67-
ReasonQuotaExhausted CooldownReason = "quota_exhausted" // API quota exhausted (fallback when no explicit time)
68-
ReasonRateLimit CooldownReason = "rate_limit_exceeded" // Rate limit (fallback when no explicit time)
69-
ReasonConcurrentLimit CooldownReason = "concurrent_limit" // Concurrent request limit (fallback when no explicit time)
70-
ReasonUnknown CooldownReason = "unknown" // Unknown error
71-
ReasonAuthFailure CooldownReason = "auth_failure" // API key invalid, expired, or account suspended
72-
ReasonModelUnavailable CooldownReason = "model_unavailable" // Model not found or access denied
73-
ReasonManual CooldownReason = "manual" // Manually frozen by admin
65+
ReasonServerError CooldownReason = "server_error" // 5xx errors
66+
ReasonNetworkError CooldownReason = "network_error" // Connection timeout, DNS failure, etc.
67+
ReasonQuotaExhausted CooldownReason = "quota_exhausted" // API quota exhausted (fallback when no explicit time)
68+
ReasonRateLimit CooldownReason = "rate_limit_exceeded" // Rate limit (fallback when no explicit time)
69+
ReasonConcurrentLimit CooldownReason = "concurrent_limit" // Concurrent request limit (fallback when no explicit time)
70+
ReasonUnknown CooldownReason = "unknown" // Unknown error
71+
ReasonAuthFailure CooldownReason = "auth_failure" // API key invalid, expired, or account suspended
72+
ReasonInsufficientBalance CooldownReason = "insufficient_balance" // Account out of credit / locked pending top-up (recovers on its own after a top-up)
73+
ReasonModelUnavailable CooldownReason = "model_unavailable" // Model not found or access denied
74+
ReasonManual CooldownReason = "manual" // Manually frozen by admin
7475
)
7576

7677
// DefaultPolicies returns the default policy configuration
@@ -109,6 +110,12 @@ func DefaultPolicies() map[CooldownReason]CooldownPolicy {
109110
ReasonAuthFailure: &FixedDurationPolicy{
110111
Duration: 1 * time.Hour,
111112
},
113+
// Insufficient balance / account locked pending top-up: fixed 2 minutes.
114+
// The key is valid — the account just ran out of credit — so recovery is
115+
// self-service (top up) and should be picked up quickly, unlike a bad key.
116+
ReasonInsufficientBalance: &FixedDurationPolicy{
117+
Duration: 2 * time.Minute,
118+
},
112119
// Model unavailable: fixed 5 minutes (model might come back)
113120
ReasonModelUnavailable: &FixedDurationPolicy{
114121
Duration: 5 * time.Minute,

internal/cooldown/policy_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package cooldown
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
// TestInsufficientBalancePolicyIsShort guards that an out-of-credit / account-
9+
// locked state (fal "TOP_UP" / "Exhausted balance" 403) recovers quickly: it
10+
// must map to a short fixed cooldown, NOT the heavy 1h auth-failure cooldown, so
11+
// the provider comes back promptly after a top-up.
12+
func TestInsufficientBalancePolicyIsShort(t *testing.T) {
13+
policies := DefaultPolicies()
14+
15+
bal, ok := policies[ReasonInsufficientBalance]
16+
if !ok {
17+
t.Fatalf("DefaultPolicies missing ReasonInsufficientBalance")
18+
}
19+
got := bal.CalculateCooldown(1)
20+
if got != 2*time.Minute {
21+
t.Fatalf("insufficient_balance cooldown = %v, want 2m", got)
22+
}
23+
24+
auth := policies[ReasonAuthFailure].CalculateCooldown(1)
25+
if got >= auth {
26+
t.Fatalf("insufficient_balance cooldown (%v) must be shorter than auth_failure (%v)", got, auth)
27+
}
28+
}

internal/domain/cooldown.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import "time"
66
type CooldownReason string
77

88
const (
9-
CooldownReasonServerError CooldownReason = "server_error"
10-
CooldownReasonNetworkError CooldownReason = "network_error"
11-
CooldownReasonQuotaExhausted CooldownReason = "quota_exhausted"
12-
CooldownReasonRateLimitExceeded CooldownReason = "rate_limit_exceeded"
13-
CooldownReasonConcurrentLimit CooldownReason = "concurrent_limit"
14-
CooldownReasonAuthFailure CooldownReason = "auth_failure"
15-
CooldownReasonModelUnavailable CooldownReason = "model_unavailable"
16-
CooldownReasonUnknown CooldownReason = "unknown"
9+
CooldownReasonServerError CooldownReason = "server_error"
10+
CooldownReasonNetworkError CooldownReason = "network_error"
11+
CooldownReasonQuotaExhausted CooldownReason = "quota_exhausted"
12+
CooldownReasonRateLimitExceeded CooldownReason = "rate_limit_exceeded"
13+
CooldownReasonConcurrentLimit CooldownReason = "concurrent_limit"
14+
CooldownReasonAuthFailure CooldownReason = "auth_failure"
15+
CooldownReasonInsufficientBalance CooldownReason = "insufficient_balance"
16+
CooldownReasonModelUnavailable CooldownReason = "model_unavailable"
17+
CooldownReasonUnknown CooldownReason = "unknown"
1718
)
1819

1920
// Cooldown represents a provider cooldown record

0 commit comments

Comments
 (0)