diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index a716de0d7..ac4d56572 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -70,10 +70,15 @@ export const GET = apiHandler(async () => { /** * Wrapped in `withIdempotency`: a custom symptom is minted from the client's - * offline outbox under the same `Idempotency-Key`, and a replay after a lost - * success response would otherwise answer the 409 the label's unique key - * raises rather than the 201 the first attempt produced — a create the client - * has no record of, reported as a conflict it cannot act on. + * offline outbox under the same `Idempotency-Key`, and nothing else here would + * catch the replay. The row's key is minted fresh per request + * (`custom:`), and the label it would duplicate lives in + * `labelEncrypted` — AES-GCM with a per-write IV, so no unique index can be + * put on it. A replay after a lost success response therefore wrote a SECOND + * row carrying the same label, up to the fifty-row cap: a silent duplicate in + * the person's own symptom vocabulary rather than a conflict anything refused. + * The wrapper answers the first attempt's 201 instead, and the in-flight 409 it + * defines is the only 409 this route can produce. */ export const POST = apiHandler( withIdempotency<[NextRequest]>(postCustomSymptom), diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 0bef620e2..d6cc7b624 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -132,9 +132,14 @@ export async function checkAnalyticsReadRateLimit( * `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten creates * should still meet a cap. Three hundred a minute is five writes a second * sustained for a full minute — no hand-driven session and no sync burst - * reaches it, and every one of these routes is already wrapped in - * `withIdempotency`, so an honest retry storm is deduplicated before it is - * counted. It exists to cut off the pathological loop, not to shape traffic. + * reaches it. Ten of the eleven are wrapped in `withIdempotency`, so an honest + * retry storm is deduplicated before it is counted; `POST + * /api/medications/intake` is not, because it updates a named `intakeId` + * rather than creating a row, so a replay writes the status that is already + * there. It exists to cut off the pathological loop, not to shape traffic. + * + * A refused request is counted too: the check runs before the body is read, + * so a client looping on a 422 spends its allowance without writing a row. * * Keyed on the ACTING account rather than the record, the frozen precedent from * the delegable medication routes: a delegate burns their own allowance rather