1
\$\begingroup\$

I am building a TS wrapper around the fetch that adds automatic retries for certain errors. The error handling using a custom ApiError class and maps various error conditions to user-friendly messages. ApiResponse<T> type for type safety. The function supports abort signal. The retry logic is handled through a separate utility that allows configurable attempts and a retry. My goals are to ensure strong type safety. make the codebase easy to read and maintain in the future. I would appreciate a review focused on the overall structure, clarity and maintainability of the code, also interested if integration of fetch with the retry logic could be improved, also, if possible, simplifying the code or identifying any mistakes would be appreciated.

index.ts

import type { ApiResponse, ServerApiError } from '../types'
import type { RetryError } from '~/shared/utils/retry'
import { ERROR_MESSAGES } from '~/shared/constants/errors'
import { retry } from '~/shared/utils/retry'
import { API_CONFIG } from '../config'

class ApiError extends Error implements RetryError {
  status?: number
  errorCode?: string
  response?: Response

  constructor(message: string, options?: { status?: number, response?: Response, errorCode?: string }) {
    super(message)
    this.name = 'ApiError'
    this.status = options?.status
    this.response = options?.response
    this.errorCode = options?.errorCode
  }
}

function isApiErrorWithResponse(error: unknown): error is ApiError & { response: Response } {
  return error instanceof ApiError && !!error.response
}

function isRetryError(error: unknown): error is RetryError {
  return (
    error != null
    && typeof error === 'object'
    && ('status' in error || 'errorCode' in error || 'message' in error)
  )
}

function resolveErrorDetails(
  error: RetryError,
): { errorCode: string, errorMessage: string, status: number } {
  const status = error.status ?? 0
  let errorCode = error.errorCode ?? ''
  let errorMessage = ERROR_MESSAGES.DEFAULT

  if (error.message?.includes('Failed to fetch') && error.errorCode === 'NETWORK_ERROR') {
    return { errorCode: 'NETWORK_ERROR', errorMessage: ERROR_MESSAGES.NETWORK_ERROR, status }
  }

  if (isApiErrorWithResponse(error)) {
    try {
      const apiError = error.response.json() as ServerApiError
      errorCode = apiError.errorCode ?? ''
      errorMessage
        = (errorCode && ERROR_MESSAGES[errorCode])
          ?? ERROR_MESSAGES[status.toString()]
          ?? apiError.message
          ?? ERROR_MESSAGES.DEFAULT
    }
    catch {
      errorMessage = ERROR_MESSAGES[status.toString()] ?? ERROR_MESSAGES.DEFAULT
    }
    return { errorCode, errorMessage, status }
  }

  if (error.errorCode) {
    const errorMessage = ERROR_MESSAGES[error.errorCode] ?? ERROR_MESSAGES.DEFAULT
    return { errorCode, errorMessage, status }
  }

  return { errorCode, errorMessage, status }
}

export async function handleResponse<T>(
  url: string,
  options: {
    signal?: AbortSignal
    maxRetries?: number
    method?: string
    mode?: RequestMode
    headers?: HeadersInit
    body?: BodyInit | object
  } = {
    method: 'GET',
    mode: 'cors',
    headers: API_CONFIG.HEADERS,
    maxRetries: 3,
  },
): Promise<ApiResponse<T>> {
  try {
    const response = await retry(
      async () => {
        const body
          = options.body && typeof options.body === 'object'
            ? JSON.stringify(options.body)
            : options.body

        const res = await fetch(`${API_CONFIG.BASE_URL}${url}`, {
          method: options.method,
          headers: options.headers,
          body,
          mode: options.mode,
          signal: options.signal,
        })

        if (!res.ok) {
          throw new ApiError(`HTTP ${res.status}`, { status: res.status, response: res })
        }
        return res
      },
      {
        signal: options.signal,
        maxRetries: options.maxRetries,
        shouldRetry: error =>
          (error.status && [429, 503].includes(error.status))
          || (error.message?.includes('Failed to fetch') && error.errorCode === 'NETWORK_ERROR')
          || false,
        onRetry: (attempt, delay, error) => {
          console.warn(`Retrying API call (attempt ${attempt}) after ${delay}ms due to:`, error)
        },
      },
    )
    const responseJson = await response.json()
    if (responseJson.error) {
      throw new ApiError(responseJson.error.message, {
        status: response.status,
        response,
        errorCode: responseJson.error.code,
      })
    }
    else {
      const data = responseJson.data as T
      return {
        isError: false,
        data,
        errorMessage: '',
        status: response.status,
      }
    }
  }
  catch (error: unknown) {
    const defaultResponse: ApiResponse<T> = {
      isError: true,
      data: null as T,
      errorMessage: ERROR_MESSAGES.DEFAULT,
      status: 0,
      errorCode: '',
    }

    if (!isRetryError(error)) {
      console.error(`API Error: ${defaultResponse.errorMessage}`, { error })
      return defaultResponse
    }

    const { errorCode, errorMessage, status } = resolveErrorDetails(error)
    console.error(`API Error: ${errorMessage}`, { status, errorCode, error })

    return {
      isError: true,
      data: null as T,
      errorMessage,
      status,
      errorCode,
    }
  }
}

