Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ The target is stored against a random `state` (`BeginOAuthUseCase`, 10-minute TT

`GET /meta/client` reports the build floor (`MOBILE_MIN_SUPPORTED_BUILD`, zero meaning none) so a published app can be told it is too old. A web bundle is replaced every morning; an app version lives on phones for months, and without this there is no safe way to make a breaking change.

### Retried writes

A client that loses the *response* to a request cannot know whether the request landed, and on a mobile network that is routine. `Idempotency-Key` makes the retry safe: the claim is a single `SET NX EX` through `CachePort.setIfAbsent`, so the store decides the race rather than the API reading and then writing.

Opt-in per route (`config: { idempotency: true }`) on the seven writes where a duplicate is real damage — posts, both comment endpoints, articles, messages and the two upload endpoints. Everything else is already repeatable: likes, follows and bookmarks are idempotent, a report has a unique constraint, a device registration is an upsert.

The record key carries the **account** (`idem:v1:<userId>:<method>:<route>:<key>`) because a key is a value the client invents and two people can pick the same one. It also carries a fingerprint of the body, so a key reused with a different request is a 409 rather than a wrong answer replayed. Only 2xx responses are stored — a 4xx is deterministic and a 5xx must stay retryable, so neither spends the key.

**It fails open:** an unreachable Redis logs and lets the request through, because this is a safety net over a write that already works and a hard dependency would turn a cache blip into "nobody can post". An endpoint that moves money should revisit that trade rather than inherit it.

`docs/idempotency.md` is the client-facing contract.

### Rate limiting

`RateLimitPolicies` in `src/http/plugins/rate-limit.plugin.ts`: `STRICT` (3/15 min, `continueExceeding`) for login/register, `SENSITIVE` (5/min) for password reset, verification, and write/social actions, `STANDARD` (60/min) for authenticated reads, `PUBLIC` (100/min). Global default is 100/min. Requests with `Authorization: Bot <token>` are allow-listed after a sha256 lookup against `user.botToken` — suspended bots (`bannedAt`) are excluded from that lookup.
Expand Down
94 changes: 94 additions & 0 deletions docs/idempotency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Retrying a write safely

A client that loses the *response* to a request cannot know whether the request
itself landed. On a mobile network that happens routinely, and retrying is the
only thing the client can do. Without help, that retry is a second post, a
second comment, a second uploaded file.

Send an `Idempotency-Key` header and the retry is answered from the first
attempt instead of running again.

## Using it

```http
POST /api/v1/posts
Authorization: Bearer …
Idempotency-Key: 4f1c0f2a-6d2f-4f0b-9d4e-2a1b3c4d5e6f
Content-Type: application/json

{ "content": "…" }
```

Generate a fresh key per user action — a UUID is ideal — and **reuse the same
key for every retry of that action**. A new key means a new action.

The replayed response is byte-for-byte the first one, with the same status
code, plus:

```http
Idempotent-Replay: true
```

Keys live for **24 hours**. A key sent to a route that does not support it is
ignored, and a request with no key behaves exactly as it always has — which is
why the web client needs no changes.

### Which endpoints

Only the writes where a duplicate is real damage:

| Endpoint | |
| --- | --- |
| `POST /posts` | |
| `POST /posts/:postId/comments` | |
| `POST /articles` | |
| `POST /articles/:articleId/comments` | |
| `POST /conversations/:id/messages` | |
| `POST /media` | uploads cost storage and moderation |
| `POST /messages/media` | |

Everything else is already safe to repeat: a like, a follow and a bookmark are
idempotent by nature, a report is protected by a unique constraint, and a
device registration is an upsert.

### Errors

**409 with `A request with this Idempotency-Key is still in progress.`** — the
first attempt has not finished. A `Retry-After` header comes with it. This is
the answer to sending the retry too eagerly, not an error to give up on.

**409 with `This Idempotency-Key was already used with a different request.`** —
the key has been seen with a different body. Almost always a client bug: a key
being reused across actions. Answering it with the earlier result would hide
the bug behind a wrong response.

A request that fails does **not** spend its key. A 4xx is deterministic — the
retry will be told the same thing by the handler — and a 5xx must stay
retryable, or a transient failure would block the action for a day.

## How it works

The record lives in Redis under `idem:v1:<userId>:<method>:<route>:<key>`. The
account is part of the key: a key is a value the client invents, two people can
easily pick the same one, and a shared bucket would hand one of them the
other's response.

The claim is a single `SET NX EX`, so the store decides the race rather than
the API reading and then writing. Winning the claim runs the handler; losing it
means reading the record and either replaying it or reporting a conflict.

The record also holds a fingerprint of the request body, which is what makes
the mismatch case detectable.

### Two things it does not promise

**Uploads are guarded by the key alone.** A multipart body is a stream that has
not been read when the claim is made, so there is nothing to fingerprint. Two
genuinely different uploads sent under one key would be treated as a repeat.
Use a fresh key per upload, as you would anyway.

**It fails open.** If Redis is unreachable the request proceeds without
protection and the failure is logged. This is a safety net over a write that
already works, and making it a hard dependency would turn a cache blip into
"nobody can post anything". If a future endpoint moves money, that endpoint
should reconsider — the trade is not universal.
6 changes: 6 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import reportRoutes from "@routes/report.routes";
import metaRoutes from "@routes/meta.routes";
import deviceRoutes from "@routes/device.routes";
import billingRoutes from "@routes/billing.routes";
import idempotencyPlugin from "@plugins/idempotency/idempotency.plugin";
import websocketPlugin from "./http/plugins/websocket.plugin";
import realtimeRoutes from "@routes/realtime.routes";
import notificationRoutes from "@routes/notification.routes";
Expand Down Expand Up @@ -116,6 +117,11 @@ export class App {

await this.server.after();

// After the container, before the routes: the hooks it installs need
// the cache service, and they have to be in place before anything
// registers a route that opts into them.
this.server.register(idempotencyPlugin);

this.server.register(refreshTokenPurgePlugin);
this.server.register(userPurgePlugin);
this.server.register(notificationPurgePlugin);
Expand Down
19 changes: 19 additions & 0 deletions src/core/ports/services/cache.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ export interface CachePort {
* Deletes a single cache entry by its exact key.
* @param key - The exact cache key to delete.
*/
/**
* Writes a value only when the key is not already taken.
*
* The claim primitive. Reading and then writing leaves a window two
* concurrent callers both pass through, which is exactly the case this
* exists to decide - a retry arriving while the first attempt is still in
* flight. The store settles it in one operation instead.
*
* @param key - The key to claim.
* @param value - The value to write if the claim succeeds.
* @param ttlSeconds - How long the claim lives.
* @returns True when this caller took the key, false when somebody held it.
*/
setIfAbsent(
key: string,
value: string,
ttlSeconds: number,
): Promise<boolean>;

delete(key: string): Promise<void>;

/**
Expand Down
130 changes: 130 additions & 0 deletions src/http/plugins/idempotency/idempotency-record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { createHash } from "node:crypto";

/**
* Where a claimed request got to.
*
* `in-flight` is a real state rather than an absence: a retry that arrives
* while the first attempt is still running must be told to wait, not served a
* second execution.
*/
export type IdempotencyState = "in-flight" | "completed";

/**
* What is remembered about one claimed request.
*/
export interface IdempotencyRecord {
state: IdempotencyState;

/**
* Fingerprint of the request body.
*
* Kept so the same key arriving with a different body is refused rather
* than answered with the first request's result - which would be a client
* bug quietly turned into a wrong response.
*/
fingerprint: string;

/** Status of the stored response, once there is one. */
statusCode?: number;

/** The serialised response body, once there is one. */
body?: string;
}

/**
* The cache key one claim lives under.
*
* Scoped by account as well as by route: a key is a value the client invents,
* so two people can easily pick the same one, and a shared bucket would let
* one of them be handed the other's response.
*
* The version prefix means a change to what is stored can be rolled out by
* bumping it rather than by reasoning about records written by the previous
* deploy.
*
* @param userId - The account making the request
* @param method - HTTP method
* @param routePath - The route pattern, not the resolved URL
* @param key - The client's `Idempotency-Key`
* @returns The cache key
*/
export function idempotencyCacheKey(
userId: string,
method: string,
routePath: string,
key: string,
): string {
return `idem:v1:${userId}:${method}:${routePath}:${key}`;
}

/**
* Fingerprints a request body.
*
* A multipart upload has no body to fingerprint - it is a stream that has not
* been read yet - and returns a constant. The key alone guards those, which is
* weaker and is documented as such.
*
* @param body - The parsed request body, if any
* @returns A stable hash of the body
*/
export function fingerprintBody(body: unknown): string {
if (body === undefined || body === null) return "empty";
if (typeof body !== "object")
return createHash("sha256").update(String(body)).digest("hex");
if (Buffer.isBuffer(body)) return "stream";

try {
return createHash("sha256").update(stableStringify(body)).digest("hex");
} catch {
// A body that will not serialise cannot be compared; the key alone
// guards it, exactly as for an upload.
return "unhashable";
}
}

/**
* Serialises a value with object keys in a fixed order, at every depth.
*
* `JSON.stringify(value, keys)` looks like it would do this and does something
* else entirely: the second argument is a *filter*, applied at every level, so
* any nested key absent from the top-level list disappears. Two bodies
* differing only somewhere nested would then fingerprint the same - and a
* fingerprint collision here does not merely miss a duplicate, it replays the
* wrong response to a genuinely different request.
*
* @param value - The value to serialise
* @returns A stable string for the value
*/
function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value) ?? "null";
}

if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}

const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(
([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`,
);

return `{${entries.join(",")}}`;
}

/**
* Whether a response is worth remembering.
*
* Only success is stored. A 4xx is deterministic - the retry will be told the
* same thing by the handler itself - and a 5xx must stay retryable, because
* remembering a transient failure would block the request for as long as the
* record lives.
*
* @param statusCode - The status the handler produced
* @returns True when the response should be replayed to a retry
*/
export function isReplayable(statusCode: number): boolean {
return statusCode >= 200 && statusCode < 300;
}
Loading