2026-04-14 20:31:04 +00:00
|
|
|
|
package quota
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
|
|
|
|
|
|
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// mockTmux for quota tests.
|
|
|
|
|
|
type mockTmux struct {
|
|
|
|
|
|
sessions map[string]bool
|
|
|
|
|
|
paneOutput map[string]string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func newMockTmux() *mockTmux {
|
|
|
|
|
|
return &mockTmux{
|
|
|
|
|
|
sessions: make(map[string]bool),
|
|
|
|
|
|
paneOutput: make(map[string]string),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
|
|
|
|
|
|
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
|
|
|
|
|
|
func (m *mockTmux) KillSession(_ string) error { return nil }
|
|
|
|
|
|
func (m *mockTmux) SendKeys(_, _ string) error { return nil }
|
|
|
|
|
|
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
|
|
|
|
|
return m.paneOutput[session], nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 19:26:00 +00:00
|
|
|
|
// TestIsQuotaExhausted verifies pattern matching on pane output, including
|
|
|
|
|
|
// the server-error veto that prevents 5xx from being mistaken for quota.
|
2026-04-14 20:31:04 +00:00
|
|
|
|
func TestIsQuotaExhausted(t *testing.T) {
|
|
|
|
|
|
cases := []struct {
|
2026-04-15 19:26:00 +00:00
|
|
|
|
name string
|
2026-04-14 20:31:04 +00:00
|
|
|
|
input string
|
|
|
|
|
|
want bool
|
|
|
|
|
|
}{
|
2026-04-15 19:26:00 +00:00
|
|
|
|
{"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},
|
2026-04-14 20:31:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
for _, c := range cases {
|
2026-04-15 19:26:00 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-14 20:31:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractResetTime parses various reset time formats.
|
|
|
|
|
|
func TestExtractResetTime(t *testing.T) {
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
|
input string
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"Usage resets 8pm", "8pm"},
|
|
|
|
|
|
{"Your quota resets at 11:30pm", "11:30pm"},
|
|
|
|
|
|
{"resets in 45 minutes", "in 45 minutes"},
|
|
|
|
|
|
{"resets in 2 hours", "in 2 hours"},
|
|
|
|
|
|
{"no reset info here", ""},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, c := range cases {
|
|
|
|
|
|
if got := extractResetTime(c.input); got != c.want {
|
|
|
|
|
|
t.Errorf("extractResetTime(%q) = %q, want %q", c.input, got, c.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 19:18:27 +00:00
|
|
|
|
// TestPollTriggersSwitchOnTwoBlockedPoolWithReset verifies a legitimate 429
|
|
|
|
|
|
// (reset time present) triggers a swap immediately on the first poll.
|
|
|
|
|
|
func TestPollTriggersSwitchOnTwoBlockedPoolWithReset(t *testing.T) {
|
2026-04-14 20:31:04 +00:00
|
|
|
|
tc := newMockTmux()
|
|
|
|
|
|
tc.sessions["ccl-auto-0"] = true
|
|
|
|
|
|
tc.sessions["ccl-auto-1"] = true
|
2026-04-15 19:18:27 +00:00
|
|
|
|
tc.paneOutput["ccl-auto-0"] = "You've hit your limit for Claude Pro. resets in 45 minutes"
|
2026-04-15 19:26:00 +00:00
|
|
|
|
tc.paneOutput["ccl-auto-1"] = `{"error":{"type":"rate_limit_error"}} — resets at 8pm`
|
2026-04-14 20:31:04 +00:00
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
|
|
|
|
|
s.SetActiveAccount("compte1")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
|
|
|
|
|
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Max: 2},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
m := New(tc, s, cfg)
|
|
|
|
|
|
m.poll()
|
|
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
|
case req := <-m.switchCh:
|
|
|
|
|
|
if req.From != "compte1" {
|
|
|
|
|
|
t.Errorf("expected From=compte1, got %q", req.From)
|
|
|
|
|
|
}
|
2026-04-15 19:18:27 +00:00
|
|
|
|
if req.ResetTime == "" {
|
|
|
|
|
|
t.Errorf("expected non-empty ResetTime")
|
|
|
|
|
|
}
|
2026-04-14 20:31:04 +00:00
|
|
|
|
default:
|
|
|
|
|
|
t.Fatal("expected SwitchRequest on channel")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 19:18:27 +00:00
|
|
|
|
// TestPollRequiresConfirmationWhenNoResetTime verifies that a hit without a
|
|
|
|
|
|
// parseable reset time does not trigger a swap on a single poll. A second
|
|
|
|
|
|
// consecutive hit is required. This guards against transient Anthropic 500
|
|
|
|
|
|
// errors whose payload happens to contain "rate limit".
|
|
|
|
|
|
func TestPollRequiresConfirmationWhenNoResetTime(t *testing.T) {
|
2026-04-14 20:31:04 +00:00
|
|
|
|
tc := newMockTmux()
|
|
|
|
|
|
tc.sessions["my-session"] = true
|
2026-04-15 19:18:27 +00:00
|
|
|
|
tc.paneOutput["my-session"] = "quota exceeded" // no reset time
|
2026-04-14 20:31:04 +00:00
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
|
|
|
|
|
s.SetActiveAccount("compte1")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
|
|
|
|
|
Dedicated: []config.DedicatedSession{{Name: "my-session"}},
|
|
|
|
|
|
Autonomous: config.AutonomousConfig{Max: 0},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
m := New(tc, s, cfg)
|
2026-04-15 19:18:27 +00:00
|
|
|
|
|
|
|
|
|
|
// First poll — suspected only, no swap yet.
|
2026-04-14 20:31:04 +00:00
|
|
|
|
m.poll()
|
2026-04-15 19:18:27 +00:00
|
|
|
|
select {
|
|
|
|
|
|
case req := <-m.switchCh:
|
|
|
|
|
|
t.Fatalf("unexpected SwitchRequest on first poll: %+v", req)
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
2026-04-14 20:31:04 +00:00
|
|
|
|
|
2026-04-15 19:18:27 +00:00
|
|
|
|
// Second poll — confirmed, swap emitted.
|
|
|
|
|
|
m.poll()
|
2026-04-14 20:31:04 +00:00
|
|
|
|
select {
|
|
|
|
|
|
case req := <-m.switchCh:
|
|
|
|
|
|
if req.From != "compte1" {
|
|
|
|
|
|
t.Errorf("expected From=compte1, got %q", req.From)
|
|
|
|
|
|
}
|
2026-04-15 19:18:27 +00:00
|
|
|
|
if req.ResetTime != "" {
|
|
|
|
|
|
t.Errorf("expected empty ResetTime, got %q", req.ResetTime)
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
t.Fatal("expected SwitchRequest on confirmation poll")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestPollSuspectedHitClearedOnRecovery verifies a transient hit followed by
|
|
|
|
|
|
// a clean poll does NOT trigger a swap on a subsequent hit — the suspected
|
|
|
|
|
|
// state must be reset when detection clears.
|
|
|
|
|
|
func TestPollSuspectedHitClearedOnRecovery(t *testing.T) {
|
|
|
|
|
|
tc := newMockTmux()
|
|
|
|
|
|
tc.sessions["my-session"] = true
|
|
|
|
|
|
tc.paneOutput["my-session"] = "quota exceeded"
|
|
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
|
|
|
|
|
s.SetActiveAccount("compte1")
|
|
|
|
|
|
|
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
|
|
|
|
|
Dedicated: []config.DedicatedSession{{Name: "my-session"}},
|
|
|
|
|
|
Autonomous: config.AutonomousConfig{Max: 0},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
m := New(tc, s, cfg)
|
|
|
|
|
|
|
|
|
|
|
|
m.poll() // suspected
|
|
|
|
|
|
tc.paneOutput["my-session"] = "all good ❯ "
|
|
|
|
|
|
m.poll() // cleared
|
|
|
|
|
|
tc.paneOutput["my-session"] = "quota exceeded"
|
|
|
|
|
|
m.poll() // re-suspected, NOT confirmed yet
|
|
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
|
case req := <-m.switchCh:
|
|
|
|
|
|
t.Fatalf("unexpected SwitchRequest after recovery: %+v", req)
|
2026-04-14 20:31:04 +00:00
|
|
|
|
default:
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestPollNoTriggerWhenBelowThreshold verifies no swap for a single blocked pool session.
|
|
|
|
|
|
func TestPollNoTriggerWhenBelowThreshold(t *testing.T) {
|
|
|
|
|
|
tc := newMockTmux()
|
|
|
|
|
|
tc.sessions["ccl-auto-0"] = true
|
|
|
|
|
|
tc.sessions["ccl-auto-1"] = true
|
2026-04-15 19:26:00 +00:00
|
|
|
|
tc.paneOutput["ccl-auto-0"] = "You've hit your limit"
|
2026-04-14 20:31:04 +00:00
|
|
|
|
tc.paneOutput["ccl-auto-1"] = "❯ " // fine
|
|
|
|
|
|
|
|
|
|
|
|
s := state.New("")
|
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
|
Pool: config.PoolConfig{
|
|
|
|
|
|
Autonomous: config.AutonomousConfig{Prefix: "ccl-auto-", Max: 2},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
m := New(tc, s, cfg)
|
|
|
|
|
|
m.poll()
|
|
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
|
case req := <-m.switchCh:
|
|
|
|
|
|
t.Errorf("unexpected SwitchRequest: %+v", req)
|
|
|
|
|
|
default:
|
|
|
|
|
|
// Correct: only 1 blocked pool session, threshold is 2.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|