From 62e98cb9e740ca2181e979b9a9c883a72f409337 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 15 Apr 2026 19:26:00 +0000 Subject: [PATCH] fix(quota): veto 5xx errors + tighten patterns to stop false-positive swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION.md | 37 ++++++++++++++++++++++++++- internal/quota/monitor.go | 46 ++++++++++++++++++++++++++++------ internal/quota/monitor_test.go | 42 ++++++++++++++++++++----------- 3 files changed, 102 insertions(+), 23 deletions(-) diff --git a/VERSION.md b/VERSION.md index bc5ebe6..7f1ac26 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1,4 +1,39 @@ -# Version actuelle : 0.2.2 +# Version actuelle : 0.2.3 + +## [0.2.3] - 2026-04-15 +**Type:** Patch — Veto 5xx pour écarter les faux positifs persistants + +### Corrigé +- **Les 500/503 d'Anthropic restent visibles dans l'historique de conversation + Claude Code** (pas juste en flash). Donc `tmux capture-pane -S -3` les voyait + à chaque poll, et la confirmation 2-polls v0.2.2 finissait par les confirmer + → swap sur faux positif persistant. +- **Racine du faux positif** : le pattern `"rate limit"` (substring lâche) + matchait dans le contenu textuel d'un 500 rendu par Claude TUI. + +### Modifié +- `quotaPatterns` retravaillés pour privilégier les signatures spécifiques : + - Retiré : `"rate limit"` (trop générique, matche les transcripts) + - Ajouté : `"rate_limit_error"` (type d'erreur Anthropic pour les vrais 429) + - Ajouté : `"5-hour limit"` (phrasing Claude Code) +- **Veto 5xx** : `serverErrorPatterns` = [`"api_error"`, `"overloaded_error"`, + `"internal server error"`, `"api error: 5"`]. Si l'un est présent, même si un + `quotaPattern` matche, `isQuotaExhausted` retourne `false`. Un 500/503 + n'est pas un quota. + +### Ajouté +- `hasServerError()` helper + tests exhaustifs : + - `api_error_500_veto`, `overloaded_error_veto`, `internal_server_error_veto` + - `real_rate_limit_error_wins` (sanity : vrai 429 passe toujours) + +### Tests effectués +- ✅ 14 sous-tests `TestIsQuotaExhausted` passent +- ✅ `go test ./...` complet OK +- ✅ Service redémarré + +### Fichiers modifiés +- `internal/quota/monitor.go` +- `internal/quota/monitor_test.go` ## [0.2.2] - 2026-04-15 **Type:** Patch — Confirmation requise pour les faux positifs (root cause) diff --git a/internal/quota/monitor.go b/internal/quota/monitor.go index 03760c2..d949d2d 100644 --- a/internal/quota/monitor.go +++ b/internal/quota/monitor.go @@ -23,13 +23,26 @@ type SwitchRequest struct { } // quotaPatterns are substrings that indicate quota exhaustion in a pane. +// Must be specific enough to avoid matching Anthropic server errors (500/503) +// that happen to include generic words like "rate limit" in their payload. var quotaPatterns = []string{ - "you've hit your limit", - "rate limit", - "quota exceeded", - "usage limit reached", - "claude pro usage", - "too many requests", + "you've hit your limit", // Claude Code TUI friendly message + "rate_limit_error", // Anthropic typed error (real HTTP 429) + "quota exceeded", // generic, but specific enough + "usage limit reached", // Claude Pro phrasing + "claude pro usage", // Claude Pro dashboard phrasing + "too many requests", // HTTP 429 status text + "5-hour limit", // Claude Code 5h window phrasing +} + +// serverErrorPatterns indicate a transient upstream server error +// (Anthropic 500/503), NOT a quota hit. When any of these is present in +// the pane, we treat the "hit" as a false positive and do NOT swap. +var serverErrorPatterns = []string{ + "api_error", // Anthropic typed error (HTTP 500) + "overloaded_error", // Anthropic typed error (HTTP 503) + "internal server error", // HTTP 500 status text + "api error: 5", // Claude TUI rendering of 5xx } // resetTimeRe extracts reset times like "resets 8pm", "resets at 11:30pm", @@ -210,9 +223,14 @@ func (m *Monitor) isQuotaPaused() bool { return len(m.switchCh) > 0 } -// isQuotaExhausted returns true if the pane content indicates quota exhaustion. +// isQuotaExhausted returns true if the pane content indicates quota exhaustion +// AND does not simultaneously show a transient server error (which would make +// the apparent quota match a false positive). func isQuotaExhausted(paneContent string) bool { - return firstMatchingPattern(paneContent) != "" + if firstMatchingPattern(paneContent) == "" { + return false + } + return !hasServerError(paneContent) } // firstMatchingPattern returns the first quota pattern found in paneContent, @@ -227,6 +245,18 @@ func firstMatchingPattern(paneContent string) string { return "" } +// hasServerError reports whether the pane content shows an Anthropic 5xx / +// transient server error. Used to veto a quota match: a 500 is not a quota. +func hasServerError(paneContent string) bool { + lower := strings.ToLower(paneContent) + for _, p := range serverErrorPatterns { + if strings.Contains(lower, p) { + return true + } + } + return false +} + // snippet returns a 120-char single-line excerpt of pane content for logging. func snippet(s string) string { s = strings.ReplaceAll(s, "\n", " ") diff --git a/internal/quota/monitor_test.go b/internal/quota/monitor_test.go index e0f9850..800b8ee 100644 --- a/internal/quota/monitor_test.go +++ b/internal/quota/monitor_test.go @@ -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("")