All files / src/core Retry.ts

100% Statements 28/28
100% Branches 28/28
100% Functions 4/4
100% Lines 25/25

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 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 15313x                         13x               13x                                                                                         13x                                     11x       11x 11x 11x 11x       11x 17x 17x   11x   11x 5x     6x   6x           6x       5x                                         22x 2x     20x   20x 17x     3x      
import { resolve } from '@/core/resolve'
 
import type { DeliverySettings } from '@/types/DeliverySettings'
import type { RetryOptions } from '@/core/RetryOptions'
 
/**
 * HTTP statuses worth trying again.
 *
 * @remarks
 * Rate limiting and the gateway family. Any other 4xx is the far end saying the
 * request itself was wrong, and repeating it unchanged asks the same question
 * again — so `400` and `404` are absent on purpose.
 */
const TRANSIENT_STATUS: ReadonlySet<number> = new Set([429, 500, 502, 503, 504])
 
/**
 * Waits for a number of milliseconds.
 *
 * @param ms How long to wait.
 * @returns A promise that settles after the delay.
 */
const sleep = (ms: number): Promise<void> => new Promise(done => setTimeout(done, ms))
 
/**
 * @class
 *
 * A bounded retry with exponential backoff.
 *
 * @remarks
 * Bounded is the load-bearing word. A scheduled job runs against a wall-clock
 * budget, and an unbounded retry turns one unreachable dependency into an
 * invocation that never ends.
 *
 * ## Why it is in the kernel
 *
 * The kernel has declared the retry *policy* since 1.1.0: `delivery.retries` and
 * `delivery.retryBackoffMs` are in {@link systemDefaults}. Until this class,
 * nothing in the package read them, so every consumer that wanted to honour them
 * had to write the loop that does. This is that loop, beside the settings it
 * reads.
 *
 * It reads them **inside** each call rather than at module scope. Resolving at
 * module scope runs before the application's {@link configure} and throws
 * {@link ConfigurationError} on startup.
 *
 * ## What it does not know
 *
 * Nothing here names a transport. The default judgement recognises an HTTP
 * failure by shape — an `Error` carrying a numeric `status` — rather than by
 * class, so this package takes no dependency on the client that threw it. A
 * caller with its own non-retryable failures, such as a validation error,
 * supplies `isRetryable` and defers to {@link Retry.isRetryable} for the rest.
 *
 * Retry only what is safe to repeat. Replaying an operation that writes needs
 * that write to be idempotent, which is a promise this class cannot make for
 * the caller.
 *
 * @example
 * ```ts
 * const body = await Retry.run(() => client.get(url), { logger: log, event: 'rates.retry' })
 * ```
 *
 * @author Bayu Dwiyan Satria
 * @version 1.3.1
 * @since 1.3.1
 */
export class Retry {
  /**
   * Runs an operation, retrying failures judged transient.
   *
   * @remarks
   * Backoff doubles: with the shipped defaults that is one attempt, 250 ms, a
   * second, 500 ms, a third. The last failure is rethrown unchanged, so the
   * caller sees the real cause rather than a wrapper.
   *
   * @typeParam T The operation's result.
   * @param operation The work to attempt. Must be safe to run more than once.
   * @param options Overrides for the resolved `delivery` defaults.
   * @returns Whatever the operation returned.
   * @throws The final failure, once the budget is spent or the failure is not retryable.
   * @throws {@link ConfigurationError} When `delivery` is not registered and
   *   either `attempts` or `backoffMs` was left to default.
   */
  public static async run<T>(operation: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
    const delivery =
      options.attempts === undefined || options.backoffMs === undefined
        ? resolve<DeliverySettings>('delivery')
        : undefined
 
    const attempts = Math.max(Math.trunc(options.attempts ?? delivery.retries + 1) || 1, 1)
    const backoffMs = options.backoffMs ?? delivery.retryBackoffMs
    const retryable = options.isRetryable ?? Retry.isRetryable
    const event = options.event ?? 'retry'
 
    let failure: unknown
 
    for (let attempt = 1; attempt <= attempts; attempt++) {
      try {
        return await operation()
      } catch (error) {
        failure = error
 
        if (attempt === attempts || !retryable(error)) {
          break
        }
 
        const delayMs = backoffMs * 2 ** (attempt - 1)
 
        options.logger?.warn(
          event,
          { attempt, of: attempts, delayMs, error },
          `Attempt ${attempt} of ${attempts} failed, retrying in ${delayMs}ms`
        )
 
        await sleep(delayMs)
      }
    }
 
    throw failure
  }
 
  /**
   * The default judgement on whether a failure is transient.
   *
   * @remarks
   * An abort or a timeout is not retryable: the caller's own timeout already
   * spent the budget it allowed.
   *
   * An `Error` with a numeric `status` is read as an HTTP failure, and retried
   * only for `429` and the `5xx` gateway family.
   *
   * Anything else is treated as a network failure and retried, which is the safe
   * reading — `fetch` rejects for DNS, TLS and connection resets, and all three
   * are worth a second look.
   *
   * @param error The thrown value.
   * @returns `true` when another attempt is reasonable.
   */
  public static isRetryable(error: unknown): boolean {
    if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
      return false
    }
 
    const status = error instanceof Error ? (error as Error & { status?: unknown }).status : undefined
 
    if (typeof status === 'number') {
      return TRANSIENT_STATUS.has(status)
    }
 
    return true
  }
}