diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8d65be753..4cc137503 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -392,12 +392,15 @@ paths: "409": *a7 "422": *a4 "429": &a10 - description: Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — + description: "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns - their own allowance rather than the record owner's. Nothing was written. The `Retry-After`, - `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off - on `Retry-After` rather than guessing. A client with more than a handful of rows to send should use the - batch endpoint for its domain instead of looping this one. + their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is + `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when + both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` + headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against + the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 + spends the same allowance as one that writes rows. A client with more than a handful of rows to send should + use the batch endpoint for its domain instead of looping this one." headers: *a8 content: application/json: @@ -29574,21 +29577,21 @@ components: onboarding.step.concurrent, onboarding.step.invalid, onboarding.step.outOfOrder, onboarding.step.rateLimited, onboarding.tour.invalid, onboarding.user.notFound; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — - profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; report-selection — - report-selection.body.invalid_json, report-selection.body.invalid_shape, - report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, - share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, - share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, - share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, - sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, - sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, - sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, - sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, - sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — - source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — - tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.encounter-not-found, vaccination.identity-required, - vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; record_write — + record_write.rate_limited; report-selection — report-selection.body.invalid_json, + report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link + — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, + sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, + sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, diff --git a/src/__tests__/record-write-rate-limit-contract.test.ts b/src/__tests__/record-write-rate-limit-contract.test.ts index e9dec3cfa..55c327147 100644 --- a/src/__tests__/record-write-rate-limit-contract.test.ts +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -128,6 +128,18 @@ describe("the shared single-record write bucket", () => { expect(missing).toEqual([]); }); + it("names the code it publishes at every site", () => { + // The prose is not the discriminator: two of these routes consult a second + // bucket of their own, and a client that can only read `error` cannot tell + // which one refused it. + const silent = ON_THE_SHARED_BUCKET.filter(([route]) => { + const src = readFileSync(join(process.cwd(), route), "utf8"); + return !src.includes('errorCode: "record_write.rate_limited"'); + }).map(([route]) => route); + expect(silent).toEqual([]); + expect(published).toContain("record_write.rate_limited"); + }); + it("is published on every operation that answers it", () => { const doc = buildOpenApiDocument() as { paths: Record< diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index e7b544667..2f9d4d360 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -93,7 +93,9 @@ async function postAllergy(request: NextRequest): Promise { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: rawBody, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 7a48c1b06..8de993f70 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -103,7 +103,9 @@ async function postBiomarker(request: NextRequest) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 0750ce9d3..166304e3f 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -112,7 +112,9 @@ async function postCustomMetricEntry( // client hits were not. const writeRl = await checkRecordWriteRateLimit(user.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { id } = await params; diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 5a0696bc6..cacf927fd 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -105,7 +105,9 @@ async function postDayLog(request: NextRequest): Promise { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: rawBody, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 80926a0c2..5abab7694 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -162,7 +162,9 @@ async function postEncounter(request: NextRequest): Promise { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: rawBody, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 81cbf8a80..d4fbf47d9 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -156,7 +156,9 @@ async function postLabResult(request: NextRequest) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 385f6a640..ca5e59685 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -807,7 +807,9 @@ async function postMeasurement(request: NextRequest) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(auth.actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index b5c3baebb..b61961363 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -64,7 +64,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } // v1.36.1 follow-up — the sibling route `POST /api/medications/intake` diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index e6c6e759d..193966b4e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -209,7 +209,9 @@ export const POST = apiHandler(async (request: NextRequest) => { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error } = await safeJson(request, { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 279ba0256..96fb20d0a 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -111,7 +111,9 @@ async function postMoodEntry(request: NextRequest) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 62a60b12c..b1ddc7ae5 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -120,7 +120,9 @@ async function postVaccination(request: NextRequest): Promise { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: rawBody, error: jsonError } = await safeJson(request, { diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 67ef9f148..5f8c6037b 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -251,6 +251,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "profile.update.invalidBody", "profile.update.nothingSaved", ], + record_write: ["record_write.rate_limited"], "report-selection": [ "report-selection.body.invalid_json", "report-selection.body.invalid_shape", diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 15da53657..09f524625 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -312,7 +312,7 @@ export const stdResponses = { export const recordWriteRateLimitResponse = { "429": { description: - "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.", + "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.", content: { "application/json": { schema: errorEnvelope } }, headers: stdResponses["429"].headers, },