UNPKG

axios-retryer

Version:

TypeScript-first Axios retry library with concurrency limits, request priority, token refresh, response caching, and circuit breaker plugins.

562 lines (547 loc) 23 kB
import { AxiosRequestConfig, AxiosError, AxiosInstance } from 'axios'; declare const AXIOS_RETRYER_REQUEST_PRIORITIES: { readonly CRITICAL: 4; readonly HIGHEST: 3; readonly HIGH: 2; readonly MEDIUM: 1; readonly LOW: 0; }; type AxiosRetryerRequestPriority = (typeof AXIOS_RETRYER_REQUEST_PRIORITIES)[keyof typeof AXIOS_RETRYER_REQUEST_PRIORITIES]; type RetryEventArgs<TEvents extends object, K extends keyof TEvents> = NonNullable<TEvents[K]> extends (...args: infer TArgs) => unknown ? TArgs : never; type RetryEventListener<TEvents extends object, K extends keyof TEvents> = (...args: RetryEventArgs<TEvents, K>) => void; /** * Terminal request error payload emitted by `onRequestError`. */ interface AxiosRetryerRequestErrorEvent { /** Final Axios error object that caused request failure. */ error: AxiosError; /** Final Axios request config that failed. */ config: AxiosRequestConfig; /** HTTP status if available, otherwise `null` for network-level failures. */ status: number | null; /** Request identifier if available. */ requestId?: string; /** Total attempts performed including the initial attempt. */ attempts: number; /** Whether the final error shape is considered retryable by the active strategy. */ retryable: boolean; } /** * Queue-entry payload emitted by `onRequestQueued`. */ interface AxiosRetryerRequestQueuedEvent { /** Request identifier generated or assigned by RetryManager. */ requestId: string; /** Request config entering the queue. */ config: AxiosRequestConfig; /** Resolved priority used for queue ordering. */ priority: AxiosRetryerRequestPriority; /** Queue size immediately after this request was enqueued. */ queueSize: number; } /** * Queue-dispatch payload emitted by `onRequestDispatched`. */ interface AxiosRetryerRequestDispatchedEvent { /** Request identifier generated or assigned by RetryManager. */ requestId: string; /** Request config dispatched from the queue. */ config: AxiosRequestConfig; /** Resolved priority used for queue ordering. */ priority: AxiosRetryerRequestPriority; /** Time spent waiting in the queue before dispatch (milliseconds). */ queuedForMs: number; } /** * Success payload emitted by `onRequestSucceeded`. */ interface AxiosRetryerRequestSucceededEvent { /** Request identifier generated or assigned by RetryManager. */ requestId?: string; /** Final request config that succeeded. */ config: AxiosRequestConfig; /** Final HTTP status code. */ status: number; /** Total attempts performed including the initial attempt. */ attempts: number; } /** * Core events exposed by RetryManager without any plugins attached. */ interface CoreRetryEvents { /** * Triggered when the retry process begins. */ onRetryProcessStarted?: () => void; /** * Triggered before each retry attempt. * @param config The Axios request configuration being retried. */ beforeRetry?: (config: AxiosRequestConfig) => void; /** * Triggered after a retry attempt. * @param config The Axios request configuration being retried. * @param success Whether the retry was successful. * @param error If the retry failed, the error that caused the failure. */ afterRetry?: (config: AxiosRequestConfig, success: boolean, error?: AxiosError) => void; /** * Triggered when a retry is scheduled and waiting for the specified delay. * @param delayMs The delay in milliseconds. * @param config The Axios request configuration. */ onRetryScheduled?: (delayMs: number, config: AxiosRequestConfig) => void; /** * Triggered for each failed retry attempt. * @param config The failed Axios request configuration. */ onFailure?: (config: AxiosRequestConfig) => void; /** * Triggered when a request enters the queue. * * @param payload Queue entry metadata for this request. */ onRequestQueued?: (payload: AxiosRetryerRequestQueuedEvent) => void; /** * Triggered when a queued request is dispatched from the queue to the network layer. * * @param payload Dispatch metadata including queue wait duration. */ onRequestDispatched?: (payload: AxiosRetryerRequestDispatchedEvent) => void; /** * Triggered when a request succeeds (initial attempt or after retries). * * @param payload Success metadata for this request. */ onRequestSucceeded?: (payload: AxiosRetryerRequestSucceededEvent) => void; /** * Triggered once when a request fails terminally (all retries exhausted or no-retry terminal path). * Unlike `onFailure`, this event is emitted only for the final failure. * * @param payload Terminal error context for application-level handling. */ onRequestError?: (payload: AxiosRetryerRequestErrorEvent) => void; /** * Triggered when all retries are completed. */ onRetryProcessFinished?: () => void; /** * Triggered when an in-flight retry delay timer is cancelled — either because * the user aborted the request (`source: 'user'`) or because the system shut * the request down (`source: 'system'`, e.g. plugin destroy, queue clear). */ onRetryTimerCancelled?: (payload: { requestId: string; source: 'user' | 'system'; }) => void; /** * Triggered when a request cancelled. * @param requestId Id of the cancelled request. */ onRequestCancelled?: (requestId: string) => void; /** * Called when a request fails due to network or connection issues, meaning * no valid server response was received (e.g., user is offline). * * @param request - The Axios request config that encountered a connection error. */ onInternetConnectionError?: (request: AxiosRequestConfig) => void; /** * Triggered when a blocking request (at or above `blockingPriorityThreshold`) fails terminally. * Only fires when `blockingPriorityThreshold` is configured. * * @param config The Axios request config of the failed blocking request. */ onBlockingRequestFailed?: (config: AxiosRequestConfig) => void; /** * Triggered when every in-flight blocking request (at or above `blockingPriorityThreshold`) * has **succeeded** (terminal success) and none remain in the internal blocker set. * Not emitted when a blocker fails (`onBlockingRequestFailed`) or is cancelled. * Only fires when `blockingPriorityThreshold` is configured. */ onAllBlockingRequestsResolved?: () => void; } type RetryManagerEvents<TPluginEvents extends object = Record<never, never>> = { [K in keyof CoreRetryEvents | keyof TPluginEvents]: K extends keyof TPluginEvents ? K extends keyof CoreRetryEvents ? CoreRetryEvents[K] & TPluginEvents[K] : TPluginEvents[K] : K extends keyof CoreRetryEvents ? CoreRetryEvents[K] : never; }; /** * Represents the distribution of different error types encountered */ interface ErrorTypesDistribution { /** Number of network-related errors (e.g., connection failures) */ network: number; /** Number of 5xx server errors */ server5xx: number; /** Number of 4xx client errors */ client4xx: number; /** Number of canceled requests */ cancelled: number; } /** * Represents metrics for a specific request priority level */ interface PriorityMetrics { /** The priority level (higher numbers indicate higher priority) */ priority: number; /** Total number of retry attempts for this priority */ total: number; /** Number of successful retries for this priority */ successes: number; /** Number of failed retries for this priority */ failures: number; /** Success rate percentage for this priority (0-100) */ successRate: number; /** Failure rate percentage for this priority (0-100) */ failureRate: number; } /** * AxiosRetryer detailed metrics * */ interface AxiosRetryerDetailedMetrics { /** Total number of requests made through the retryer */ totalRequests: number; /** Number of successfully completed retries */ successfulRetries: number; /** Number of failed retry attempts */ failedRetries: number; /** Requests that failed all retry attempts */ completelyFailedRequests: number; /** Requests canceled before completion */ canceledRequests: number; /** Critical priority requests that failed all retries */ completelyFailedCriticalRequests: number; /** Distribution of error types encountered */ errorTypesDistribution: ErrorTypesDistribution; /** Distribution of retry attempts across all requests */ retryAttemptsDistribution: Record<number, number>; /** Count of requests by priority level */ requestCountsByPriority: Record<number, number>; /** Average time spent in queue (seconds) */ avgQueueWait: number; /** Average delay between retry attempts (seconds) */ avgRetryDelay: number; /** Detailed metrics grouped by request priority */ priorityMetrics: PriorityMetrics[]; /** Timer health and accumulation metrics */ timerHealth: { /** Number of active internal timers */ activeTimers: number; /** Number of active retry timers */ activeRetryTimers: number; /** Health score (0 = excellent, 100+ = potential issues) */ healthScore: number; }; } /** * Interface for pluggable metrics recording. * The core library ships with no-op metrics by default. * Use MetricsPlugin for full metrics collection. */ interface MetricsRecorder { reset(): void; buildDetailedMetrics(timerStats: { activeTimers: number; activeRetryTimers: number; }): AxiosRetryerDetailedMetrics; emitMetricsUpdated?(): void; } /** * Logger interface used by RetryManager and its collaborators. * Supply a custom implementation via {@link RetryManagerOptions.logger} * to redirect or suppress log output. */ interface Logger { log(message: string, data?: unknown): void; error(message: string, error?: unknown): void; warn(message: string, data?: unknown): void; debug(message: string, meta?: unknown): void; } /** * Context object passed to plugins during initialization and teardown. * Provides the plugin-facing view of RetryManager capabilities including * plugin-only wiring hooks that are not part of the public manager API. */ interface PluginContext<TPluginEvents extends object = Record<never, never>> { /** The Axios instance managed by RetryManager. */ readonly axiosInstance: AxiosInstance; /** Returns the configured logger. */ getLogger(): Logger; /** Subscribe to a manager or plugin event. */ on<K extends keyof RetryManagerEvents<TPluginEvents>>(event: K, listener: RetryEventListener<RetryManagerEvents<TPluginEvents>, K>): void; /** Unsubscribe from a manager or plugin event. */ off<K extends keyof RetryManagerEvents<TPluginEvents>>(event: K, listener: RetryEventListener<RetryManagerEvents<TPluginEvents>, K>): boolean; /** Fire all listeners registered for this event. */ emit<K extends keyof RetryManagerEvents<TPluginEvents>>(event: K, ...args: RetryEventArgs<RetryManagerEvents<TPluginEvents>, K>): void; /** * Identical to `emit` — fires all listeners registered for this event. * * Kept for backward compatibility with existing plugins. There is no semantic * distinction from `emit`: prefer `emit` in new code. */ triggerAndEmit<K extends keyof RetryManagerEvents<TPluginEvents>>(event: K, ...args: RetryEventArgs<RetryManagerEvents<TPluginEvents>, K>): void; /** Cancel a specific in-flight or queued request by its ID. */ cancelRequest(requestId: string): void; /** Cancel all active and queued requests. */ cancelAllRequests(): void; /** Cancel only requests currently waiting in the queue. */ cancelQueuedRequests(): void; /** * Register a queue gate that must approve each request before it is dispatched. * Used by plugins that need to block request processing under certain conditions. */ registerQueueGate(name: string, canProcess: (request: AxiosRequestConfig) => boolean): void; /** Remove a previously registered queue gate. */ unregisterQueueGate(name: string): boolean; /** Trigger a queue drain pass. Useful after a gate condition changes. */ refreshQueue(): void; /** * Register or unregister a metrics recorder. * Pass `null` to detach. Used by MetricsPlugin to expose metric data to the RetryManager's getMetrics() method. */ registerMetricsRecorder(recorder: MetricsRecorder | null): void; /** * Return active timer counts. * Used by MetricsPlugin to populate the timerHealth section of detailed metrics. */ getTimerStats(): { activeTimers: number; activeRetryTimers: number; }; /** * Release lifecycle tracking for a request config and mark its queue slot complete. * Used by TokenRefreshPlugin when a tracked request is intercepted for token refresh. */ releaseRequestTracking(config: AxiosRequestConfig): void; } /** * AxiosRetryer plugin interface that can be attached with {@link RetryManager.use} and removed with {@link RetryManager.unuse} * */ interface RetryPlugin<TPluginEvents extends object = Record<never, never>> { /** * Plugin name. Should be unique * */ name: string; /** * Plugin version (e.g. 1.0.0) * */ version: string; /** * Phantom covariant marker for TypeScript to infer `TPluginEvents` at call sites * such as `manager.use(plugin)`. Never set this at runtime; implementations may * simply omit it (it is always `undefined`). * */ readonly _events?: Readonly<TPluginEvents>; /** * Called when the plugin is attached and initialized. * @param context Plugin context providing manager capabilities and plugin-only wiring hooks. * */ initialize: (context: PluginContext<TPluginEvents>) => void; /** * Called before the plugin is removed. * @param context Plugin context providing manager capabilities and plugin-only wiring hooks. * */ onBeforeDestroyed?: (context: PluginContext<TPluginEvents>) => void; } interface TokenRefreshResult { /** * New access token. When `null` or `undefined` (or omitted), the plugin treats the refresh as a no-op: * no header update, no `onTokenRefreshed` / `onTokenRefreshFailed`, and no “failed refresh” short-circuit. * Concurrent waiters are released without forcing `TokenRefreshFailedError`. */ token?: string | null; } type TokenRefreshHandler = (axiosInst: AxiosInstance) => Promise<TokenRefreshResult>; interface TokenRefreshPluginEvents { onTokenRefreshed?: (newToken: string) => void; onTokenRefreshFailed?: () => void; onBeforeTokenRefresh?: () => void; } interface TokenRefreshPluginOptions { /** If true, allow multiple refresh attempts up to maxRefreshAttempts on failure. */ retryOnRefreshFail?: boolean; /** Maximum number of refresh attempts (1 => 1 total attempt, 2 => 2 attempts, etc.). */ maxRefreshAttempts?: number; /** Timeout in ms for each refresh call. */ refreshTimeout?: number; /** The HTTP header name to set with the new token (e.g. "Authorization"). */ authHeaderName?: string; /** A prefix for your token (commonly "Bearer "). */ tokenPrefix?: string; /** HTTP status codes that trigger a token refresh (e.g., [401, 419]). */ refreshStatusCodes?: readonly number[]; /** * Maximum backoff delay in ms between refresh retry attempts. * Caps the exponential backoff to prevent multi-minute stalls with high maxRefreshAttempts. * Default: 30_000 (30 seconds). */ maxRefreshBackoffMs?: number; /** * Maximum number of requests that may queue while a token refresh is in flight. * Excess requests reject with TokenRefreshQueueOverflowError. Default: 500. * Set to 0 or a negative number to disable the cap (not recommended). */ maxQueuedRequests?: number; /** * Optional function to detect auth errors in response bodies (for APIs that return 200 with error in body) * Return true if response contains an auth error that should trigger token refresh */ customErrorDetector?: (response: unknown) => boolean; } /** * A RetryPlugin that manages token refresh on certain status codes (e.g., 401). * It intercepts failed requests, attempts to refresh the token, * and re-dispatches any queued requests if refresh succeeds. * * Can also detect custom auth errors in response bodies for APIs that return 200 OK * with error messages in the body (like GraphQL). */ declare class TokenRefreshPlugin implements RetryPlugin<TokenRefreshPluginEvents> { name: string; version: string; readonly _events?: Readonly<TokenRefreshPluginEvents>; private context; private requestInterceptorId; private interceptorId; private responseInterceptorId; private refreshAxios; private isRefreshing; private refreshQueue; private readonly teardown; private timerManager; private failedAuthHeaderValue; private readonly refreshToken; private readonly options; private logger; constructor(refreshToken: TokenRefreshHandler, options?: TokenRefreshPluginOptions); /** * Called by RetryManager when we register this plugin via manager.use(plugin). * Attaches a response interceptor to the manager's axios instance and * creates a dedicated axios instance for refresh calls. */ initialize(context: PluginContext<TokenRefreshPluginEvents>): void; /** * Called when the plugin is removed. */ onBeforeDestroyed(context: PluginContext<TokenRefreshPluginEvents>): void; private withTeardown; private ensureActive; private bindRefreshErrorToRequest; /** * Returns true when the queue is at or above `maxQueuedRequests`. * A value <= 0 disables the cap. */ private isQueueOverflowing; private buildQueueOverflowError; private rejectQueueEntryWithBoundError; private dispose; /** * Checks successful responses for custom auth errors in the response body */ private handleSuccessResponse; /** * Intercepts a failed response. If the error status is refreshable and the request * hasn't already been retried, then either queues the request (if refresh is in progress) * or starts a new refresh cycle. */ private handleResponseError; /** * Checks if the error status code is in the list of refreshable status codes. */ private isRefreshableError; /** * Main token refresh flow: * 1) Set isRefreshing = true. * 2) Attempt to refresh the token. * 3) On success, update the auth header and retry both queued and original requests. * 4) On failure, clear the queue and reject. */ private handleTokenRefresh; /** * Attempts token refresh up to maxRefreshAttempts times if retryOnRefreshFail is true. * Each attempt is subject to a timeout defined in refreshTimeout. Throw a non-Axios * error from the refresh handler to stop remaining refresh retries immediately, for * example when no refresh token is available in storage. */ private executeTokenRefresh; /** * Updates the manager's default auth header so subsequent requests automatically carry the new token. */ private updateAuthHeader; /** * Retries the given request through the retry manager pipeline, * marking it with __isRetryRefreshRequest to avoid loops. */ private retryRequest; /** * If a 401 is encountered while a refresh is already in progress, queue the request. */ private queueRefreshRequest; private flushQueuedWithToken; /** * When the refresh handler opts out (`token` null/undefined), release waiters without treating refresh as failed. */ private flushQueuedAfterSkippedRefresh; /** * If the token refresh fails completely, reject all queued requests and emit an event. * @returns The logical refresh failure error — callers must wrap with `bindRefreshErrorToRequest` * for the initiating request so RetryManager receives `config`. */ private handleRefreshFailure; } declare class AxiosRetryerError extends Error { readonly code: string; constructor(message: string, code: string); } declare class MissingTokenRefreshHandlerError extends AxiosRetryerError { constructor(message?: string); } declare class TokenRefreshTimeoutError extends AxiosRetryerError { readonly retryableRefreshFailure = true; constructor(message?: string); } declare class TokenRefreshAbortError extends AxiosRetryerError { readonly stopRefreshRetries = true; constructor(message?: string); } declare class TokenRefreshFailedError extends AxiosRetryerError { constructor(message?: string); } /** * Raised when the TokenRefreshPlugin queue exceeds `maxQueuedRequests`. * Prevents unbounded memory growth (and a DoS surface) under sustained 401 conditions. */ declare class TokenRefreshQueueOverflowError extends AxiosRetryerError { readonly queueSize: number; constructor(queueSize: number); } /** * Creates a TokenRefreshPlugin instance. * Functional alternative to using the `new TokenRefreshPlugin()` constructor. * * @param refreshToken Function that performs the token refresh operation. Return `{ token: string }` to apply a new token, or resolve with no usable `token` (`null`/`undefined`/omitted) to skip that cycle without failure events (see plugin docs). * @param options Configuration options for the TokenRefreshPlugin * @returns A configured TokenRefreshPlugin instance * * @example * ```typescript * const tokenRefresher = createTokenRefreshPlugin( * async (axiosInstance) => { * const refreshToken = localStorage.getItem('refreshToken'); * if (!refreshToken) { * throw new TokenRefreshAbortError('Refresh token not found'); * } * const { data } = await axiosInstance.post('/auth/refresh', { refreshToken }); * return { token: data.accessToken }; * }, * { * authHeaderName: 'Authorization', * tokenPrefix: 'Bearer ' * } * ); * * manager.use(tokenRefresher); * ``` */ declare function createTokenRefreshPlugin(refreshToken: TokenRefreshHandler, options?: TokenRefreshPluginOptions): TokenRefreshPlugin; export { MissingTokenRefreshHandlerError, TokenRefreshAbortError, TokenRefreshFailedError, TokenRefreshPlugin, TokenRefreshQueueOverflowError, TokenRefreshTimeoutError, createTokenRefreshPlugin }; export type { TokenRefreshHandler, TokenRefreshPluginEvents, TokenRefreshPluginOptions, TokenRefreshResult };