diff --git a/src/lib/__tests__/rate-limit-headers.test.ts b/src/lib/__tests__/rate-limit-headers.test.ts index 51938a67c..248cd2aad 100644 --- a/src/lib/__tests__/rate-limit-headers.test.ts +++ b/src/lib/__tests__/rate-limit-headers.test.ts @@ -48,13 +48,16 @@ describe("rateLimitResponseHeaders", () => { expect(headers["Retry-After"]).toBe("1"); }); - it("never names a delay in the past", () => { + it("names at least a second even when the window already reads as expired", () => { const now = Date.UTC(2026, 0, 1, 12, 0, 0); const headers = rateLimitResponseHeaders( { limit: 10, remaining: 0, resetAt: now - 5_000 }, now, ); - expect(headers["Retry-After"]).toBe("0"); + // `resetAt` is the database clock and `now` is this process's, so a row + // that still refuses can read as expired here. Zero would say "retry now" + // beside a refusal and turn the client into a hot loop. + expect(headers["Retry-After"]).toBe("1"); }); it("keeps X-RateLimit-Reset in the ISO form already on the wire", () => { diff --git a/src/lib/rate-limit-context.ts b/src/lib/rate-limit-context.ts index 75df1b2a3..fd1181233 100644 --- a/src/lib/rate-limit-context.ts +++ b/src/lib/rate-limit-context.ts @@ -80,9 +80,11 @@ export function capturedRateLimit(): RateLimitSnapshot | null { * * `Retry-After` is whole seconds and rounds UP, per RFC 9110 §10.2.3: a client * that waits the value it is given must find the window already rolled over, - * which flooring cannot promise. It never goes below zero — a window that - * expired between the verdict and the response says "retry now" rather than - * naming a time in the past. + * which flooring cannot promise. It never goes below one second: `resetAt` + * comes from the database clock and the subtraction happens on the app + * process's, so a window can read as already expired here while the row still + * refuses; and zero on a refusal means "retry now", which contradicts the + * status it rides on and invites a hot loop. * * `X-RateLimit-Reset` keeps the ISO-8601 instant it has always carried. It is * the wrong convention for the header name and every other deployment uses @@ -96,7 +98,7 @@ export function rateLimitResponseHeaders( ): Record { return { "Retry-After": String( - Math.max(0, Math.ceil((snapshot.resetAt - now) / 1000)), + Math.max(1, Math.ceil((snapshot.resetAt - now) / 1000)), ), "X-RateLimit-Limit": String(snapshot.limit), "X-RateLimit-Remaining": String(snapshot.remaining),