Idempotency Key Store With Response Replay
Deduplicate retried mutations by idempotency key, share in-flight work, replay stored results and reject key reuse with different payloads.
Code
interface StoredRecord {
fingerprint: string;
expiresAt: number;
promise: Promise<unknown>;
}
export interface IdempotencyOptions {
ttlMs?: number;
now?: () => number;
}
export class IdempotencyConflictError extends Error {
constructor(key: string) {
super("idempotency key reused with a different fingerprint: " + key);
this.name = "IdempotencyConflictError";
}
}
export class IdempotencyKeyStore {
private readonly records = new Map<string, StoredRecord>();
private readonly ttlMs: number;
private readonly now: () => number;
constructor(options: IdempotencyOptions = {}) {
const ttlMs = options.ttlMs ?? 24 * 60 * 60 * 1000;
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new RangeError("ttlMs must be a positive finite number");
}
this.ttlMs = ttlMs;
this.now = options.now ?? Date.now;
}
get size(): number {
this.prune();
return this.records.size;
}
async execute<T>(
key: string,
fingerprint: string,
handler: () => Promise<T>,
): Promise<T> {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("key must be a non-empty string");
}
if (typeof fingerprint !== "string" || fingerprint.length === 0) {
throw new TypeError("fingerprint must be a non-empty string");
}
if (typeof handler !== "function") throw new TypeError("handler must be a function");
const now = this.now();
if (!Number.isFinite(now)) throw new RangeError("now must return a finite timestamp");
const existing = this.records.get(key);
if (existing) {
if (now < existing.expiresAt) {
if (existing.fingerprint !== fingerprint) {
throw new IdempotencyConflictError(key);
}
return (await existing.promise) as T;
}
this.records.delete(key);
}
const record: StoredRecord = {
fingerprint,
expiresAt: now + this.ttlMs,
promise: Promise.resolve().then(handler),
};
this.records.set(key, record);
try {
return (await record.promise) as T;
} catch (error) {
if (this.records.get(key) === record) this.records.delete(key);
throw error;
}
}
prune(): number {
const now = this.now();
let removed = 0;
for (const [key, record] of this.records) {
if (now >= record.expiresAt) {
this.records.delete(key);
removed += 1;
}
}
return removed;
}
}Report a problem with this exampleOpens a pre-filled issue on GitHub.