types

export interface ApiResponse<T> {
  data: T
  isError: boolean
  errorMessage: string
  status?: number
  errorCode?: string
}

export interface ServerApiError {
  errorCode?: string
  message?: string
}

const/errors.ts

export const ERROR_MESSAGES = {
  400: 'Щось пішло не так. Перевірте введені дані та спробуйте ще раз.',
  401: 'Ви не авторизовані. Будь ласка, увійдіть у свій обліковий запис.',
  403: 'Доступ заборонено. У вас немає прав для цієї дії.',
  404: 'Не знайдено. Схоже, цей продукт або сторінка не існує.',
  429: 'Забагато запитів. Спробуйте ще раз через кілька секунд.',
  500: 'Проблема з сервером. Спробуйте пізніше або зверніться до підтримки.',
  503: 'Сервер тимчасово недоступний. Спробуйте ще раз.',
  // others
  USER_NOT_FOUND: 'Користувача не знайдено.',
  INVALID_CREDENTIALS: 'Неправильний email або пароль.',
  PRODUCT_NOT_FOUND: 'Цей продукт більше не доступний.',
  NETWORK_ERROR: 'Проблема з мережею. Перевірте підключення до інтернету.',
  DEFAULT: 'Щось пішло не так. Спробуйте ще раз або зверніться до підтримки.',
} as Record<string, string>

export const RETRYABLE_ERRORS = [429, 503, 'NETWORK_ERROR']
export const MAX_RETRIES = 3
export const BASE_RETRY_DELAY = 1000

retry.ts

import { BASE_RETRY_DELAY, MAX_RETRIES, RETRYABLE_ERRORS } from '@shared/constants/errors'

export interface RetryError {
  status?: number
  errorCode?: string
  message?: string
}

export interface ApiErrorResponse {
  isError: true
  errorCode?: string
  errorMessage?: string
}

function isApiErrorResponse(result: any): result is ApiErrorResponse {
  return (
    result
    && typeof result === 'object'
    && 'isError' in result
    && result.isError === true
  )
}

interface RetryOptions {
  maxRetries?: number
  baseDelayMs?: number
  jitterMs?: number
  shouldRetry?: (error: RetryError) => boolean
  onRetry?: (attempt: number, delay: number, error: RetryError) => void
  signal?: AbortSignal
  logger?: (message: string, meta: Record<string, any>) => void
}

function defaultShouldRetry(error: RetryError): boolean {
  if (error.message?.includes('Failed to fetch')) {
    return RETRYABLE_ERRORS.includes('NETWORK_ERROR')
  }
  return (
    (error.status && RETRYABLE_ERRORS.includes(error.status))
    || (error.errorCode && RETRYABLE_ERRORS.includes(error.errorCode))
    || false
  )
}

function defaultLogger(message: string, meta: Record<string, any>) {
  console.warn(`[Retry] ${message}`, meta)
}

export async function retry<T>(
  fn: () => Promise<T>,
  options: RetryOptions = {},
): Promise<T> {
  const {
    maxRetries = MAX_RETRIES,
    baseDelayMs = BASE_RETRY_DELAY,
    jitterMs = 100,
    shouldRetry = defaultShouldRetry,
    onRetry,
    signal,
    logger = defaultLogger,
  } = options

  let lastError: RetryError | null = null

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    if (signal?.aborted) {
      const abortError: RetryError = { message: 'Retry aborted' }
      logger('Retry aborted by signal', { attempt, reason: signal.reason })
      throw abortError
    }

    try {
      const result = await fn()
      if (isApiErrorResponse(result)) {
        const error: RetryError = {
          errorCode: result.errorCode,
          message: result.errorMessage || 'API error response',
        }
        throw error
      }
      return result
    }
    catch (error: any) {
      lastError = {
        status: error.status,
        errorCode: error.errorCode,
        message: error.message || String(error),
      }

      logger(`Attempt ${attempt} failed`, {
        error: lastError,
        function: fn.name || 'anonymous',
        attempt,
        maxRetries,
      })

      if (!shouldRetry(lastError) || attempt === maxRetries) {
        throw lastError
      }

      const backoff = baseDelayMs * 2 ** (attempt - 1)
      const jitter = Math.random() * jitterMs
      const delay = backoff + jitter

      onRetry?.(attempt, delay, lastError)

      await new Promise((resolve, reject) => {
        const timeout = setTimeout(resolve, delay)
        signal?.addEventListener('abort', () => {
          clearTimeout(timeout)
          reject(new Error('Retry aborted during delay'))
        }, { once: true })
      })
    }
  }

  throw lastError || new Error('Retry failed without error')
}
\$\endgroup\$

0

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.