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')
}