CREATE TABLE IF NOT EXISTS idempotency_keys (
id BIGSERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
request_hash TEXT NOT NULL,
response_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
import crypto from 'node:crypto';
import type { Request, Response } from 'express';
import { withClient } from '../db/pool';
function hashBody(body: unknown) {
return crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
}
export async function createProject(req: Request, res: Response) {
const key = req.header('idempotency-key');
if (!key) return res.status(400).json({ error: 'MISSING_IDEMPOTENCY_KEY' });
const requestHash = hashBody(req.body);
const result = await withClient(async (client) => {
await client.query('BEGIN');
try {
const existing = await client.query(
'SELECT request_hash, response_json FROM idempotency_keys WHERE key = $1',
[key]
);
if (existing.rowCount === 1) {
const row = existing.rows[0];
if (row.request_hash !== requestHash) {
return { conflict: true as const };
}
return { replay: true as const, response: row.response_json };
}
const created = { id: crypto.randomUUID(), name: req.body.name };
await client.query(
'INSERT INTO idempotency_keys(key, request_hash, response_json) VALUES($1, $2, $3)',
[key, requestHash, created]
);
await client.query('COMMIT');
return { created: true as const, response: created };
} catch (e) {
await client.query('ROLLBACK');
throw e;
}
});
if ('conflict' in result) return res.status(409).json({ error: 'IDEMPOTENCY_KEY_REUSED' });
return res.status(result.replay ? 200 : 201).json(result.response);
}
Retries are inevitable: mobile clients, flaky networks, and load balancers will resend POST requests. Without idempotency you end up double-charging or double-creating records. I store an Idempotency-Key with a sha256 hash of the request body and the resulting response payload. If the same key arrives with a different payload, I treat it as a client bug and return a 409 instead of guessing. I also keep the TTL long enough to cover real retry windows (minutes to hours, not seconds). The big win is that I can safely enable aggressive retries at the edge without duplicating side effects, and incident response is easier because I can reason about replays deterministically.