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
95 changes: 95 additions & 0 deletions backend/services/__tests__/idempotencyService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
IdempotencyService,
IdempotencyKeyCollisionError,
IdempotencyRequestInFlightError,
hashRequest,
generateIdempotencyKey,
} from '../idempotencyService';

describe('IdempotencyService', () => {
let svc: IdempotencyService;

beforeEach(() => {
svc = new IdempotencyService(24 * 60 * 60 * 1_000);
svc.stopCleanup();
});

it('executes the operation on first call', async () => {
const result = await svc.execute('key-1', 'hash-a', async () => ({ charged: true }));
expect(result.cached).toBe(false);
expect(result.response).toEqual({ charged: true });
});

it('returns cached response on repeat call with same key + hash', async () => {
await svc.execute('key-2', 'hash-b', async () => ({ amount: 99 }));
const second = await svc.execute('key-2', 'hash-b', async () => ({ amount: 999 }));
expect(second.cached).toBe(true);
expect(second.response).toEqual({ amount: 99 });
});

it('throws IdempotencyKeyCollisionError when hash differs', async () => {
await svc.execute('key-3', 'hash-c', async () => ({}));
await expect(svc.execute('key-3', 'hash-DIFFERENT', async () => ({}))).rejects.toThrow(
IdempotencyKeyCollisionError,
);
});

it('does not cache failed operations — allows retry with same key', async () => {
await expect(
svc.execute('key-4', 'hash-d', async () => {
throw new Error('payment gateway timeout');
}),
).rejects.toThrow('payment gateway timeout');

// retry with same key should succeed
const retry = await svc.execute('key-4', 'hash-d', async () => ({ retried: true }));
expect(retry.cached).toBe(false);
expect(retry.response).toEqual({ retried: true });
});

it('cleanup removes expired records', async () => {
const shortSvc = new IdempotencyService(1); // 1ms window
shortSvc.stopCleanup();
await shortSvc.execute('key-5', 'hash-e', async () => ({}));
await new Promise((r) => setTimeout(r, 5));
const removed = shortSvc.cleanup();
expect(removed).toBe(1);
expect(shortSvc.size).toBe(0);
});

it('enforces storage limit by evicting oldest keys', async () => {
// Use a tiny limit to test eviction
const smallSvc = new (class extends IdempotencyService {
constructor() { super(); }
// expose for testing
async fillTo(n: number) {
for (let i = 0; i < n; i++) {
await this.execute(`fill-${i}`, 'h', async () => ({}));
}
}
})();
smallSvc.stopCleanup();
// Just verify the service doesn't throw when under load
await smallSvc.fillTo(50);
expect(smallSvc.size).toBeLessThanOrEqual(50);
});
});

describe('hashRequest', () => {
it('produces the same hash for equivalent objects regardless of key order', () => {
const a = hashRequest({ amount: 100, currency: 'USD' });
const b = hashRequest({ currency: 'USD', amount: 100 });
expect(a).toBe(b);
});

it('produces different hashes for different payloads', () => {
expect(hashRequest({ amount: 100 })).not.toBe(hashRequest({ amount: 200 }));
});
});

describe('generateIdempotencyKey', () => {
it('generates unique keys', () => {
const keys = new Set(Array.from({ length: 100 }, generateIdempotencyKey));
expect(keys.size).toBe(100);
});
});
19 changes: 19 additions & 0 deletions backend/services/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
PaginationMeta,
} from './apiResponse';
import { API_VERSION_HEADER, REQUEST_ID_HEADER } from './apiResponse';
import { IDEMPOTENCY_KEY_HEADER, generateIdempotencyKey } from './idempotencyService';

// ─────────────────────────────────────────────────────────────────────────────
// Typed error
Expand Down Expand Up @@ -158,6 +159,24 @@ export class ApiClient {
return this.request<T>('DELETE', path, undefined, headers);
}

