diff --git a/internal/gateway/ratewords_test.go b/internal/gateway/ratewords_test.go new file mode 100644 index 00000000..02e0f017 --- /dev/null +++ b/internal/gateway/ratewords_test.go @@ -0,0 +1,29 @@ +package gateway + +import "testing" + +// A 429 that is a per-minute rate limit rests a minute, not the quarter +// hour of a used-up plan — however magpie or the vendor words it (#153). +func TestRateLimitIsNotQuota(t *testing.T) { + for body, want := range map[string]string{ + // as magpie words an upstream 429 for the agent + `{"error":{"code":null,"message":"ORF: Rate limit exceeded: free-models-per-min","param":null,"type":"rate_limit_error"}}`: failRate, + `{"error":{"message":"Rate limit reached for gpt-4o in organization org-x on tokens per min (TPM): Limit 30000, Used 29000"}}`: failRate, + `{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your organization's rate limit of 50,000 input tokens per minute"}}`: failRate, + `{"error":{"message":"Too Many Requests"}}`: failRate, + // still a used-up plan + `{"error":{"message":"Rate limit exceeded: free-models-per-day. Add 10 credits to unlock 1000 free model requests per day"}}`: failQuota, + `{"type":"error","error":{"type":"rate_limit_error","message":"Claude AI usage limit reached|1790000000"}}`: failQuota, + `{"error":{"type":"usage_limit_reached","message":"You've hit your usage limit."}}`: failQuota, + `{"error":{"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`: failQuota, + `{"error":{"message":"You exceeded your current quota","type":"insufficient_quota"}}`: failCredit, + } { + if got := failure(429, []byte(body)); got != want { + t.Errorf("%s: %s, want %s", body, got, want) + } + } + // not a 429: words alone don't make it a rate limit + if got := failure(403, []byte(`{"error":{"message":"rate limit exceeded for plan"}}`)); got != failQuota { + t.Errorf("403: %s, want %s", got, failQuota) + } +} diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go index 66f6890e..17b50c37 100644 --- a/internal/gateway/routing.go +++ b/internal/gateway/routing.go @@ -86,6 +86,15 @@ var ( creditWords = regexp.MustCompile(`(?i)insufficient.?(balance|credit|fund)|balance|credit|billing|payment|arrear|overdue|suspended|余额|欠费|充值|账户.*(不足|停)`) // quotaWords: it has used up what its plan allows for now. usedUpWords = regexp.MustCompile(`(?i)quota|usage.?limit|limit.?reached|hit your .*limit|limit.{0,24}resets|exceeded.*(plan|limit)|额度|用量|套餐|上限`) + // rateWords: a 429 that is a short rate limit — requests or tokens per + // minute — which usedUpWords took for a used-up plan ("Rate limit + // exceeded", "reached"): as magpie words an upstream error, with + // "rate_limit_error" for its type, every such 429 was, and rested a + // quarter of an hour rather than a minute (#153). + rateWords = regexp.MustCompile(`(?i)rate.?limit|too many requests|per.?(second|sec|minute|min)\b|\b[rt]pm\b|频率|太频繁`) + // plannedWords: a 429 that says the plan's own allowance is used, rate + // words or not — a day's free requests, say. + plannedWords = regexp.MustCompile(`(?i)quota|usage.?limit|hit your .*limit|limit.{0,24}resets|per.?(day|week|month)|daily|weekly|monthly|额度|用量|套餐`) // resetsWords: Claude Code's "usage limit reached|". resetsWords = regexp.MustCompile(`(?i)limit reached\|(\d{10})\b`) ) @@ -117,7 +126,9 @@ func failure(status int, body []byte) string { switch { case status == 402, creditWords.Match(body) && status != 429 || strings.Contains(string(body), "insufficient_quota"): return failCredit - case usedUpWords.Match(body): + case status == 429 && rateWords.Match(body) && !plannedWords.Match(body): + return failRate + case usedUpWords.Match(body), status == 429 && plannedWords.Match(body): return failQuota case status == 429: return failRate