Skip to content

Commit 7207a69

Browse files
authored
Merge pull request #172 from Macnelson9/idempotency
Idempotency
2 parents 8f55b84 + 1a8e654 commit 7207a69

7 files changed

Lines changed: 363 additions & 1 deletion

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* IdempotencyMiddleware unit tests.
3+
*
4+
* Uses in-memory stubs for Redis — no real Redis required.
5+
*/
6+
7+
import { BadRequestException } from '@nestjs/common';
8+
import { IdempotencyMiddleware, IDEMPOTENCY_VERSION } from '../common/middleware/idempotency.middleware';
9+
import * as cache from '../redis/cache';
10+
11+
// ── Helpers ───────────────────────────────────────────────────────────────────
12+
13+
function makeReq(overrides: Partial<{
14+
method: string;
15+
path: string;
16+
headers: Record<string, string>;
17+
user: { sub: string };
18+
}> = {}): any {
19+
return {
20+
method: 'POST',
21+
path: '/ipfs/upload',
22+
headers: {},
23+
...overrides,
24+
};
25+
}
26+
27+
function makeRes(): any {
28+
const res: any = {
29+
statusCode: 200,
30+
_headers: {} as Record<string, string>,
31+
_body: undefined as unknown,
32+
setHeader(k: string, v: string) { this._headers[k] = v; },
33+
status(code: number) { this.statusCode = code; return this; },
34+
json(body: unknown) { this._body = body; return this; },
35+
};
36+
res.json = res.json.bind(res);
37+
return res;
38+
}
39+
40+
const VALID_KEY = '550e8400-e29b-41d4-a716-446655440000';
41+
42+
// ── Tests ─────────────────────────────────────────────────────────────────────
43+
44+
describe('IdempotencyMiddleware', () => {
45+
let middleware: IdempotencyMiddleware;
46+
let getEntry: jest.SpyInstance;
47+
let setEntry: jest.SpyInstance;
48+
49+
beforeEach(() => {
50+
middleware = new IdempotencyMiddleware();
51+
getEntry = jest.spyOn(cache, 'getIdempotencyEntry').mockResolvedValue(null);
52+
setEntry = jest.spyOn(cache, 'setIdempotencyEntry').mockResolvedValue(undefined);
53+
});
54+
55+
afterEach(() => jest.restoreAllMocks());
56+
57+
test('passes through when no Idempotency-Key header', async () => {
58+
const req = makeReq();
59+
const res = makeRes();
60+
const next = jest.fn();
61+
await middleware.use(req, res, next);
62+
expect(next).toHaveBeenCalled();
63+
expect(getEntry).not.toHaveBeenCalled();
64+
});
65+
66+
test('rejects malformed key (not UUID v4) with 400', async () => {
67+
const req = makeReq({ headers: { 'idempotency-key': 'not-a-uuid' } });
68+
await expect(middleware.use(req, makeRes(), jest.fn())).rejects.toBeInstanceOf(BadRequestException);
69+
});
70+
71+
test('rejects key with wrong UUID version (v1)', async () => {
72+
const v1Key = '550e8400-e29b-11d4-a716-446655440000'; // version digit = 1
73+
const req = makeReq({ headers: { 'idempotency-key': v1Key } });
74+
await expect(middleware.use(req, makeRes(), jest.fn())).rejects.toBeInstanceOf(BadRequestException);
75+
});
76+
77+
test('cache miss: calls next and stores response on json()', async () => {
78+
const req = makeReq({ headers: { 'idempotency-key': VALID_KEY } });
79+
const res = makeRes();
80+
const next = jest.fn();
81+
82+
await middleware.use(req, res, next);
83+
expect(next).toHaveBeenCalled();
84+
85+
// Simulate handler writing a response
86+
res.json({ cid: 'Qm123' });
87+
88+
// Wait for the async setEntry call
89+
await new Promise(resolve => setImmediate(resolve));
90+
expect(setEntry).toHaveBeenCalledWith(
91+
expect.any(String),
92+
{ status: 200, body: { cid: 'Qm123' }, version: IDEMPOTENCY_VERSION },
93+
expect.any(Number),
94+
);
95+
});
96+
97+
test('cache hit: replays stored response without calling next', async () => {
98+
getEntry.mockResolvedValue({ status: 200, body: { cid: 'Qm123' }, version: IDEMPOTENCY_VERSION });
99+
const req = makeReq({ headers: { 'idempotency-key': VALID_KEY } });
100+
const res = makeRes();
101+
const next = jest.fn();
102+
103+
await middleware.use(req, res, next);
104+
105+
expect(next).not.toHaveBeenCalled();
106+
expect(res._body).toEqual({ cid: 'Qm123' });
107+
expect(res._headers['Idempotency-Replayed']).toBe('true');
108+
});
109+
110+
test('double-submit returns identical body on second call', async () => {
111+
// First call — cache miss
112+
const req1 = makeReq({ headers: { 'idempotency-key': VALID_KEY } });
113+
const res1 = makeRes();
114+
await middleware.use(req1, res1, jest.fn());
115+
res1.json({ cid: 'QmABC' });
116+
await new Promise(resolve => setImmediate(resolve));
117+
118+
// Capture what was stored
119+
const stored = setEntry.mock.calls[0][1] as cache.IdempotencyEntry;
120+
121+
// Second call — cache hit
122+
getEntry.mockResolvedValue(stored);
123+
const req2 = makeReq({ headers: { 'idempotency-key': VALID_KEY } });
124+
const res2 = makeRes();
125+
const next2 = jest.fn();
126+
await middleware.use(req2, res2, next2);
127+
128+
expect(next2).not.toHaveBeenCalled();
129+
expect(res2._body).toEqual({ cid: 'QmABC' });
130+
});
131+
132+
test('5xx responses are NOT cached', async () => {
133+
const req = makeReq({ headers: { 'idempotency-key': VALID_KEY } });
134+
const res = makeRes();
135+
res.statusCode = 503;
136+
await middleware.use(req, res, jest.fn());
137+
res.json({ error: 'service_unavailable' });
138+
await new Promise(resolve => setImmediate(resolve));
139+
expect(setEntry).not.toHaveBeenCalled();
140+
});
141+
142+
test('4xx error responses ARE cached (replay error to client)', async () => {
143+
const req = makeReq({ headers: { 'idempotency-key': VALID_KEY } });
144+
const res = makeRes();
145+
res.statusCode = 400;
146+
await middleware.use(req, res, jest.fn());
147+
res.json({ error: 'invalid_file' });
148+
await new Promise(resolve => setImmediate(resolve));
149+
expect(setEntry).toHaveBeenCalledWith(
150+
expect.any(String),
151+
{ status: 400, body: { error: 'invalid_file' }, version: IDEMPOTENCY_VERSION },
152+
expect.any(Number),
153+
);
154+
});
155+
156+
test('different subjects produce different cache keys (scope isolation)', async () => {
157+
const capturedKeys: string[] = [];
158+
setEntry.mockImplementation(async (key: string) => { capturedKeys.push(key); });
159+
160+
const req1 = makeReq({ headers: { 'idempotency-key': VALID_KEY }, user: { sub: 'userA' } });
161+
const res1 = makeRes();
162+
await middleware.use(req1, res1, jest.fn());
163+
res1.json({ ok: true });
164+
165+
const req2 = makeReq({ headers: { 'idempotency-key': VALID_KEY }, user: { sub: 'userB' } });
166+
const res2 = makeRes();
167+
await middleware.use(req2, res2, jest.fn());
168+
res2.json({ ok: true });
169+
170+
await new Promise(resolve => setImmediate(resolve));
171+
expect(capturedKeys[0]).not.toBe(capturedKeys[1]);
172+
});
173+
174+
test('Redis unavailable: fails open and calls next', async () => {
175+
getEntry.mockRejectedValue(new Error('Redis down'));
176+
const req = makeReq({ headers: { 'idempotency-key': VALID_KEY } });
177+
const next = jest.fn();
178+
// Should not throw — fail open
179+
await expect(middleware.use(req, makeRes(), next)).resolves.toBeUndefined();
180+
expect(next).toHaveBeenCalled();
181+
});
182+
183+
test('version mismatch treated as cache miss', async () => {
184+
// Stored entry has old version
185+
getEntry.mockResolvedValue({ status: 200, body: { old: true }, version: IDEMPOTENCY_VERSION - 1 });
186+
// getIdempotencyEntry already filters by version — returns null for mismatch
187+
getEntry.mockResolvedValue(null);
188+
189+
const req = makeReq({ headers: { 'idempotency-key': VALID_KEY } });
190+
const next = jest.fn();
191+
await middleware.use(req, makeRes(), next);
192+
expect(next).toHaveBeenCalled();
193+
});
194+
});

