fix(api): put Retry-After and the rate-limit triple on every 429

A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.

Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.

`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.

The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 03:55:46 +02:00
1 parent 1d372df3a0
commit 08100b6207
56 files changed
+681 -161

No files matched your search

@@ -0,0 +1,141 @@
/**
* The rate-limit response headers and the request-scoped verdict capture.
*
* The headers are what a refused client backs off against, so the shape is
* pinned here rather than left to whichever route happens to be exercised:
* `Retry-After` in whole seconds rounding UP, the cap and the remainder as
* plain integers, and the reset instant in the ISO form it has always carried.
*
* The capture half is what lets `apiHandler` dress a 429 that a handler built
* without headers. Its one interesting rule is that a refusal outranks a pass:
* a handler that clears one bucket and is refused by the next must report the
* bucket that refused it.
*/
import { describe, expect, it } from "vitest";
import {
capturedRateLimit,
captureRateLimitResult,
rateLimitResponseHeaders,
runWithRateLimitCapture,
} from "@/lib/rate-limit-context";
describe("rateLimitResponseHeaders", () => {
it("emits Retry-After, the cap, the remainder and the reset instant", () => {
const now = Date.UTC(2026, 0, 1, 12, 0, 0);
const headers = rateLimitResponseHeaders(
{ limit: 60, remaining: 0, resetAt: now + 30_000 },
now,
);
expect(headers).toEqual({
"Retry-After": "30",
"X-RateLimit-Limit": "60",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": new Date(now + 30_000).toISOString(),
});
});
it("rounds Retry-After up so the named delay outlasts the window", () => {
const now = Date.UTC(2026, 0, 1, 12, 0, 0);
const headers = rateLimitResponseHeaders(
{ limit: 10, remaining: 0, resetAt: now + 1 },
now,
);
// One millisecond left still means "wait a second", never "retry now" —
// a floored zero would send the client straight back into the same window.
expect(headers["Retry-After"]).toBe("1");
});
it("never names a delay in the past", () => {
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");
});
it("keeps X-RateLimit-Reset in the ISO form already on the wire", () => {
const resetAt = Date.UTC(2026, 4, 17, 8, 30, 0);
const headers = rateLimitResponseHeaders({
limit: 120,
remaining: 7,
resetAt,
});
expect(headers["X-RateLimit-Reset"]).toBe("2026-05-17T08:30:00.000Z");
});
});
describe("request-scoped rate-limit capture", () => {
it("reads null outside a request scope", () => {
captureRateLimitResult({
allowed: false,
limit: 5,
remaining: 0,
resetAt: Date.now(),
});
expect(capturedRateLimit()).toBeNull();
});
it("reports the last verdict when every bucket passed", () => {
runWithRateLimitCapture(() => {
captureRateLimitResult({
allowed: true,
limit: 5,
remaining: 4,
resetAt: 1_000,
});
captureRateLimitResult({
allowed: true,
limit: 9,
remaining: 8,
resetAt: 2_000,
});
expect(capturedRateLimit()).toEqual({
allowed: true,
limit: 9,
remaining: 8,
resetAt: 2_000,
});
});
});
it("reports the bucket that refused, not one that passed after it", () => {
runWithRateLimitCapture(() => {
captureRateLimitResult({
allowed: false,
limit: 60,
remaining: 0,
resetAt: 5_000,
});
captureRateLimitResult({
allowed: true,
limit: 120,
remaining: 100,
resetAt: 9_000,
});
expect(capturedRateLimit()).toMatchObject({ limit: 60, resetAt: 5_000 });
});
});
it("gives each scope its own cell", async () => {
const first = runWithRateLimitCapture(async () => {
captureRateLimitResult({
allowed: false,
limit: 1,
remaining: 0,
resetAt: 111,
});
await Promise.resolve();
return capturedRateLimit();
});
const second = runWithRateLimitCapture(async () => {
await Promise.resolve();
return capturedRateLimit();
});
expect((await first)?.resetAt).toBe(111);
expect(await second).toBeNull();
});
});
+156 -121
View File
@@ -41,6 +41,11 @@ import {
assertRecordSessionFence,
attachRecordContextEcho,
} from "./sharing/record-session-fence";
import {
capturedRateLimit,
rateLimitResponseHeaders,
runWithRateLimitCapture,
} from "./rate-limit-context";
/**
* HTTP methods a read-only credential may use on the REST surface. A request
@@ -221,133 +226,163 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
undefined,
});
return eventStorage.run(evt, async () => {
let response: Response | undefined;
try {
response = await handler(...args);
} catch (error) {
if (error instanceof AssistantDisabledError) {
// v1.4.31 — operator has disabled the assistant surface.
// The 403 + `errorCode: "assistant.disabled.<surface>"`
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients can branch on the
// errorCode to render an inline operator-disabled notice.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof ConsentRequiredError) {
// v1.12.1 — server-side consent gate before external-LLM PHI
// egress on the operator's server-managed key. Mirrors the
// AssistantDisabledError envelope (403 + meta.errorCode) so the
// iOS client renders an inline "grant consent" notice instead of
// a generic failure.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof StepUpRequiredError) {
// v1.23 — step-up gate not satisfied. Same 401 + meta.errorCode
// envelope shape as the assistant/consent gates so the client can
// branch on the stable code and launch a re-verification flow.
// Checked before the generic HttpError branch because
// StepUpRequiredError extends it.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: error.statusCode },
);
} else if (error instanceof SharingAuthError) {
// v1.36.0 — the acting-account resolver refused. Same
// 403 + meta.errorCode envelope as the gates above, so a client
// distinguishes "you may not act as that account" from "this
// endpoint does not support acting on one" without parsing prose.
// Checked before the generic HttpError branch because it extends it.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: error.statusCode },
);
} else if (error instanceof HttpError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: error.message },
{ status: error.statusCode },
);
} else if (error instanceof SyntaxError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: "Invalid JSON body" },
{ status: 400 },
);
} else {
evt.setError(error);
// Report to GlitchTip (fire-and-forget)
reportToGlitchtip(error, request, evt).catch(() => {});
response = NextResponse.json(
{ data: null, error: "Interner Serverfehler" },
{ status: 500 },
);
}
} finally {
const status = (response as Response | undefined)?.status ?? 500;
evt.finish(status);
return runWithRateLimitCapture(() =>
eventStorage.run(evt, async () => {
let response: Response | undefined;
try {
emitIfSampled(evt.toJSON());
} catch {
/* logging must never crash the handler */
response = await handler(...args);
} catch (error) {
if (error instanceof AssistantDisabledError) {
// v1.4.31 — operator has disabled the assistant surface.
// The 403 + `errorCode: "assistant.disabled.<surface>"`
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients can branch on the
// errorCode to render an inline operator-disabled notice.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof ConsentRequiredError) {
// v1.12.1 — server-side consent gate before external-LLM PHI
// egress on the operator's server-managed key. Mirrors the
// AssistantDisabledError envelope (403 + meta.errorCode) so the
// iOS client renders an inline "grant consent" notice instead of
// a generic failure.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof StepUpRequiredError) {
// v1.23 — step-up gate not satisfied. Same 401 + meta.errorCode
// envelope shape as the assistant/consent gates so the client can
// branch on the stable code and launch a re-verification flow.
// Checked before the generic HttpError branch because
// StepUpRequiredError extends it.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: error.statusCode },
);
} else if (error instanceof SharingAuthError) {
// v1.36.0 — the acting-account resolver refused. Same
// 403 + meta.errorCode envelope as the gates above, so a client
// distinguishes "you may not act as that account" from "this
// endpoint does not support acting on one" without parsing prose.
// Checked before the generic HttpError branch because it extends it.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: error.statusCode },
);
} else if (error instanceof HttpError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: error.message },
{ status: error.statusCode },
);
} else if (error instanceof SyntaxError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: "Invalid JSON body" },
{ status: 400 },
);
} else {
evt.setError(error);
// Report to GlitchTip (fire-and-forget)
reportToGlitchtip(error, request, evt).catch(() => {});
response = NextResponse.json(
{ data: null, error: "Interner Serverfehler" },
{ status: 500 },
);
}
} finally {
const status = (response as Response | undefined)?.status ?? 500;
evt.finish(status);
try {
emitIfSampled(evt.toJSON());
} catch {
/* logging must never crash the handler */
}
}
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
// v1.37.0 — echo the record context this response was actually served
// under, when one was decided. The value comes from the wide event and
// from nowhere else: the fence stamps it on every call it makes, so
// exactly the responses that resolved a record scope carry it and
// everything else — public routes, actor surfaces, admin, `/me`, static —
// carries nothing.
//
// Deliberately NOT derived here with a `getSession()`. That would put a
// session read on every public route, and it would make the echo a second
// derivation of the context rather than a report of the one that was
// used, which is the difference between "what this response served" and
// "what a later read thinks is true".
//
// Absence is therefore meaningful and safe: the client discards a
// response whose echo CONTRADICTS its adopted context, and serves one
// that carries no echo normally. A discard-on-absence rule would throw
// away the `/api/version` poll on every cycle.
//
// The two header names stay inside the fence module, which is what keeps
// them to four files overall (declared, read, attached client-side,
// published) — see `src/__tests__/record-session-fence-guard.test.ts`.
attachRecordContextEcho(nr.headers, evt.getRecordContext());
return nr;
});
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
// v1.37.0 — echo the record context this response was actually served
// under, when one was decided. The value comes from the wide event and
// from nowhere else: the fence stamps it on every call it makes, so
// exactly the responses that resolved a record scope carry it and
// everything else — public routes, actor surfaces, admin, `/me`, static —
// carries nothing.
//
// Deliberately NOT derived here with a `getSession()`. That would put a
// session read on every public route, and it would make the echo a second
// derivation of the context rather than a report of the one that was
// used, which is the difference between "what this response served" and
// "what a later read thinks is true".
//
// Absence is therefore meaningful and safe: the client discards a
// response whose echo CONTRADICTS its adopted context, and serves one
// that carries no echo normally. A discard-on-absence rule would throw
// away the `/api/version` poll on every cycle.
//
// The two header names stay inside the fence module, which is what keeps
// them to four files overall (declared, read, attached client-side,
// published) — see `src/__tests__/record-session-fence-guard.test.ts`.
attachRecordContextEcho(nr.headers, evt.getRecordContext());
attachRateLimitHeaders(nr);
return nr;
}),
);
};
return wrapped as T;
}
/**
* Put the rate-limit headers on a refusal that left the handler without them.
*
* Only ~70 of the 199 route files that consult a limiter attach
* `rateLimitHeaders(rl)` by hand; the rest answer 429 with a bare prose
* envelope, so an offline replay queue that trips the batch ceiling has
* nothing to back off against but a guess. The limiter records its verdict in
* the request-scoped cell, so the headers can be derived here for every one of
* them at once instead of being threaded through each call site — and a route
* added tomorrow gets them without knowing this exists.
*
* `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.
*/
function attachRateLimitHeaders(response: NextResponse): void {
if (response.status !== 429) return;
if (response.headers.has("Retry-After")) return;
const snapshot = capturedRateLimit();
if (!snapshot) return;
for (const [name, value] of Object.entries(
rateLimitResponseHeaders(snapshot),
)) {
response.headers.set(name, value);
}
}
/**
* Authenticated request context. Returned by both session-cookie and Bearer-token
* authentication paths. The `session.id` is the session-record id for cookie auth
@@ -77,6 +77,7 @@ describe("documentAiRateLimited", () => {
const resetAt = Date.now() + 42 * 60_000;
const response = documentAiRateLimited({
allowed: false,
limit: 60,
remaining: 0,
resetAt,
});
+2 -1
View File
@@ -181,7 +181,8 @@ export const stdResponses = {
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description: "Rate limit exceeded.",
description:
"Rate limit exceeded. The response carries `Retry-After` (whole seconds, rounded up — wait at least that long before retrying), `X-RateLimit-Limit` (the bucket's cap), `X-RateLimit-Remaining` and `X-RateLimit-Reset` (the reset instant, ISO-8601, not epoch seconds). Back off on `Retry-After` rather than guessing.",
content: { "application/json": { schema: errorEnvelope } },
},
};
+97
View File
@@ -0,0 +1,97 @@
/**
* Request-scoped capture of the rate-limiter's last verdict, plus the one
* builder that turns a verdict into response headers — a LEAF module.
*
* It lives apart from `rate-limit.ts` on purpose. Nearly two hundred route
* tests replace `@/lib/rate-limit` wholesale with a `vi.mock` factory, so
* anything `apiHandler` needs from the limiter has to come from a module those
* factories do not stand in for; otherwise the wrapper calls `undefined()` the
* moment a mocked route is exercised.
*
* Why the capture exists at all: a route that refuses a request answers 429
* from its own handler body, and most of them build that response with a bare
* `apiError(...)`. Threading the headers through every one of those call sites
* is 129 edits that the next new route immediately forgets. Instead the
* limiter records its verdict here, `apiHandler` runs every handler inside the
* store, and any 429 that leaves without headers gets them on the way out. A
* route that already attaches them keeps exactly the response it built.
*/
import { AsyncLocalStorage } from "node:async_hooks";
/** What a rate-limit verdict has to carry to be renderable as headers. */
export interface RateLimitSnapshot {
/** Requests allowed per window. */
limit: number;
/** Requests left in the current window; zero once the cap is hit. */
remaining: number;
/** Epoch milliseconds at which the current window rolls over. */
resetAt: number;
}
interface RateLimitCapture {
last: RateLimitSnapshot | null;
denied: RateLimitSnapshot | null;
}
const storage = new AsyncLocalStorage<RateLimitCapture>();
/**
* Run `fn` with a fresh capture cell. Called once per request by
* `apiHandler`; outside one, every capture below is a no-op and
* {@link capturedRateLimit} reads null.
*/
export function runWithRateLimitCapture<T>(fn: () => T): T {
return storage.run({ last: null, denied: null }, fn);
}
/**
* Record a limiter verdict for the request in flight.
*
* A denied verdict wins over an allowed one: a handler that consults two
* buckets and is refused by the second must report the bucket that actually
* refused it, not the one it passed.
*/
export function captureRateLimitResult(
result: RateLimitSnapshot & { allowed: boolean },
): void {
const store = storage.getStore();
if (!store) return;
store.last = result;
if (!result.allowed) store.denied = result;
}
/** The verdict to render for this request, or null when none was taken. */
export function capturedRateLimit(): RateLimitSnapshot | null {
const store = storage.getStore();
if (!store) return null;
return store.denied ?? store.last;
}
/**
* The rate-limit response headers.
*
* `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.
*
* `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
* epoch or delta seconds, but it is on the wire today and changing it is a
* contract decision of its own; `Retry-After` gives an integer-parsing client
* the number it needs without touching the existing field.
*/
export function rateLimitResponseHeaders(
snapshot: RateLimitSnapshot,
now: number = Date.now(),
): Record<string, string> {
return {
"Retry-After": String(
Math.max(0, Math.ceil((snapshot.resetAt - now) / 1000)),
),
"X-RateLimit-Limit": String(snapshot.limit),
"X-RateLimit-Remaining": String(snapshot.remaining),
"X-RateLimit-Reset": new Date(snapshot.resetAt).toISOString(),
};
}
+16 -10
View File
@@ -5,11 +5,14 @@
import { prisma } from "@/lib/db";
import { getClientIpOrTrustWarning } from "@/lib/api-response";
import {
captureRateLimitResult,
rateLimitResponseHeaders,
type RateLimitSnapshot,
} from "@/lib/rate-limit-context";
export interface RateLimitResult {
export interface RateLimitResult extends RateLimitSnapshot {
allowed: boolean;
remaining: number;
resetAt: number;
}
/**
@@ -45,11 +48,16 @@ export async function checkRateLimit(
const resetAt = row.reset_at.getTime();
const count = Number(row.count);
return {
const result: RateLimitResult = {
allowed: count <= limit,
limit,
remaining: Math.max(0, limit - count),
resetAt,
};
// Hand the verdict to the request-scoped cell so `apiHandler` can dress a
// bare 429 on the way out. A no-op outside a request.
captureRateLimitResult(result);
return result;
}
/**
@@ -77,15 +85,13 @@ export async function refundRateLimit(key: string): Promise<void> {
}
/**
* Rate limit response headers.
* Rate limit response headers: `Retry-After` plus the `X-RateLimit-*` triple.
* See `rateLimitResponseHeaders` for what each one promises.
*/
export function rateLimitHeaders(
result: RateLimitResult,
result: RateLimitSnapshot,
): Record<string, string> {
return {
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": new Date(result.resetAt).toISOString(),
};
return rateLimitResponseHeaders(result);
}
/**