All files / kernel-utils/src retry.ts

100% Statements 30/30
100% Branches 26/26
100% Functions 4/4
100% Lines 29/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137          111x   111x   111x                                                                                   38x 38x 38x 38x 38x 38x   6x   32x                               20x 33x   20x 20x     20x 51x 1x     50x 50x 50x   37x 37x 37x 5x     32x           37x             37x                                             1x    
import { AbortError } from '@metamask/kernel-errors';
 
import { abortableDelay } from './misc.ts';
 
/** The default maximum number of retry attempts. */
export const DEFAULT_MAX_RETRY_ATTEMPTS = 0; // 0 = infinite
/** The default base delay in milliseconds. */
export const DEFAULT_BASE_DELAY_MS = 500;
/** The default maximum delay in milliseconds. */
export const DEFAULT_MAX_DELAY_MS = 10_000;
 
export type RetryBackoffOptions = Readonly<{
  /** 0 means infinite attempts */
  maxAttempts?: number;
  /** The base delay in milliseconds. */
  baseDelayMs?: number;
  /** The maximum delay in milliseconds. */
  maxDelayMs?: number;
  /** Whether to use full jitter. */
  jitter?: boolean;
  /** A function to determine if an error is retryable. */
  shouldRetry?: (error: unknown) => boolean;
  /** A function to observe each retry schedule. */
  onRetry?: (info: Readonly<RetryOnRetryInfo>) => void;
  /** An abort signal to cancel the whole retry operation. */
  signal?: AbortSignal;
}>;
 
export type RetryOnRetryInfo = {
  /** The 1-based attempt that just failed. */
  attempt: number;
  /** The resolved numeric maximum number of attempts. */
  maxAttempts: number;
  /** The delay in milliseconds. */
  delayMs: number;
  /** The error that occurred. */
  error: unknown;
};
 
/**
 * Calculate exponential backoff with optional full jitter.
 * attempt is 1-based.
 *
 * @param attempt - The 1-based attempt that just failed.
 * @param opts - The options for the backoff.
 * @returns The delay in milliseconds.
 */
export function calculateReconnectionBackoff(
  attempt: number,
  opts?: Pick<RetryBackoffOptions, 'baseDelayMs' | 'maxDelayMs' | 'jitter'>,
): number {
  const base = Math.max(1, opts?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS);
  const cap = Math.max(base, opts?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS);
  const pow = Math.max(0, attempt - 1);
  const raw = Math.min(cap, base * Math.pow(2, pow));
  const useJitter = opts?.jitter !== false;
  if (useJitter) {
    // Full jitter in [0, raw)
    return Math.floor(Math.random() * raw);
  }
  return raw;
}
 
/**
 * Generic retry helper with backoff.
 * Throws the last error if attempts exhausted or shouldRetry returns false.
 *
 * @param operation - The operation to retry.
 * @param options - The options for the retry.
 * @returns The result of the operation.
 * @throws If the operation fails and shouldRetry returns false or if the maximum number of attempts is reached.
 */
export async function retry<Result>(
  operation: () => Promise<Result>,
  options?: RetryBackoffOptions,
): Promise<Result> {
  const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_RETRY_ATTEMPTS;
  const shouldRetry = options?.shouldRetry ?? (() => true);
 
  let attempt = 0;
  const isInfinite = maxAttempts === 0;
  // Loop until success or we hit a finite cap. 0 = infinite attempts.
  // eslint-disable-next-line no-unmodified-loop-condition
  while (isInfinite || attempt < maxAttempts) {
    if (options?.signal?.aborted) {
      throw new AbortError();
    }
 
    try {
      attempt += 1;
      return await operation();
    } catch (error) {
      const canRetry = shouldRetry(error);
      const finalAttempt = !isInfinite && attempt >= maxAttempts;
      if (!canRetry || finalAttempt) {
        throw error;
      }
 
      const delayMs = calculateReconnectionBackoff(attempt, {
        baseDelayMs: options?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS,
        maxDelayMs: options?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
        jitter: options?.jitter ?? true,
      });
 
      options?.onRetry?.({
        attempt,
        maxAttempts,
        delayMs,
        error,
      });
 
      await abortableDelay(delayMs, options?.signal);
      // Continue loop
    }
    /* v8 ignore start */
  }
 
  // Unreachable (loop returns or throws)
  throw new Error('Retry operation ended unexpectedly');
}
/* v8 ignore stop */
 
/**
 * Compatibility wrapper for existing call sites
 *
 * @param operation - The operation to retry.
 * @param options - The options for the retry.
 * @returns The result of the operation.
 * @throws If the operation fails and shouldRetry returns false or if the maximum number of attempts is reached.
 */
export async function retryWithBackoff<Result>(
  operation: () => Promise<Result>,
  options?: RetryBackoffOptions,
): Promise<Result> {
  return retry(operation, options);
}