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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ Changelog
All notable changes to the AnchorNet API are documented here.

[Unreleased]
Fixed
Idempotency: the replay cache is no longer a private `Map` closed over by
each `idempotency()` call. All mounts share a process-wide
`MemoryIdempotencyStore` with a hard entry cap (default 1024; expired and
soonest-to-expire eviction), and concurrent same-key requests share one
in-flight execution so only a single handler runs. Replay still returns the
cached JSON body (not a conflict); only `status` + body are stored — no
response headers. Multi-replica sharing still waits on the separate
persistence-layer issue; this change closes the within-process holes without
adding an external dependency.
Added
Metrics: GET /api/v1/metrics now reports totalSettledAmount (sum of
settlement amount) and totalFeesCollected (sum of settlement fee),
Expand Down
36 changes: 23 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,20 @@ message `rate limit exceeded, try again later`. Clients should treat this as a
retryable response and back off before sending the next mutating request.

Mutating requests may also send an Idempotency-Key header. The first request
for a given key/method/path combination runs normally and its response is cached; any
later request reusing the same key (within 24h) replays the original response
instead of re-running the handler, so retried requests don't double-apply
side effects (e.g. registering the same anchor twice). State is in-memory and
per-process.
for a given key/method/path combination runs normally and its JSON response is
cached; any later request reusing the same key (within the configured TTL)
replays the original response instead of re-running the handler, so retried
requests don't double-apply side effects (e.g. registering the same anchor
twice). Reusing a key with a different body returns `422 IDEMPOTENCY_KEY_REUSE`.

Cache state is a process-wide in-memory store shared by every `idempotency()`
mount (hard-capped; default 1024 entries, soonest-expiry eviction). Concurrent
same-key requests share one in-flight execution. Multi-replica deployments still
need an external shared store once the persistence layer lands — this is the
same sequencing constraint as the rate limiter.

Only status + JSON body are stored for replay; response headers are never
cached.

Walkthrough Example
To verify how the idempotency system behaves, you can perform the following walkthrough using curl.
Expand Down Expand Up @@ -213,27 +222,28 @@ x-request-id: 4a123f52-1623-429b-ba67-3d0d0d5c2eb0
"registeredAt": "2026-07-22T14:17:57.537Z",
"active": true
}
Mismatched body (Known Gap): If you reuse the same Idempotency-Key but change the request payload (e.g., modifying the name field), the server will still return the cached 201 response corresponding to the first payload. Detecting mismatched request bodies (which would ideally return a 422 error) is currently a known gap in this system.
Mismatched body: If you reuse the same Idempotency-Key but change the request
payload, the server returns `422` with code `IDEMPOTENCY_KEY_REUSE` instead of
replaying the original response.

Bash

curl -i -X POST http://localhost:3001/api/v1/anchors
-H "Content-Type: application/json"
-H "Idempotency-Key: register-anchor-xyz"
-d '{"id": "anchor-xyz", "name": "Anchor XYZ Modified Name"}'
Response (Replayed from the original cached version):
Response:

http

HTTP/1.1 201 Created
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json; charset=utf-8
x-request-id: 184c8357-3fc3-4e2f-a87c-19042ab804fe

