fix(api): dress only the 429s a limiter actually refused

The request-scoped capture kept the last verdict of any kind and fell back
to it when nothing had refused. That fallback fires in exactly one state —
no limiter refused this request — which is the definition of "this 429 came
from somewhere else". So a daily AI budget that is spent until midnight, an
hourly generation quota, and a provider-side 429 relayed onward all went out
carrying our own bucket's numbers: a remainder that says there is room left
and a Retry-After measured against an unrelated window. On the AI paths the
captured window can also expire while the provider call runs, so the dressed
delay could land at zero and tell the client to retry immediately into the
ceiling that had just refused it.

The cell now keeps refusals only. A request every bucket let through reports
nothing, and a 429 from another cause leaves undressed, which is correct:
there is no bucket to describe. The two-limiter rule is unchanged — the last
refusal wins, and a refusal still outranks a pass.

The unit case that pinned the fallback is turned round, and both directions
now run through the real wrapper: a limiter refusal gets the headers, a 429
raised without one gets none. The integration file gains the two shapes it
was missing — a route that consults two buckets must report the one that
refused, and a route whose 429 comes from the daily budget must carry no
rate-limit headers at all.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:55:19 +02:00
1 parent 21dd7c7e48
commit dd3844a7a2
5 files changed
+305 -23

No files matched your search

+5
View File
@@ -380,6 +380,11 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
* `Retry-After` is the presence test rather than a flag: a handler that built
* its own headers already carries it (they come from the same builder), so
* this leaves that response exactly as it was written.
*
* The cell holds refusals only, so a 429 no limiter produced — a spent daily
* AI budget, an hourly generation quota, a provider-side 429 relayed onward —
* reads null here and goes out undressed. There is no bucket to describe, and
* one that still has room would name a delay against the wrong window.
*/
function attachRateLimitHeaders(response: NextResponse): void {
if (response.status !== 429) return;