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.

TypescriptResilience

Code

snippet
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;
  }
}