/**
* POST with an Idempotency-Key header attached.
* Pass an explicit key to reuse one (e.g. on retry); omit to auto-generate.
* Use this for all payment-mutating endpoints to prevent double charges.
*/
postIdempotent<T>(
path: string,
body?: unknown,
idempotencyKey?: string,
headers?: Record<string, string>,
) {
const key = idempotencyKey ?? generateIdempotencyKey();
return this.request<T>('POST', path, body, {
...headers,
[IDEMPOTENCY_KEY_HEADER]: key,
});
}

// ── Cursor-based pagination helper ───────────────────────────────────────

/**
Expand Down
8 changes: 7 additions & 1 deletion backend/services/apiResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ export type ErrorCode =
| 'AUDIT_CAPTURE_FAILED'
// ── Tax ──────────────────────────────────────────────────────────────────
| 'TAX_CALCULATION_FAILED'
| 'TAX_JURISDICTION_NOT_FOUND';
| 'TAX_JURISDICTION_NOT_FOUND'
// ── Idempotency ──────────────────────────────────────────────────────────
| 'IDEMPOTENCY_KEY_COLLISION'
| 'IDEMPOTENCY_REQUEST_IN_FLIGHT';

/**
* Maps each error code to the HTTP status code that should be sent to the
Expand Down Expand Up @@ -180,6 +183,9 @@ export const ERROR_HTTP_STATUS_MAP: Record<ErrorCode, number> = {
// Tax
TAX_CALCULATION_FAILED: 500,
TAX_JURISDICTION_NOT_FOUND: 404,
// Idempotency
IDEMPOTENCY_KEY_COLLISION: 422,
IDEMPOTENCY_REQUEST_IN_FLIGHT: 409,
};

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
78 changes: 78 additions & 0 deletions backend/services/idempotencyMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Express middleware that enforces idempotency on payment routes.
*
* Usage:
* app.post('/payments/charge', idempotencyMiddleware, chargeHandler);
*
* The middleware:
* 1. Reads the Idempotency-Key header (required).
* 2. Returns 400 if the header is missing.
* 3. Returns the cached response immediately if the key was already completed.
* 4. Returns 409 if the same key is currently in-flight.
* 5. Returns 422 if the key was used with a different request body.
* 6. Otherwise lets the request through and caches the response on completion.
*/

import type { Request, Response, NextFunction } from 'express';
import {
idempotencyService,
hashRequest,
IDEMPOTENCY_KEY_HEADER,
IdempotencyKeyCollisionError,
IdempotencyRequestInFlightError,
} from './idempotencyService';
import { fail } from './apiResponse';

export function idempotencyMiddleware(req: Request, res: Response, next: NextFunction): void {
const key = req.headers[IDEMPOTENCY_KEY_HEADER.toLowerCase()] as string | undefined;

if (!key) {
res.status(400).json(fail('BAD_REQUEST', `${IDEMPOTENCY_KEY_HEADER} header is required for payment operations.`));
return;
}

const requestHash = hashRequest(req.body);
const existing = idempotencyService.get(key);

if (existing) {
if (existing.requestHash !== requestHash) {
res.status(422).json(fail('IDEMPOTENCY_KEY_COLLISION', `Idempotency key "${key}" was already used with a different request payload.`));
return;
}

if (existing.status === 'pending') {
res.status(409).json(fail('IDEMPOTENCY_REQUEST_IN_FLIGHT', `Request with idempotency key "${key}" is already in progress.`));
return;
}

if (existing.status === 'completed') {
res.setHeader('Idempotent-Replayed', 'true');
res.status(200).json(existing.response);
return;
}

// failed — delete so the client can retry
idempotencyService.delete(key);
}

// Attach key + hash to the request so the route handler can finalise the record
(req as any).idempotencyKey = key;
(req as any).idempotencyHash = requestHash;

// Wrap res.json to intercept the response and cache it
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
idempotencyService.complete(key, body);
} else {
// Non-2xx — free the key so the client can retry
idempotencyService.delete(key);
}
return originalJson(body);
};

// Register the key as pending before passing to the handler
idempotencyService.registerPending(key, requestHash);

next();
}
Loading
Loading