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
37
VERSION.md
37
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
|
## [0.2.2] - 2026-04-15
|
||||||
**Type:** Patch — Confirmation requise pour les faux positifs (root cause)
|
**Type:** Patch — Confirmation requise pour les faux positifs (root cause)
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,26 @@ type SwitchRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// quotaPatterns are substrings that indicate quota exhaustion in a pane.
|
// 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{
|
var quotaPatterns = []string{
|
||||||
"you've hit your limit",
|
"you've hit your limit", // Claude Code TUI friendly message
|
||||||
"rate limit",
|
"rate_limit_error", // Anthropic typed error (real HTTP 429)
|
||||||
"quota exceeded",
|
"quota exceeded", // generic, but specific enough
|
||||||
"usage limit reached",
|
"usage limit reached", // Claude Pro phrasing
|
||||||
"claude pro usage",
|
"claude pro usage", // Claude Pro dashboard phrasing
|
||||||
"too many requests",
|
"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",
|
// 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
|
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 {
|
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,
|
// firstMatchingPattern returns the first quota pattern found in paneContent,
|
||||||
|
|
@ -227,6 +245,18 @@ func firstMatchingPattern(paneContent string) string {
|
||||||
return ""
|
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.
|
// snippet returns a 120-char single-line excerpt of pane content for logging.
|
||||||
func snippet(s string) string {
|
func snippet(s string) string {
|
||||||
s = strings.ReplaceAll(s, "\n", " ")
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
|
|
||||||
|
|
@ -28,25 +28,39 @@ func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
||||||
return m.paneOutput[session], nil
|
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) {
|
func TestIsQuotaExhausted(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
name string
|
||||||
input string
|
input string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"You've hit your limit for Claude Pro.", true},
|
{"friendly hit message", "You've hit your limit for Claude Pro.", true},
|
||||||
{"rate limit exceeded", true},
|
{"typed rate_limit_error", `{"error":{"type":"rate_limit_error"}}`, true},
|
||||||
{"quota exceeded for this period", true},
|
{"quota exceeded", "quota exceeded for this period", true},
|
||||||
{"Usage limit reached", true},
|
{"usage limit", "Usage limit reached", true},
|
||||||
{"Too many requests", true},
|
{"too many requests", "Too many requests", true},
|
||||||
{"Some normal output ❯", false},
|
{"5-hour limit", "You've reached the 5-hour limit", true},
|
||||||
{"❯ ", false},
|
|
||||||
{"still running 5s · ", false},
|
{"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 {
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
if got := isQuotaExhausted(c.input); got != c.want {
|
if got := isQuotaExhausted(c.input); got != c.want {
|
||||||
t.Errorf("isQuotaExhausted(%q) = %v, want %v", 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-0"] = true
|
||||||
tc.sessions["ccl-auto-1"] = 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-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 := state.New("")
|
||||||
s.SetActiveAccount("compte1")
|
s.SetActiveAccount("compte1")
|
||||||
|
|
@ -182,7 +196,7 @@ func TestPollNoTriggerWhenBelowThreshold(t *testing.T) {
|
||||||
tc := newMockTmux()
|
tc := newMockTmux()
|
||||||
tc.sessions["ccl-auto-0"] = true
|
tc.sessions["ccl-auto-0"] = true
|
||||||
tc.sessions["ccl-auto-1"] = 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
|
tc.paneOutput["ccl-auto-1"] = "❯ " // fine
|
||||||
|
|
||||||
s := state.New("")
|
s := state.New("")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue