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
139 changes: 139 additions & 0 deletions docs/idempotency.md
Original file line number Diff line number Diff line change
@@ -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. |
24 changes: 24 additions & 0 deletions issue353.md
Original file line number Diff line number Diff line change
@@ -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
223 changes: 223 additions & 0 deletions src/__tests__/idempotencyCache.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading