From e4a8f0307af7aa9e25cf7acc7595a8989f71b293 Mon Sep 17 00:00:00 2001 From: Yunus Date: Sun, 26 Jul 2026 07:14:07 +0100 Subject: [PATCH] docs: document the Idempotency-Key contract and cache semantics --- docs/idempotency.md | 139 +++++++++++++++ issue353.md | 24 +++ src/__tests__/idempotencyCache.test.ts | 223 +++++++++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 docs/idempotency.md create mode 100644 issue353.md create mode 100644 src/__tests__/idempotencyCache.test.ts diff --git a/docs/idempotency.md b/docs/idempotency.md new file mode 100644 index 0000000..06c8fa7 --- /dev/null +++ b/docs/idempotency.md @@ -0,0 +1,139 @@ +# Idempotency-Key contract + +The `Idempotency-Key` request header lets callers safely retry create +operations without accidentally duplicating resources. The backend keeps a +bounded, TTL-expiring in-memory cache of the first response for each unique +key so that a repeat of the same request replays the original response +verbatim — the handler is **not** invoked a second time. + +> **The cache is purely in-memory.** A process restart discards every cached +> entry. Clients MUST supply a fresh key for each unique operation and be +> prepared for a `409 idempotency_conflict` when reusing a key with a +> different payload. + +--- + +## Header + +| Field | Value | +|------------------|----------------------------| +| Header name | `Idempotency-Key` | +| Required? | No — a missing or invalid key is silently ignored (the endpoint behaves normally). | +| Accepted length | 1–200 characters | +| Accepted charset | Any ASCII string (1–200 bytes). Keys outside this range cause the middleware to pass through without caching. | + +--- + +## Participating endpoints + +The following **POST** endpoints are protected by the idempotency guard: + +| Method | Path | +|--------|-------------------------------| +| POST | `/api/v1/pairs` | +| POST | `/api/v1/api-keys` | +| POST | `/api/v1/webhooks` | + +All other mutating routes — including the routes listed below — do +**not** participate and ignore the `Idempotency-Key` header entirely: + +| Method | Path | +|----------|-------------------------------------------------------------------| +| POST | `/api/v1/pairs/bulk` | +| POST | `/api/v1/quote/bulk` | +| POST | `/api/v1/pairs/:source/:destination/reset` | +| POST | `/api/v1/api-keys/:prefix/rotate` | +| POST | `/api/v1/admin/pause` | +| POST | `/api/v1/admin/unpause` | +| POST | `/api/v1/admin/read-only` | +| POST | `/api/v1/admin/read-write` | +| PATCH | `/api/v1/pairs/:source/:destination/enabled` | +| PATCH | `/api/v1/pairs/:source/:destination/{liquidity,max,min,fee_bps,rate}` | +| PATCH | `/api/v1/webhooks/:id` | +| PATCH | `/api/v1/config` | +| DELETE | `/api/v1/pairs/:source/:destination` | +| DELETE | `/api/v1/api-keys/:prefix` | +| DELETE | `/api/v1/webhooks/:id` | + +--- + +## Semantics + +### First request with a given key + +1. The middleware derives a cache key: `METHOD:path:idempotency-key`. +2. A SHA-256 hash of `JSON.stringify(body ?? null)` is computed. +3. The request proceeds to the route handler. +4. The handler's JSON response (`status` + `body`) is captured and stored: + `{ status, body, bodyHash, expiresAt }`. + +### Repeat request — exact same body + +1. The same cache key and body hash are computed. +2. A live cache entry is found and the body hash matches. +3. **The handler is skipped.** The stored `status` and `body` are returned + to the caller verbatim, with the real `Content-Type: application/json` + header. + +### Repeat request — different body + +1. The same cache key is found but the body hash **differs**. +2. The request is rejected with: + +``` +HTTP/1.1 409 Conflict +Content-Type: application/json + +{ + "error": "idempotency_conflict", + "message": "Idempotency-Key reused with a different request body", + "requestId": "…" +} +``` + +### Expired or evicted entry + +If the cached entry has expired (TTL elapsed) or was evicted (cache at +capacity), the key is treated as fresh — the handler executes and a new +entry is stored. + +### Key outside 1–200 characters + +The middleware passes through without caching; the endpoint behaves as if +no `Idempotency-Key` header was sent. + +--- + +## Cache behaviour + +### TTL + +Default: **24 hours**. Override via the `IDEMPOTENCY_TTL_MS` environment +variable (value in milliseconds). + +```env +IDEMPOTENCY_TTL_MS=3600000 # 1 hour +``` + +### Bounding + +Maximum entries: **10,000**. Override via `IDEMPOTENCY_CACHE_MAX`. + +```env +IDEMPOTENCY_CACHE_MAX=50000 # allow up to 50,000 entries +``` + +When the cache reaches capacity the oldest entry (by insertion order) is +evicted to make room for a new one. Expired entries are pruned on every +write. + +--- + +## Error code reference + +The `idempotency_conflict` error code is part of the standard API error +envelope (see `docs/api.md`): + +| Code | HTTP | When it is emitted | +|------------------------|------|-----------------------------------------------------------| +| `idempotency_conflict` | 409 | A repeat request carries the same `Idempotency-Key` but a different request body. | diff --git a/issue353.md b/issue353.md new file mode 100644 index 0000000..9011e45 --- /dev/null +++ b/issue353.md @@ -0,0 +1,24 @@ +Document document the Idempotency-Key contract and cache semantics +Description +The idempotencyGuard in src/index.ts protects pair, webhook, and api-key creation with a TTL cache, a 409 conflict path, and a 200-character key bound, none of which is documented for API consumers. + +Requirements and context +Repository scope: StableRoute-Org/Stableroute-backend only. +Specify the header name, accepted key length, TTL, and replay-response semantics. +Describe exactly when a 409 idempotency_conflict is returned versus a cached replay. +List which endpoints participate and note explicitly that other mutating routes do not. +Suggested execution +Fork the repo and create a branch +git checkout -b docs/docs-document-the-idempotency-key-contract +Write code in: docs/idempotency.md +Write comprehensive tests in: src/__tests__/idempotencyCache.test.ts +Add documentation: docs/idempotency.md +Test and commit +Run npm test, npm run lint +Cover edge cases; include test output +Example commit message +docs: document the Idempotency-Key contract and cache semantics + +Guidelines +Minimum 95 percent test coverage for impacted modules +Clear documentation diff --git a/src/__tests__/idempotencyCache.test.ts b/src/__tests__/idempotencyCache.test.ts new file mode 100644 index 0000000..c540ca8 --- /dev/null +++ b/src/__tests__/idempotencyCache.test.ts @@ -0,0 +1,223 @@ +import request from "supertest"; +import app, { clearIdempotencyCache } from "../index"; +import { resetStores } from "../stores"; + +describe("Idempotency-Key — cache edge cases", () => { + beforeEach(() => { + resetStores(); + clearIdempotencyCache(); + delete process.env.IDEMPOTENCY_TTL_MS; + delete process.env.IDEMPOTENCY_CACHE_MAX; + }); + + afterEach(() => { + delete process.env.IDEMPOTENCY_TTL_MS; + delete process.env.IDEMPOTENCY_CACHE_MAX; + }); + + describe("Key length boundaries", () => { + it("passes through when key is an empty string (0-length)", async () => { + const res1 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", "") + .send({ label: "k1" }); + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", "") + .send({ label: "k1" }); + expect(res2.status).toBe(201); + expect(res2.body.key).not.toBe(res1.body.key); + }); + + it("caches a key at exactly 1 character", async () => { + const key = "a"; + const res1 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "one-char" }); + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "one-char" }); + expect(res2.status).toBe(201); + expect(res2.body).toEqual(res1.body); + }); + + it("caches a key at exactly 200 characters", async () => { + const key = "x".repeat(200); + const res1 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "200-char" }); + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "200-char" }); + expect(res2.status).toBe(201); + expect(res2.body).toEqual(res1.body); + }); + + it("passes through when key is 201 characters", async () => { + const key = "x".repeat(201); + const res1 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "too-long" }); + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "too-long" }); + expect(res2.status).toBe(201); + expect(res2.body.key).not.toBe(res1.body.key); + }); + + it("passes through when key is whitespace only (no caching)", async () => { + const key = " "; + const res1 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "spaces" }); + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "spaces" }); + expect(res2.status).toBe(201); + expect(res2.body.key).not.toBe(res1.body.key); + }); + }); + + describe("Non-participating endpoints", () => { + it("POST /api/v1/pairs/bulk ignores Idempotency-Key (same key, different body → no 409)", async () => { + const key = "bulk-idem"; + const res1 = await request(app) + .post("/api/v1/pairs/bulk") + .set("Idempotency-Key", key) + .send({ pairs: [{ source: "BULKA", destination: "BULKB" }] }); + expect(res1.status).toBe(200); + expect(res1.body.results[0].ok).toBe(true); + + const res2 = await request(app) + .post("/api/v1/pairs/bulk") + .set("Idempotency-Key", key) + .send({ pairs: [{ source: "BULKC", destination: "BULKD" }] }); + expect(res2.status).toBe(200); + expect(res2.body.results[0].ok).toBe(true); + }); + + it("PATCH /api/v1/pairs/:source/:destination/enabled ignores Idempotency-Key", async () => { + await request(app) + .post("/api/v1/pairs") + .send({ source: "ENABL", destination: "TEST" }); + + const key = "patch-idem"; + const res1 = await request(app) + .patch("/api/v1/pairs/ENABL/TEST/enabled") + .set("Idempotency-Key", key) + .send({ enabled: true }); + expect(res1.status).toBe(200); + expect(res1.body.enabled).toBe(true); + + const res2 = await request(app) + .patch("/api/v1/pairs/ENABL/TEST/enabled") + .set("Idempotency-Key", key) + .send({ enabled: false }); + expect(res2.status).toBe(200); + expect(res2.body.enabled).toBe(false); + }); + }); + + describe("Cache lifecycle", () => { + it("clearIdempotencyCache() discards all entries — handler executes again", async () => { + const key = "clear-test"; + const res1 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "before-clear" }); + expect(res1.status).toBe(201); + + clearIdempotencyCache(); + + const res2 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "before-clear" }); + expect(res2.status).toBe(201); + expect(res2.body.key).not.toBe(res1.body.key); + }); + + it("expired entry is removed and handler executes again", async () => { + process.env.IDEMPOTENCY_TTL_MS = "20"; + const key = "expire-test"; + const res1 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "expire-me" }); + expect(res1.status).toBe(201); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + const res2 = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "expire-me" }); + expect(res2.status).toBe(201); + expect(res2.body.key).not.toBe(res1.body.key); + }); + }); + + describe("Body hash sensitivity", () => { + it("different JSON key ordering yields 409 conflict", async () => { + const key = "json-order"; + const body1 = { source: "ORDRA", destination: "TSTRA" }; + const body2 = { destination: "TSTRA", source: "ORDRA" }; + + const res1 = await request(app) + .post("/api/v1/pairs") + .set("Idempotency-Key", key) + .send(body1); + expect(res1.status).toBe(201); + + const res2 = await request(app) + .post("/api/v1/pairs") + .set("Idempotency-Key", key) + .send(body2); + expect(res2.status).toBe(409); + expect(res2.body.error).toBe("idempotency_conflict"); + }); + }); + + describe("Cross-endpoint isolation", () => { + it("same key on different endpoints does not cross-contaminate", async () => { + const key = "shared-key"; + + const keyRes = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "shared" }); + expect(keyRes.status).toBe(201); + + const whRes = await request(app) + .post("/api/v1/webhooks") + .set("Idempotency-Key", key) + .send({ url: "https://example.com/share", events: ["pair.registered"] }); + expect(whRes.status).toBe(201); + + const keyReplay = await request(app) + .post("/api/v1/api-keys") + .set("Idempotency-Key", key) + .send({ label: "shared" }); + expect(keyReplay.status).toBe(201); + expect(keyReplay.body).toEqual(keyRes.body); + }); + }); +});