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

@@ -168,6 +168,7 @@ beforeEach(() => {
vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 5,
resetAt: Date.now() + 60 * 60 * 1000,
});
@@ -304,6 +305,7 @@ describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
it("consumes the shared document-AI bucket honestly", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 30 * 60_000,
});
@@ -128,6 +128,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 5,
resetAt: Date.now() + 60 * 60 * 1000,
});
@@ -222,6 +223,7 @@ describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
const resetAt = Date.now() + 47 * 60_000;
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt,
});
@@ -309,6 +311,7 @@ describe("POST /api/documents/inbound/[id]/index — staging continuation", () =
it("does not reach staging when the index itself was refused", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});