From 46454d98013464ea7ad4e041deeea073ffc28103 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 28 Sep 2026 21:37:46 +0800 Subject: [PATCH] =?UTF-8?q?gateway:=20a=20429=20that=20is=20a=20per-minute?= =?UTF-8?q?=20rate=20limit=20rests=20a=20minute,=20not=20a=20quarter=20of?= =?UTF-8?q?=20an=20hour=20(found=20reviewing=20#153)=20=E2=80=94=20usedUpW?= =?UTF-8?q?ords=20took=20"Rate=20limit=20exceeded"=20and=20"Rate=20limit?= =?UTF-8?q?=20reached=20=E2=80=A6=20tokens=20per=20min"=20for=20a=20used-u?= =?UTF-8?q?p=20plan,=20and=20as=20magpie=20words=20an=20upstream=20error?= =?UTF-8?q?=20("rate=5Flimit=5Ferror"=20its=20type)=20exceeded.*limit=20ma?= =?UTF-8?q?tched=20every=20such=20429=20whatever=20the=20vendor=20said,=20?= =?UTF-8?q?so=20a=20provider=20rate-limited=20for=20a=20minute=20sat=20out?= =?UTF-8?q?=2015=20as=20out=20of=20quota=20(an=20OpenRouter=20free=20model?= =?UTF-8?q?'s=20free-models-per-min,=20OpenAI's=20TPM,=20Anthropic's=20per?= =?UTF-8?q?-minute=20limit).=20Now=20a=20429=20in=20rate=20words=20(rate?= =?UTF-8?q?=20limit,=20too=20many=20requests,=20per=20second/minute,=20RPM?= =?UTF-8?q?/TPM)=20is=20a=20rate=20limit=20unless=20it=20also=20says=20the?= =?UTF-8?q?=20plan's=20allowance=20is=20used=20(quota,=20usage=20limit,=20?= =?UTF-8?q?per=20day/week/month,=20daily=E2=80=A6),=20and=20a=20429=20sayi?= =?UTF-8?q?ng=20that=20is=20out=20of=20quota,=20as=20a=20day's=20free=20re?= =?UTF-8?q?quests=20are.=20Tested:=20those=20bodies=20as=20vendors=20and?= =?UTF-8?q?=20magpie=20word=20them,=20and=20a=20group=20through=20the=20ga?= =?UTF-8?q?teway=20whose=20first=20member=20answers=20OpenRouter's=20free-?= =?UTF-8?q?models-per-min=20=E2=80=94=20it=20rests=20rate=20for=201m=20whe?= =?UTF-8?q?re=20main=20rested=20quota=20for=2015m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/ratewords_test.go | 29 +++++++++++++++++++++++++++++ internal/gateway/routing.go | 13 ++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 internal/gateway/ratewords_test.go 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