mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(api): floor Retry-After at one second on a refusal
`resetAt` is written by Postgres as `NOW() + interval` and subtracted from the app process's clock, so a row that still refuses can read as already expired here. The old floor let that render as `Retry-After: 0`, which tells a client to retry immediately into the ceiling that just turned it away — a contradiction of the status it rides on, and a hot loop for anything that honours the header. One second is the honest minimum on a 429. The rounding-up rule and the reason for it are unchanged; only the floor moves.
This commit is contained in:
1 parent
dd3844a7a2
commit
dd8a724039
2 files changed
+11
-6
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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<string, string> {
|
||||
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),
|
||||
|
||||
Reference in new issue
Block a user