Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ agentpay-backend/
- [Billing units and settlement semantics](docs/billing-units.md) explains
stroops, `priceStroops`, `billedStroops`, `/api/v1/billing/*`, and why
`POST /api/v1/settle` drains backend counters without moving funds.
- [Idempotency keys](docs/idempotency.md) documents retry-safe billing writes
for `POST /api/v1/usage`, `POST /api/v1/usage/bulk`, and
`POST /api/v1/settle`.

## Quickstart

Expand Down
59 changes: 59 additions & 0 deletions docs/idempotency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Idempotency Keys

`POST /api/v1/usage`, `POST /api/v1/usage/bulk`, and `POST /api/v1/settle`
honor the `Idempotency-Key` header so clients can safely retry billing writes
after a timeout or dropped connection.

## Replay Behavior

The backend stores the first JSON response for each `(caller, key)` pair. The
caller namespace is the recognized `X-API-Key` when present, otherwise the
client IP address. API keys are hashed before they are used in the in-memory
idempotency cache key.

When the same caller retries the same route with the same request body and the
same `Idempotency-Key`, the backend returns the original status and body, and
adds:

```text
Idempotency-Replayed: true
```

The success response shapes are unchanged. For example, a replayed
`POST /api/v1/usage` response still looks like:

```json
{
"agent": "agent-alpha",
"serviceId": "embedding-v1",
"total": 3
}
```

## Conflicts

If the same caller reuses an `Idempotency-Key` with a different request body or
route before the cached entry expires, the backend rejects the request:

```json
{
"error": "idempotency_conflict",
"message": "Idempotency-Key was already used with a different request body or route",
"requestId": "..."
}
```

The response status is `409 Conflict`.

## Cache Limits

The idempotency cache is process-local and in-memory. It resets on restart. Two
optional environment variables control its size and age:

| Variable | Default | Description |
| ------------------------------- | -------: | ---------------------------------- |
| `IDEMPOTENCY_CACHE_TTL_MS` | `600000` | Entry lifetime in milliseconds |
| `IDEMPOTENCY_CACHE_MAX_ENTRIES` | `1000` | Maximum cached idempotency entries |

Expired entries are pruned before handling a keyed request. When the cache is
over capacity, the oldest entries are evicted first.
268 changes: 268 additions & 0 deletions src/middleware/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import request from "supertest";
import { createApp } from "../index.js";
import { apiKeyStore, servicesStore, usageStore } from "../store/state.js";

function createAppWithIdempotencyEnv(env: { ttlMs?: number; maxEntries?: number }) {
const previousTtl = process.env.IDEMPOTENCY_CACHE_TTL_MS;
const previousMax = process.env.IDEMPOTENCY_CACHE_MAX_ENTRIES;

if (env.ttlMs === undefined) {
delete process.env.IDEMPOTENCY_CACHE_TTL_MS;
} else {
process.env.IDEMPOTENCY_CACHE_TTL_MS = String(env.ttlMs);
}

if (env.maxEntries === undefined) {
delete process.env.IDEMPOTENCY_CACHE_MAX_ENTRIES;
} else {
process.env.IDEMPOTENCY_CACHE_MAX_ENTRIES = String(env.maxEntries);
}

const app = createApp();

if (previousTtl === undefined) {
delete process.env.IDEMPOTENCY_CACHE_TTL_MS;
} else {
process.env.IDEMPOTENCY_CACHE_TTL_MS = previousTtl;
}

if (previousMax === undefined) {
delete process.env.IDEMPOTENCY_CACHE_MAX_ENTRIES;
} else {
process.env.IDEMPOTENCY_CACHE_MAX_ENTRIES = previousMax;
}

return app;
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

beforeEach(() => {
apiKeyStore.clear();
servicesStore.clear();
usageStore.clear();
});

void describe("Idempotency-Key handling", () => {
void it("replays POST /api/v1/usage without incrementing usage again", async () => {
const app = createAppWithIdempotencyEnv({});
const payload = {
agent: "agent-idem-usage",
serviceId: "svc-idem-usage",
requests: 3,
};

const first = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "usage-replay")
.send(payload);
assert.strictEqual(first.status, 201);
assert.deepStrictEqual(first.body, {
agent: "agent-idem-usage",
serviceId: "svc-idem-usage",
total: 3,
});

const replay = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "usage-replay")
.send(payload);
assert.strictEqual(replay.status, 201);
assert.strictEqual(replay.headers["idempotency-replayed"], "true");
assert.deepStrictEqual(replay.body, first.body);

const total = await request(app).get(
"/api/v1/usage/agent-idem-usage/svc-idem-usage"
);
assert.strictEqual(total.body.total, 3);
});

void it("rejects reuse of the same key with a different body", async () => {
const app = createAppWithIdempotencyEnv({});

await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "usage-conflict")
.send({
agent: "agent-idem-conflict",
serviceId: "svc-idem-conflict",
requests: 1,
});

const conflict = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "usage-conflict")
.send({
agent: "agent-idem-conflict",
serviceId: "svc-idem-conflict",
requests: 2,
});
assert.strictEqual(conflict.status, 409);
assert.strictEqual(conflict.body.error, "idempotency_conflict");
assert.ok(conflict.body.requestId);
});