{
"id": "anchor-xyz",
"name": "Anchor XYZ",
"registeredAt": "2026-07-22T14:17:57.537Z",
"active": true
"error": {
"code": "IDEMPOTENCY_KEY_REUSE",
"message": "Idempotency key already used with a different request body"
}
}
The process shuts down gracefully on SIGTERM/SIGINT: it stops accepting
new connections, closes the HTTP server, marks /health/ready unready, and
Expand Down
7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ Settlement, anchor, and liquidity data are held in process-local in-memory
repositories (src/repositories/*), all extending the shared
InMemoryRepository base class.

Idempotency cache (src/middleware/idempotency.ts) follows the same sequencing:
a process-wide `MemoryIdempotencyStore` (shared across mounts, hard-capped,
in-flight coalescing) closes within-process holes without introducing Redis/DB.
Cross-replica idempotency is intentionally deferred to the persistence-layer
issue — once that store exists, swap the default `IdempotencyStore`
implementation rather than bolting on a second persistence stack here.

Persistence-Swap Risk (read before swapping any repository for a DB)
Several repositories already document that they are "swappable for a
persistent … store later" (e.g. liquidityRepository.ts). This is a forward
Expand Down
131 changes: 122 additions & 9 deletions src/middleware/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,50 @@
import express, { Express } from "express";
import request from "supertest";
import { idempotency, IdempotencyOptions } from "./idempotency";
import {
idempotency,
IdempotencyOptions,
MemoryIdempotencyStore,
resetDefaultIdempotencyStore,
} from "./idempotency";
import { errorHandler } from "./errorHandler";
import { ApiError } from "../errors/ApiError";

function makeApp(options?: IdempotencyOptions): Express {
let counter = 0;
function makeApp(
options?: IdempotencyOptions,
counterRef?: { value: number },
): Express {
const counter = counterRef ?? { value: 0 };
// Isolate unit tests from the process-wide default store unless a shared
// store is explicitly injected (cross-instance coverage).
const store = options?.store ?? new MemoryIdempotencyStore(options?.maxEntries);
const app = express();
app.use(express.json());
app.use(idempotency(options));
app.use(idempotency({ ...options, store }));
app.post("/mutate", (_req, res) => {
counter += 1;
res.status(201).json({ counter });
counter.value += 1;
res.status(201).json({ counter: counter.value });
});
app.post("/fail", (_req, _res, next) => {
counter += 1;
counter.value += 1;
next(ApiError.conflict("already exists"));
});
app.get("/read", (_req, res) => {
counter += 1;
res.json({ counter });
counter.value += 1;
res.json({ counter: counter.value });
});
app.post("/slow", async (_req, res) => {
counter.value += 1;
await new Promise((resolve) => setTimeout(resolve, 50));
res.status(201).json({ counter: counter.value });
});
app.use(errorHandler);
return app;
}

beforeEach(() => {
resetDefaultIdempotencyStore();
});

describe("idempotency", () => {
it("replays the cached response for a repeated key", async () => {
const app = makeApp();
Expand Down Expand Up @@ -198,4 +218,97 @@ describe("idempotency", () => {
expect(second.status).toBe(201);
expect(second.body.counter).toBe(1);
});

it("shares cache across two middleware instances with the same store", async () => {
const store = new MemoryIdempotencyStore();
const counter = { value: 0 };
// Two separate middleware instances (two apps) sharing one store — the
// pre-fix design closed over a private Map per call, so appB would
// re-execute after appA had already handled the key.
const appA = makeApp({ store }, counter);
const appB = makeApp({ store }, counter);

const first = await request(appA)
.post("/mutate")
.set("Idempotency-Key", "shared-key");
const second = await request(appB)
.post("/mutate")
.set("Idempotency-Key", "shared-key");

expect(first.body.counter).toBe(1);
expect(second.body.counter).toBe(1);
expect(counter.value).toBe(1);
});

it("bounds the cache under many distinct keys", async () => {
const maxEntries = 8;
const store = new MemoryIdempotencyStore(maxEntries);
const app = makeApp({ store });

for (let i = 0; i < maxEntries * 3; i += 1) {
const res = await request(app)
.post("/mutate")
.set("Idempotency-Key", `key-${i}`);
expect(res.status).toBe(201);
}

expect(store.size()).toBeLessThanOrEqual(maxEntries);
});

it("runs only one handler for concurrent same-key requests", async () => {
const counter = { value: 0 };
const app = makeApp({}, counter);

const [first, second] = await Promise.all([
request(app).post("/slow").set("Idempotency-Key", "concurrent"),
request(app).post("/slow").set("Idempotency-Key", "concurrent"),
]);

expect(first.status).toBe(201);
expect(second.status).toBe(201);
expect(first.body.counter).toBe(1);
expect(second.body.counter).toBe(1);
expect(counter.value).toBe(1);
});

it("leaves keyless mutating requests unaffected", async () => {
const counter = { value: 0 };
const app = makeApp({}, counter);

const first = await request(app).post("/mutate");
const second = await request(app).post("/mutate");

expect(first.body.counter).toBe(1);
expect(second.body.counter).toBe(2);
expect(counter.value).toBe(2);
});

it("does not store response headers on the cached entry", async () => {
const store = new MemoryIdempotencyStore();
const app = express();
app.use(express.json());
app.use(idempotency({ store }));
app.post("/mutate", (_req, res) => {
res.setHeader("X-Sensitive", "secret-token");
res.setHeader("Set-Cookie", "session=abc");
res.status(201).json({ ok: true });
});
app.use(errorHandler);

await request(app).post("/mutate").set("Idempotency-Key", "hdr");
const entry = store.get("POST /mutate hdr");
expect(entry).toBeDefined();
expect(entry).toEqual({
status: 201,
body: { ok: true },
expiresAt: expect.any(Number),
bodyHash: expect.any(String),
});
expect(Object.keys(entry!).sort()).toEqual([
"body",
"bodyHash",
"expiresAt",
"status",
]);
});
});
Loading