Back to Delivery 4 — Collaborative and Tested Quality Utilities

delivery-04-collaborative-tested-utilities/src/retry.ts

import { QualityUtilityError } from "./errors.js";
import type { Clock, FailureKind, RetryResult } from "./models.js";

export interface RetryOptions<T> {
  readonly operationName: string;
  operation(): Promise<T>;
  shouldRetry(error: unknown): boolean;
  readonly maxAttempts: number;
  readonly delayMs: number;
  readonly clock: Clock;
}

function validateRetryOptions(maxAttempts: number, delayMs: number): void {
  if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
    throw new QualityUtilityError("invalid-input", "maxAttempts must be an integer of at least 1.", {
      operation: "retry",
    });
  }

  if (!Number.isFinite(delayMs) || delayMs < 0) {
    throw new QualityUtilityError("invalid-input", "delayMs must be a non-negative finite number.", {
      operation: "retry",
    });
  }
}

function terminalFailureKind(error: unknown): FailureKind {
  return error instanceof QualityUtilityError ? error.kind : "unexpected-result";
}

function terminalRetryFailure(
  operationName: string,
  attempt: number,
  retryExhausted: boolean,
  error: unknown,
): QualityUtilityError {
  const outcome = retryExhausted
    ? "exhausted its bounded attempts"
    : "stopped because the failure is not retryable";

  return new QualityUtilityError(
    terminalFailureKind(error),
    `${operationName} ${outcome} at attempt ${attempt}.`,
    { operation: operationName, attempt, retryExhausted },
    error,
  );
}

/**
 * Retries only a caller-classified failure. `maxAttempts` includes the first
 * attempt; the terminal message deliberately excludes raw dependency details.
 */
export async function retryBounded<T>(options: RetryOptions<T>): Promise<RetryResult<T>> {
  validateRetryOptions(options.maxAttempts, options.delayMs);

  for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
    try {
      return { value: await options.operation(), attempts: attempt };
    } catch (error: unknown) {
      const retryable = options.shouldRetry(error);
      if (!retryable || attempt === options.maxAttempts) {
        throw terminalRetryFailure(
          options.operationName,
          attempt,
          retryable && attempt === options.maxAttempts,
          error,
        );
      }

      await options.clock.sleep(options.delayMs);
    }
  }

  throw new Error("Unreachable retry state.");
}