void it("replays POST /api/v1/usage/bulk without applying the batch again", async () => {
const app = createAppWithIdempotencyEnv({});
const payload = {
items: [
{ agent: "agent-idem-bulk", serviceId: "svc-idem-bulk", requests: 2 },
{ agent: "agent-idem-bulk", serviceId: "svc-idem-bulk", requests: 4 },
],
};

const first = await request(app)
.post("/api/v1/usage/bulk")
.set("Idempotency-Key", "bulk-replay")
.send(payload);
assert.strictEqual(first.status, 201);
assert.deepStrictEqual(first.body.results, [
{ index: 0, ok: true, total: 2 },
{ index: 1, ok: true, total: 6 },
]);

const replay = await request(app)
.post("/api/v1/usage/bulk")
.set("Idempotency-Key", "bulk-replay")
.send(payload);
assert.strictEqual(replay.status, 201);
assert.strictEqual(replay.headers["idempotency-replayed"], "true");
assert.deepStrictEqual(replay.body, first.body);

const total = await request(app).get("/api/v1/usage/agent-idem-bulk/svc-idem-bulk");
assert.strictEqual(total.body.total, 6);
});

void it("replays POST /api/v1/settle without draining a second time", async () => {
const app = createAppWithIdempotencyEnv({});
servicesStore.set("svc-idem-settle", { priceStroops: 10 });
await request(app)
.post("/api/v1/usage")
.send({ agent: "agent-idem-settle", serviceId: "svc-idem-settle", requests: 5 });

const payload = { agent: "agent-idem-settle", serviceId: "svc-idem-settle" };
const first = await request(app)
.post("/api/v1/settle")
.set("Idempotency-Key", "settle-replay")
.send(payload);
assert.strictEqual(first.status, 200);
assert.strictEqual(first.body.requests, 5);
assert.strictEqual(first.body.billedStroops, 50);

const replay = await request(app)
.post("/api/v1/settle")
.set("Idempotency-Key", "settle-replay")
.send(payload);
assert.strictEqual(replay.status, 200);
assert.strictEqual(replay.headers["idempotency-replayed"], "true");
assert.deepStrictEqual(replay.body, first.body);
});

void it("namespaces idempotency keys by recognized API key", async () => {
const app = createAppWithIdempotencyEnv({});
apiKeyStore.set("tenant-a-secret", { label: "tenant-a", createdAt: Date.now() });
apiKeyStore.set("tenant-b-secret", { label: "tenant-b", createdAt: Date.now() });
const payload = {
agent: "agent-idem-tenant",
serviceId: "svc-idem-tenant",
requests: 2,
};

const first = await request(app)
.post("/api/v1/usage")
.set("X-API-Key", "tenant-a-secret")
.set("Idempotency-Key", "shared-key")
.send(payload);
assert.strictEqual(first.status, 201);

const tenantAReplay = await request(app)
.post("/api/v1/usage")
.set("X-API-Key", "tenant-a-secret")
.set("Idempotency-Key", "shared-key")
.send(payload);
assert.strictEqual(tenantAReplay.headers["idempotency-replayed"], "true");
assert.deepStrictEqual(tenantAReplay.body, first.body);

const tenantBFirst = await request(app)
.post("/api/v1/usage")
.set("X-API-Key", "tenant-b-secret")
.set("Idempotency-Key", "shared-key")
.send(payload);
assert.strictEqual(tenantBFirst.status, 201);
assert.strictEqual(tenantBFirst.headers["idempotency-replayed"], undefined);
assert.strictEqual(tenantBFirst.body.total, 4);
});

void it("expires cached responses after the configured TTL", async () => {
const app = createAppWithIdempotencyEnv({ ttlMs: 5 });
const payload = {
agent: "agent-idem-ttl",
serviceId: "svc-idem-ttl",
requests: 1,
};

const first = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "ttl-key")
.send(payload);
assert.strictEqual(first.status, 201);

const replay = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "ttl-key")
.send(payload);
assert.strictEqual(replay.headers["idempotency-replayed"], "true");
assert.deepStrictEqual(replay.body, first.body);

await sleep(15);

const afterExpiry = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "ttl-key")
.send(payload);
assert.strictEqual(afterExpiry.status, 201);
assert.strictEqual(afterExpiry.headers["idempotency-replayed"], undefined);
assert.strictEqual(afterExpiry.body.total, 2);
});

void it("evicts the oldest idempotency entry when the cache is capped", async () => {
const app = createAppWithIdempotencyEnv({ maxEntries: 1 });
const payloadA = {
agent: "agent-idem-cap-a",
serviceId: "svc-idem-cap",
requests: 1,
};
const payloadB = {
agent: "agent-idem-cap-b",
serviceId: "svc-idem-cap",
requests: 1,
};

const firstA = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "cap-a")
.send(payloadA);
assert.strictEqual(firstA.status, 201);

const replayA = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "cap-a")
.send(payloadA);
assert.strictEqual(replayA.headers["idempotency-replayed"], "true");

await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "cap-b")
.send(payloadB);

const afterEviction = await request(app)
.post("/api/v1/usage")
.set("Idempotency-Key", "cap-a")
.send(payloadA);
assert.strictEqual(afterEviction.status, 201);
assert.strictEqual(afterEviction.headers["idempotency-replayed"], undefined);
assert.strictEqual(afterEviction.body.total, 2);
});
});
Loading