fix(quota): veto 5xx errors + tighten patterns to stop false-positive swaps
v0.2.2's 2-poll confirmation was insufficient because Anthropic 500/503 errors are printed into Claude Code's conversation transcript and stay visible in every tmux capture until the user scrolls. A persistent server error would confirm on the second poll and still trigger a swap. Root cause: the pattern "rate limit" (bare substring) matched any 500 payload that happened to mention rate limits in its error text. Real HTTP 429s from Anthropic are typed as "rate_limit_error" in the error payload — and that's the signature we should actually key on. - Remove "rate limit" from quotaPatterns (too generic — matches transcripts). - Add "rate_limit_error" (Anthropic's typed 429 error) and "5-hour limit". - Add serverErrorPatterns veto: "api_error", "overloaded_error", "internal server error", "api error: 5". When any is present in the pane, isQuotaExhausted returns false even if a quota pattern matched. - 4 new subtests covering the veto paths + sanity that real 429s pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c5f8384fa
commit
62e98cb9e7
3 changed files with 102 additions and 23 deletions
|
|
@ -28,25 +28,39 @@ func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
|||
return m.paneOutput[session], nil
|
||||
}
|
||||
|
||||
// TestIsQuotaExhausted verifies pattern matching on pane output.
|
||||
// TestIsQuotaExhausted verifies pattern matching on pane output, including
|
||||
// the server-error veto that prevents 5xx from being mistaken for quota.
|
||||
func TestIsQuotaExhausted(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"You've hit your limit for Claude Pro.", true},
|
||||
{"rate limit exceeded", true},
|
||||
{"quota exceeded for this period", true},
|
||||
{"Usage limit reached", true},
|
||||
{"Too many requests", true},
|
||||
{"Some normal output ❯", false},
|
||||
{"❯ ", false},
|
||||
{"still running 5s · ", false},
|
||||
{"friendly hit message", "You've hit your limit for Claude Pro.", true},
|
||||
{"typed rate_limit_error", `{"error":{"type":"rate_limit_error"}}`, true},
|
||||
{"quota exceeded", "quota exceeded for this period", true},
|
||||
{"usage limit", "Usage limit reached", true},
|
||||
{"too many requests", "Too many requests", true},
|
||||
{"5-hour limit", "You've reached the 5-hour limit", true},
|
||||
|
||||
{"normal output", "Some normal output ❯", false},
|
||||
{"empty prompt", "❯ ", false},
|
||||
{"status line", "still running 5s · ", false},
|
||||
|
||||
// Server-error veto cases — these MUST NOT trigger a swap.
|
||||
{"api_error 500 veto", `API Error: 500 {"type":"error","error":{"type":"api_error","message":"Internal server error"}} rate limit`, false},
|
||||
{"overloaded_error veto", `{"error":{"type":"overloaded_error"}} rate limit`, false},
|
||||
{"internal server error veto", "Internal Server Error — rate limit mentioned elsewhere", false},
|
||||
|
||||
// Real 429 should still pass even if generic words are around.
|
||||
{"real rate_limit_error wins", `{"error":{"type":"rate_limit_error","message":"Rate limited"}}`, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isQuotaExhausted(c.input); got != c.want {
|
||||
t.Errorf("isQuotaExhausted(%q) = %v, want %v", c.input, got, c.want)
|
||||
}
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isQuotaExhausted(c.input); got != c.want {
|
||||
t.Errorf("isQuotaExhausted(%q) = %v, want %v", c.input, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +90,7 @@ func TestPollTriggersSwitchOnTwoBlockedPoolWithReset(t *testing.T) {
|
|||
tc.sessions["ccl-auto-0"] = true
|
||||
tc.sessions["ccl-auto-1"] = true
|
||||
tc.paneOutput["ccl-auto-0"] = "You've hit your limit for Claude Pro. resets in 45 minutes"
|
||||
tc.paneOutput["ccl-auto-1"] = "rate limit exceeded — resets at 8pm"
|
||||
tc.paneOutput["ccl-auto-1"] = `{"error":{"type":"rate_limit_error"}} — resets at 8pm`
|
||||
|
||||
s := state.New("")
|
||||
s.SetActiveAccount("compte1")
|
||||
|
|
@ -182,7 +196,7 @@ func TestPollNoTriggerWhenBelowThreshold(t *testing.T) {
|
|||
tc := newMockTmux()
|
||||
tc.sessions["ccl-auto-0"] = true
|
||||
tc.sessions["ccl-auto-1"] = true
|
||||
tc.paneOutput["ccl-auto-0"] = "rate limit exceeded"
|
||||
tc.paneOutput["ccl-auto-0"] = "You've hit your limit"
|
||||
tc.paneOutput["ccl-auto-1"] = "❯ " // fine
|
||||
|
||||
s := state.New("")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue