NaturalCycles/js-lib

View on GitHub
src/promise/pRetry.ts

Summary

Maintainability
A
3 hrs
Test Coverage
A
96%
import type { AnyFunction, CommonLogger, ErrorData } from '..'
import { _errorDataAppend, _since, pDelay, pTimeout } from '..'

export interface PRetryOptions {
  /**
   * If set - will be included in the error message.
   * Can be used to identify the place in the code that failed.
   */
  name?: string

  /**
   * Timeout for each Try, in milliseconds.
   *
   * Defaults to no timeout.
   */
  timeout?: number

  /**
   * How many attempts to try.
   * First attempt is not a retry, but "initial try". It still counts.
   * maxAttempts of 4 will be 1 try and 3 retries.
   *
   * @default 4
   */
  maxAttempts?: number

  /**
   * @default 1000 ms
   */
  delay?: number

  /**
   * @default 2
   */
  delayMultiplier?: number

  /**
   * Called on every retry (since 2nd attempt, cause 1st attempt is not a retry).
   *
   * True - keep retrying.
   * False - stop retrying and return immediately.
   *
   * @default () => true
   */
  predicate?: (err: Error, attempt: number, maxAttempts: number) => boolean

  /**
   * Log the first attempt (which is not a "retry" yet).
   *
   * @default false
   */
  logFirstAttempt?: boolean

  /**
   * Log retries - attempts that go after the first one.
   *
   * @default true
   */
  logRetries?: boolean

  /**
   * @default false
   */
  logSuccess?: boolean

  /**
   * @default true
   */
  logFailures?: boolean

  /**
   * @default false
   */
  logAll?: boolean

  /**
   * @default false
   */
  logNone?: boolean

  /**
   * Default to `console`
   */
  logger?: CommonLogger

  /**
   * Will be merged with `err.data` object.
   */
  errorData?: ErrorData
}

/**
 * Returns a Function (!), enhanced with retry capabilities.
 * Implements "Exponential back-off strategy" by multiplying the delay by `delayMultiplier` with each try.
 */
export function pRetryFn<T extends AnyFunction>(fn: T, opt: PRetryOptions = {}): T {
  return async function pRetryFunction(this: any, ...args: any[]) {
    return await pRetry(() => fn.call(this, ...args), opt)
  } as any
}

export async function pRetry<T>(
  fn: (attempt: number) => Promise<T>,
  opt: PRetryOptions = {},
): Promise<T> {
  const {
    maxAttempts = 4,
    delay: initialDelay = 1000,
    delayMultiplier = 2,
    predicate,
    logger = console,
    name,
    timeout,
  } = opt

  const fakeError = timeout ? new Error('TimeoutError') : undefined
  let { logFirstAttempt = false, logRetries = true, logFailures = false, logSuccess = false } = opt

  if (opt.logAll) {
    logSuccess = logFirstAttempt = logRetries = logFailures = true
  }
  if (opt.logNone) {
    logSuccess = logFirstAttempt = logRetries = logFailures = false
  }

  const fname = name || fn.name || 'pRetry function'

  let delay = initialDelay
  let attempt = 0

  while (true) {
    const started = Date.now()

    try {
      attempt++
      if ((attempt === 1 && logFirstAttempt) || (attempt > 1 && logRetries)) {
        logger.log(`${fname} attempt #${attempt}...`)
      }

      let result: any

      if (timeout) {
        result = await pTimeout(async () => await fn(attempt), {
          timeout,
          name: fname,
          errorData: opt.errorData,
          fakeError,
        })
      } else {
        result = await fn(attempt)
      }

      if (logSuccess) {
        logger.log(`${fname} attempt #${attempt} succeeded in ${_since(started)}`)
      }

      return result
    } catch (err) {
      if (logFailures) {
        logger.warn(`${fname} attempt #${attempt} error in ${_since(started)}:`, err)
      }

      if (attempt >= maxAttempts || (predicate && !predicate(err as Error, attempt, maxAttempts))) {
        // Give up
        throw _errorDataAppend(err, opt.errorData)
      }

      // Retry after delay
      delay *= delayMultiplier
      await pDelay(delay)
      // back to while(true) loop
    }
  }
}