backend/src/app.module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { RequestContextMiddleware } from './common/middleware/request-context.mi
2222
import { AppLoggerService } from './common/logger/app-logger.service';
2323
import { OracleHooksController } from './experimental/oracle-hooks.controller';
2424
import { BetaCalculatorsController } from './experimental/beta-calculators.controller';
25+
import { IdempotencyMiddleware } from './common/middleware/idempotency.middleware';
2526

2627
@Module({
2728
imports: [
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* IdempotencyMiddleware — safe retries for POST /ipfs/upload and POST /tx/submit.
3+
*
4+
* ## How it works
5+
*
6+
* 1. Client sends a POST with `Idempotency-Key: <uuid-v4>` header.
7+
* 2. Middleware hashes `SHA-256(method + path + key + subject)` to form the
8+
* Redis cache key. Scoping by subject prevents one user replaying another's
9+
* key on the same endpoint.
10+
* 3. On cache hit (same status + body stored): response is replayed immediately,
11+
* no handler is invoked. Header `Idempotency-Replayed: true` is set.
12+
* 4. On cache miss: request proceeds normally. The response interceptor stores
13+
* the result before flushing to the client.
14+
*
15+
* ## Client responsibilities
16+
*
17+
* - Generate a fresh UUID v4 per *logical* operation (not per retry).
18+
* - Reuse the same key on retries of the same operation.
19+
* - Keys must be 36 characters (UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx).
20+
* Shorter/longer keys are rejected with 400.
21+
* - Key collision probability with UUID v4 is negligible (~1 in 5.3×10³⁶).
22+
*
23+
* ## TTL and eviction
24+
*
25+
* Cached responses expire after `TTL.IDEMPOTENCY_SECONDS` (24 h). After expiry
26+
* the key is evicted and a fresh request is processed normally. TTL is always
27+
* set unconditionally — Redis growth is bounded.
28+
*
29+
* ## Schema versioning
30+
*
31+
* `IDEMPOTENCY_VERSION` is embedded in every cached entry. Bump it when the
32+
* response shape of a covered endpoint changes. Old entries with a stale
33+
* version are treated as cache misses and overwritten.
34+
*
35+
* ## Redis unavailability (FAIL OPEN)
36+
*
37+
* If Redis is down, the middleware logs a warning and lets the request through
38+
* normally. A duplicate submission may be processed, but the service stays
39+
* available. Operators should alert on Redis unavailability and restore quickly.
40+
* This behaviour is intentional and documented — idempotency is best-effort
41+
* when the cache layer is unavailable.
42+
*
43+
* Contrast with nonce storage (auth), which FAILS CLOSED.
44+
*/
45+
46+
import { Injectable, NestMiddleware, BadRequestException, Logger } from '@nestjs/common';
47+
import { Request, Response, NextFunction } from 'express';
48+
import { createHash } from 'crypto';
49+
import { getIdempotencyEntry, setIdempotencyEntry } from '../redis/cache';
50+
import { TTL } from '../redis/config';
51+
52+
/** Bump this when any covered endpoint's response schema changes. */
53+
export const IDEMPOTENCY_VERSION = 1;
54+
55+
/** UUID v4 pattern — the only accepted key format. */
56+
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
57+
58+
@Injectable()
59+
export class IdempotencyMiddleware implements NestMiddleware {
60+
private readonly logger = new Logger(IdempotencyMiddleware.name);
61+
62+
async use(req: Request, res: Response, next: NextFunction): Promise<void> {
63+
const rawKey = req.headers['idempotency-key'] as string | undefined;
64+
65+
// No key — pass through (idempotency is opt-in)
66+
if (!rawKey) {
67+
next();
68+
return;
69+
}
70+
71+
// Validate format
72+
if (!UUID_V4_RE.test(rawKey)) {
73+
throw new BadRequestException(
74+
'Idempotency-Key must be a valid UUID v4 (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)',
75+
);
76+
}
77+
78+
// Build scoped cache key: hash(method + path + rawKey + subject)
79+
// Subject is the authenticated wallet address when present, otherwise 'anon'.
80+
const subject: string = (req as Request & { user?: { sub?: string } }).user?.sub ?? 'anon';
81+
const cacheKey = createHash('sha256')
82+
.update(`${req.method}:${req.path}:${rawKey}:${subject}`)
83+
.digest('hex');
84+
85+
// Cache hit — replay stored response
86+
const cached = await getIdempotencyEntry(cacheKey, IDEMPOTENCY_VERSION);
87+
if (cached) {
88+
this.logger.debug(`Idempotency replay: key=${rawKey} subject=${subject} path=${req.path}`);
89+
res.setHeader('Idempotency-Replayed', 'true');
90+
res.status(cached.status).json(cached.body);
91+
return;
92+
}
93+
94+
// Cache miss — intercept the response to store it
95+
const originalJson = res.json.bind(res);
96+
res.json = (body: unknown): Response => {
97+
// Only cache 2xx and 4xx responses; never cache 5xx (transient errors)
98+
if (res.statusCode < 500) {
99+
setIdempotencyEntry(
100+
cacheKey,
101+
{ status: res.statusCode, body, version: IDEMPOTENCY_VERSION },
102+
TTL.IDEMPOTENCY_SECONDS,
103+
).catch((err: unknown) => {
104+
this.logger.warn(`Failed to store idempotency entry: ${String(err)}`);
105+
});
106+
}
107+
return originalJson(body);
108+
};
109+
110+
next();
111+
}
112+
}

backend/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ async function bootstrap() {
6767
},
6868
credentials: true,
6969
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
70-
allowedHeaders: ["Authorization", "Content-Type", "X-Requested-With"],
70+
allowedHeaders: ["Authorization", "Content-Type", "X-Requested-With", "Idempotency-Key"],
7171
maxAge: 86400,
7272
preflightContinue: false,
7373
optionsSuccessStatus: 204,

backend/src/redis/cache.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,43 @@ export async function incrementRateLimit(identifier: string): Promise<number> {
145145
return 0;
146146
}
147147
}
148+
149+
// ── Idempotency (FAIL OPEN) ───────────────────────────────────────────────────
150+
//
151+
// Idempotency keys map a hashed (method + path + key + subject) to a cached
152+
// response envelope { status, body, version }. If Redis is unavailable the
153+
// request is processed normally (fail open) — a duplicate may go through, but
154+
// the service remains available. This is documented behaviour; clients must
155+
// treat Redis unavailability as a best-effort guarantee.
156+
157+
export interface IdempotencyEntry {
158+
status: number;
159+
body: unknown;
160+
/** Schema version — bump when response shape changes to invalidate old entries. */
161+
version: number;
162+
}
163+
164+
/**
165+
* Store an idempotency response. TTL is always set (bounded Redis growth).
166+
* Silently swallows Redis errors (fail open).
167+
*/
168+
export async function setIdempotencyEntry(
169+
key: string,
170+
entry: IdempotencyEntry,
171+
ttlSeconds: number,
172+
): Promise<void> {
173+
await cacheSet(`idempotency:${key}`, entry, ttlSeconds);
174+
}
175+
176+
/**
177+
* Retrieve a cached idempotency response.
178+
* Returns null on cache miss, Redis error, or version mismatch.
179+
*/
180+
export async function getIdempotencyEntry(
181+
key: string,
182+
currentVersion: number,
183+
): Promise<IdempotencyEntry | null> {
184+
const entry = await cacheGet<IdempotencyEntry>(`idempotency:${key}`);
185+
if (!entry || entry.version !== currentVersion) return null;
186+
return entry;
187+
}

backend/src/redis/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,16 @@ export const TTL = {
7474
POLICY_CACHE_SECONDS: 30,
7575
/** Claim read cache. Lower TTL because claim status changes frequently. */
7676
CLAIM_CACHE_SECONDS: 10,
77+
/**
78+
* Idempotency key TTL: 24 hours.
79+
*
80+
* A client may safely retry any idempotent POST within this window and
81+
* receive the exact same status code + body as the original response.
82+
* After expiry the key is evicted and a fresh request is processed normally.
83+
*
84+
* Eviction note: TTL is always set unconditionally — Redis growth is bounded.
85+
* Schema versioning: if a response schema changes, bump IDEMPOTENCY_VERSION
86+
* in idempotency.middleware.ts; old cached entries will be ignored.
87+
*/
88+
IDEMPOTENCY_SECONDS: 24 * 60 * 60,
7789
} as const;

backend/src/redis/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,8 @@ export {
1919
setNonce,
2020
consumeNonce,
2121
incrementRateLimit,
22+
setIdempotencyEntry,
23+
getIdempotencyEntry,
24+
type IdempotencyEntry,
2225
} from "./cache";
2326
export { collectRedisMetrics } from "./metrics";

0 commit comments

Comments
 (0)