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

@@ -71,6 +71,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
@@ -190,6 +190,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -229,6 +230,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -258,6 +260,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -289,6 +292,7 @@ describe("GET /api/export/measurements", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -327,6 +331,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -370,6 +375,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -390,6 +396,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -425,6 +432,7 @@ describe("GET /api/export/medications", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -452,6 +460,7 @@ describe("GET /api/export/mood", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -492,6 +501,7 @@ describe("GET /api/export/full-backup", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -562,6 +572,7 @@ describe("GET /api/export/full-backup", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
@@ -130,6 +130,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});