Dead Letter Queue for Failed Jobs

Retry failed jobs up to a limit, then move them to an inspectable dead-letter queue with the error message and failure time.

TypescriptResilience

Code

snippet
export interface Job<T> {
  id: string;
  payload: T;
  attempts: number;
}

export interface DeadLetterEntry<T> {
  job: Job<T>;
  error: string;
  failedAt: number;
}

export class RetryQueue<T> {
  private readonly pending: Job<T>[] = [];
  private readonly deadLetters: DeadLetterEntry<T>[] = [];
  private readonly maxAttempts: number;
  private readonly now: () => number;

  constructor(maxAttempts = 3, now: () => number = () => Date.now()) {
    if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
      throw new RangeError("maxAttempts must be a positive integer");
    }
    if (typeof now !== "function") throw new TypeError("now must be a function");
    this.maxAttempts = maxAttempts;
    this.now = now;
  }

  enqueue(id: string, payload: T): void {
    if (typeof id !== "string" || id.length === 0) {
      throw new TypeError("id must be a non-empty string");
    }
    this.pending.push({ id, payload, attempts: 0 });
  }

  async drain(
    handler: (payload: T, job: Job<T>) => Promise<void>,
  ): Promise<{ processed: number; deadLettered: number }> {
    if (typeof handler !== "function") throw new TypeError("handler must be a function");
    let processed = 0;
    let deadLettered = 0;
    while (this.pending.length > 0) {
      const job = this.pending.shift();
      if (!job) break;
      job.attempts += 1;
      try {
        await handler(job.payload, job);
        processed += 1;
      } catch (error) {
        if (job.attempts < this.maxAttempts) {
          this.pending.push(job);
        } else {
          this.deadLetters.push({
            job,
            error: error instanceof Error ? error.message : String(error),
            failedAt: this.now(),
          });
          deadLettered += 1;
        }
      }
    }
    return { processed, deadLettered };
  }

  get deadLetterCount(): number {
    return this.deadLetters.length;
  }

  listDeadLetters(): readonly DeadLetterEntry<T>[] {
    return [...this.deadLetters];
  }
}