From eb894994878ca376d529172f74d7ec0ba0aaba19 Mon Sep 17 00:00:00 2001 From: eli Date: Wed, 18 Feb 2026 09:59:47 -0600 Subject: [PATCH 1/2] Add retry mechanism for HTTP requests - Add built-in retry support with exponential backoff for transient failures - Retry on network errors and status codes: 408, 429, 500, 502, 503, 504 - Respect Retry-After headers for rate limiting (429) - Default: 3 retries with exponential backoff (1s, 2s, 4s) - Add IRetryConfig interface for full configuration control - Support separate retry config for PDP calls via pdpRetry option - Enable POST retry for PDP calls (check operations are idempotent) - Add comprehensive unit tests (33 tests) - Update README with retry configuration documentation Co-Authored-By: Claude Opus 4.5 --- README.md | 41 ++++ src/config.ts | 19 ++ src/enforcement/enforcer.ts | 16 ++ src/index.ts | 19 ++ src/tests/unit/retry.spec.ts | 339 +++++++++++++++++++++++++++++++++ src/utils/retry-interceptor.ts | 91 +++++++++ src/utils/retry.ts | 200 +++++++++++++++++++ yarn.lock | 47 ++--- 8 files changed, 743 insertions(+), 29 deletions(-) create mode 100644 src/tests/unit/retry.spec.ts create mode 100644 src/utils/retry-interceptor.ts create mode 100644 src/utils/retry.ts diff --git a/README.md b/README.md index 94ba1b7..8f74f8c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,47 @@ npm install permitio 3. Execute `yarn docs ; git add docs/ ; git commit -m "update tsdoc"` to update the auto generated docs 4. Execute `yarn publish --access public` +## Retry Configuration + +The SDK includes built-in retry support for transient failures. By default, retries are **enabled** with the following settings: + +- **3 retry attempts** with exponential backoff +- Retries on network errors and status codes: `408`, `429`, `500`, `502`, `503`, `504` +- Respects `Retry-After` headers for rate limiting (429) + +### Customizing Retry Behavior + +```typescript +import { Permit } from 'permitio'; + +// Use defaults (retry enabled) +const permit = new Permit({ token: 'your-api-key' }); + +// Custom retry configuration +const permit = new Permit({ + token: 'your-api-key', + retry: { + maxRetries: 5, + retryDelay: 500, // Initial delay in ms + backoffMultiplier: 2, // Exponential backoff multiplier + maxDelay: 30000, // Maximum delay cap + }, +}); + +// Disable retry entirely +const permit = new Permit({ + token: 'your-api-key', + retry: false, +}); + +// Different config for PDP vs REST API +const permit = new Permit({ + token: 'your-api-key', + retry: { maxRetries: 3 }, + pdpRetry: { maxRetries: 5 }, +}); +``` + ## Documentation [Read the documentation at Permit.io website](https://docs.permit.io/sdk/nodejs/quickstart-nodejs#add-the-sdk-to-your-js-code) diff --git a/src/config.ts b/src/config.ts index a021814..476c5e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import globalAxios, { AxiosInstance } from 'axios'; import _ from 'lodash'; import { ApiContext } from './api/context'; +import { IRetryConfig } from './utils/retry'; import { RecursivePartial } from './utils/types'; export type FactsSyncTimeoutPolicy = 'ignore' | 'fail'; @@ -110,6 +111,24 @@ export interface IPermitConfig { * @see https://axios-http.com/docs/req_config */ opaAxiosInstance?: AxiosInstance; + + /** + * Configuration for automatic retry of failed requests. + * Set to false to disable retries entirely. + * Defaults to enabled with sensible defaults (3 retries, exponential backoff). + * + * @see {@link IRetryConfig} + */ + retry?: IRetryConfig | false; + + /** + * Optional separate retry configuration for PDP (enforcement) calls. + * If not provided, uses the main `retry` configuration. + * Set to false to disable retries for PDP calls only. + * + * @see {@link IRetryConfig} + */ + pdpRetry?: IRetryConfig | false; } /** diff --git a/src/enforcement/enforcer.ts b/src/enforcement/enforcer.ts index cf1ec9f..410cd8d 100644 --- a/src/enforcement/enforcer.ts +++ b/src/enforcement/enforcer.ts @@ -5,6 +5,8 @@ import URL from 'url-parse'; import { IPermitConfig } from '../config'; import { CheckConfig, Context, ContextStore } from '../utils/context'; import { AxiosLoggingInterceptor } from '../utils/http-logger'; +import { resolveRetryConfig } from '../utils/retry'; +import { AxiosRetryInterceptor } from '../utils/retry-interceptor'; import { AllTenantsResponse, @@ -163,6 +165,20 @@ export class Enforcer implements IEnforcer { } this.logger = logger; AxiosLoggingInterceptor.setupInterceptor(this.client, this.logger); + + // Setup retry interceptors for PDP clients + // Use pdpRetry config if provided, otherwise fall back to main retry config + const pdpRetryConfig = resolveRetryConfig(config.pdpRetry ?? config.retry); + if (pdpRetryConfig.enabled) { + // For PDP calls, enable POST retry since check operations are idempotent + const pdpRetryWithPost = { + ...pdpRetryConfig, + retryMethods: [...pdpRetryConfig.retryMethods, 'POST'], + }; + AxiosRetryInterceptor.setupInterceptor(this.client, pdpRetryWithPost, this.logger, 'PDP'); + AxiosRetryInterceptor.setupInterceptor(this.opaClient, pdpRetryWithPost, this.logger, 'OPA'); + } + this.contextStore = new ContextStore(); } diff --git a/src/index.ts b/src/index.ts index 082d455..00dd70a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,8 @@ import { import { LoggerFactory } from './logger'; import { CheckConfig, Context } from './utils/context'; import { AxiosLoggingInterceptor } from './utils/http-logger'; +import { resolveRetryConfig } from './utils/retry'; +import { AxiosRetryInterceptor } from './utils/retry-interceptor'; import { RecursivePartial } from './utils/types'; // exported interfaces @@ -25,6 +27,12 @@ export { PermitConnectionError, PermitError, PermitPDPStatusError } from './enfo export { Context, ContextTransform } from './utils/context'; export { ApiContext, PermitContextError, ApiKeyLevel } from './api/context'; export { PermitApiError } from './api/base'; +export { + IRetryConfig, + RetryConditionFn, + RETRYABLE_STATUS_CODES, + NON_RETRYABLE_STATUS_CODES, +} from './utils/retry'; export interface IPermitClient extends IEnforcer { /** @@ -140,6 +148,17 @@ export class Permit implements IPermitClient { this.logger = LoggerFactory.createLogger(this.config); AxiosLoggingInterceptor.setupInterceptor(this.config.axiosInstance, this.logger); + // Setup retry interceptor for REST API calls + const resolvedRetryConfig = resolveRetryConfig(this.config.retry); + if (resolvedRetryConfig.enabled) { + AxiosRetryInterceptor.setupInterceptor( + this.config.axiosInstance, + resolvedRetryConfig, + this.logger, + 'API', + ); + } + this.api = new ApiClient(this.config, this.logger); this.enforcer = new Enforcer(this.config, this.logger); diff --git a/src/tests/unit/retry.spec.ts b/src/tests/unit/retry.spec.ts new file mode 100644 index 0000000..b455924 --- /dev/null +++ b/src/tests/unit/retry.spec.ts @@ -0,0 +1,339 @@ +import test from 'ava'; +import { AxiosError, AxiosHeaders } from 'axios'; + +import { + DEFAULT_RETRY_CONFIG, + defaultRetryCondition, + calculateRetryDelay, + parseRetryAfter, + resolveRetryConfig, + RETRYABLE_STATUS_CODES, + NON_RETRYABLE_STATUS_CODES, + IRetryConfig, +} from '../../utils/retry'; + +// Helper to create mock AxiosError +function createAxiosError(status?: number): AxiosError { + const error = new Error('Request failed') as AxiosError; + error.isAxiosError = true; + error.config = { headers: new AxiosHeaders() }; + error.toJSON = () => ({}); + + if (status !== undefined) { + error.response = { + status, + statusText: 'Error', + headers: {}, + config: { headers: new AxiosHeaders() }, + data: {}, + }; + } + + return error; +} + +// ============================================ +// Tests for RETRYABLE_STATUS_CODES +// ============================================ + +test('RETRYABLE_STATUS_CODES contains expected status codes', (t) => { + t.deepEqual(RETRYABLE_STATUS_CODES, [408, 429, 500, 502, 503, 504]); +}); + +test('NON_RETRYABLE_STATUS_CODES contains expected status codes', (t) => { + t.deepEqual(NON_RETRYABLE_STATUS_CODES, [400, 401, 403, 404, 422]); +}); + +// ============================================ +// Tests for defaultRetryCondition +// ============================================ + +test('defaultRetryCondition returns true for network errors (no response)', (t) => { + const error = createAxiosError(); + t.true(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns true for 408 Request Timeout', (t) => { + const error = createAxiosError(408); + t.true(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns true for 429 Too Many Requests', (t) => { + const error = createAxiosError(429); + t.true(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns true for 500 Internal Server Error', (t) => { + const error = createAxiosError(500); + t.true(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns true for 502 Bad Gateway', (t) => { + const error = createAxiosError(502); + t.true(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns true for 503 Service Unavailable', (t) => { + const error = createAxiosError(503); + t.true(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns true for 504 Gateway Timeout', (t) => { + const error = createAxiosError(504); + t.true(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns false for 400 Bad Request', (t) => { + const error = createAxiosError(400); + t.false(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns false for 401 Unauthorized', (t) => { + const error = createAxiosError(401); + t.false(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns false for 403 Forbidden', (t) => { + const error = createAxiosError(403); + t.false(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns false for 404 Not Found', (t) => { + const error = createAxiosError(404); + t.false(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns false for 422 Unprocessable Entity', (t) => { + const error = createAxiosError(422); + t.false(defaultRetryCondition(error)); +}); + +test('defaultRetryCondition returns false for 200 OK', (t) => { + const error = createAxiosError(200); + t.false(defaultRetryCondition(error)); +}); + +// ============================================ +// Tests for parseRetryAfter +// ============================================ + +test('parseRetryAfter parses integer seconds correctly', (t) => { + t.is(parseRetryAfter('5'), 5000); + t.is(parseRetryAfter('60'), 60000); + t.is(parseRetryAfter('0'), 0); + t.is(parseRetryAfter('120'), 120000); +}); + +test('parseRetryAfter returns null for invalid values', (t) => { + t.is(parseRetryAfter('invalid'), null); + t.is(parseRetryAfter(''), null); + t.is(parseRetryAfter('abc123'), null); +}); + +test('parseRetryAfter parses HTTP-date format', (t) => { + // Use a future date + const futureDate = new Date(Date.now() + 10000); + const httpDate = futureDate.toUTCString(); + const result = parseRetryAfter(httpDate); + + t.truthy(result); + // Should be approximately 10 seconds (10000ms), with some tolerance + t.true(result! > 9000 && result! < 11000); +}); + +test('parseRetryAfter returns 0 for past HTTP-date', (t) => { + const pastDate = new Date(Date.now() - 10000); + const httpDate = pastDate.toUTCString(); + const result = parseRetryAfter(httpDate); + + t.is(result, 0); +}); + +// ============================================ +// Tests for calculateRetryDelay +// ============================================ + +test('calculateRetryDelay calculates exponential backoff correctly', (t) => { + const config = { ...DEFAULT_RETRY_CONFIG, retryDelay: 1000, backoffMultiplier: 2 }; + + // First retry (attempt 0): 1000 * 2^0 = 1000ms + jitter + const delay0 = calculateRetryDelay(0, config); + t.true(delay0 >= 1000 && delay0 <= 1100); // 10% jitter max + + // Second retry (attempt 1): 1000 * 2^1 = 2000ms + jitter + const delay1 = calculateRetryDelay(1, config); + t.true(delay1 >= 2000 && delay1 <= 2200); + + // Third retry (attempt 2): 1000 * 2^2 = 4000ms + jitter + const delay2 = calculateRetryDelay(2, config); + t.true(delay2 >= 4000 && delay2 <= 4400); +}); + +test('calculateRetryDelay respects maxDelay limit', (t) => { + const config = { + ...DEFAULT_RETRY_CONFIG, + retryDelay: 1000, + backoffMultiplier: 10, + maxDelay: 5000, + }; + + // Even with high multiplier, should cap at maxDelay + const delay = calculateRetryDelay(5, config); + t.true(delay <= 5000); +}); + +test('calculateRetryDelay respects Retry-After header', (t) => { + const config = { ...DEFAULT_RETRY_CONFIG, respectRetryAfter: true }; + + const delay = calculateRetryDelay(0, config, '3'); + t.is(delay, 3000); +}); + +test('calculateRetryDelay caps Retry-After at maxDelay', (t) => { + const config = { ...DEFAULT_RETRY_CONFIG, respectRetryAfter: true, maxDelay: 5000 }; + + const delay = calculateRetryDelay(0, config, '60'); + t.is(delay, 5000); +}); + +test('calculateRetryDelay ignores Retry-After when disabled', (t) => { + const config = { + ...DEFAULT_RETRY_CONFIG, + respectRetryAfter: false, + retryDelay: 1000, + backoffMultiplier: 2, + }; + + const delay = calculateRetryDelay(0, config, '60'); + // Should use exponential backoff, not Retry-After + t.true(delay >= 1000 && delay <= 1100); +}); + +// ============================================ +// Tests for resolveRetryConfig +// ============================================ + +test('resolveRetryConfig returns defaults when config is undefined', (t) => { + const resolved = resolveRetryConfig(undefined); + + t.true(resolved.enabled); + t.is(resolved.maxRetries, 3); + t.is(resolved.retryDelay, 1000); + t.is(resolved.backoffMultiplier, 2); + t.is(resolved.maxDelay, 30000); + t.true(resolved.respectRetryAfter); + t.deepEqual(resolved.retryMethods, ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); +}); + +test('resolveRetryConfig returns disabled config when false', (t) => { + const resolved = resolveRetryConfig(false); + + t.false(resolved.enabled); + // Other defaults should still be present + t.is(resolved.maxRetries, 3); +}); + +test('resolveRetryConfig merges user config with defaults', (t) => { + const userConfig: IRetryConfig = { + maxRetries: 5, + retryDelay: 500, + }; + + const resolved = resolveRetryConfig(userConfig); + + t.true(resolved.enabled); + t.is(resolved.maxRetries, 5); // User value + t.is(resolved.retryDelay, 500); // User value + t.is(resolved.backoffMultiplier, 2); // Default + t.is(resolved.maxDelay, 30000); // Default +}); + +test('resolveRetryConfig allows custom retry condition', (t) => { + const customCondition = () => false; + const userConfig: IRetryConfig = { + retryCondition: customCondition, + }; + + const resolved = resolveRetryConfig(userConfig); + + t.is(resolved.retryCondition, customCondition); +}); + +test('resolveRetryConfig allows custom retry methods', (t) => { + const userConfig: IRetryConfig = { + retryMethods: ['GET', 'POST'], + }; + + const resolved = resolveRetryConfig(userConfig); + + t.deepEqual(resolved.retryMethods, ['GET', 'POST']); +}); + +test('resolveRetryConfig allows disabling via enabled: false', (t) => { + const userConfig: IRetryConfig = { + enabled: false, + maxRetries: 10, + }; + + const resolved = resolveRetryConfig(userConfig); + + t.false(resolved.enabled); + t.is(resolved.maxRetries, 10); +}); + +// ============================================ +// Tests for DEFAULT_RETRY_CONFIG +// ============================================ + +test('DEFAULT_RETRY_CONFIG has expected default values', (t) => { + t.true(DEFAULT_RETRY_CONFIG.enabled); + t.is(DEFAULT_RETRY_CONFIG.maxRetries, 3); + t.is(DEFAULT_RETRY_CONFIG.retryDelay, 1000); + t.is(DEFAULT_RETRY_CONFIG.backoffMultiplier, 2); + t.is(DEFAULT_RETRY_CONFIG.maxDelay, 30000); + t.true(DEFAULT_RETRY_CONFIG.respectRetryAfter); + t.deepEqual(DEFAULT_RETRY_CONFIG.retryMethods, ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); + t.is(typeof DEFAULT_RETRY_CONFIG.retryCondition, 'function'); +}); + +// ============================================ +// Tests for exports from SDK +// ============================================ + +test('retry types are exported from SDK', async (t) => { + const sdk = await import('../../index'); + + t.truthy(sdk.RETRYABLE_STATUS_CODES); + t.truthy(sdk.NON_RETRYABLE_STATUS_CODES); + t.deepEqual(sdk.RETRYABLE_STATUS_CODES, [408, 429, 500, 502, 503, 504]); +}); + +test('Permit accepts retry configuration', async (t) => { + const { Permit } = await import('../../index'); + + // With retry enabled (default) + const permit1 = new Permit({ token: 'test' }); + t.truthy(permit1); + + // With custom retry config + const permit2 = new Permit({ + token: 'test', + retry: { maxRetries: 5 }, + }); + t.truthy(permit2); + + // With retry disabled + const permit3 = new Permit({ + token: 'test', + retry: false, + }); + t.truthy(permit3); + + // With separate PDP retry config + const permit4 = new Permit({ + token: 'test', + retry: { maxRetries: 3 }, + pdpRetry: { maxRetries: 5 }, + }); + t.truthy(permit4); +}); diff --git a/src/utils/retry-interceptor.ts b/src/utils/retry-interceptor.ts new file mode 100644 index 0000000..9d57ae6 --- /dev/null +++ b/src/utils/retry-interceptor.ts @@ -0,0 +1,91 @@ +import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; +import { Logger } from 'pino'; + +import { calculateRetryDelay, IResolvedRetryConfig } from './retry'; + +// Symbols to track retry state on request config (avoids polluting the config object) +const RETRY_COUNT_KEY = '__permitRetryCount'; + +/** + * Extended request config with retry tracking + */ +interface RetryableRequestConfig extends InternalAxiosRequestConfig { + [RETRY_COUNT_KEY]?: number; +} + +/** + * Axios interceptor that implements retry logic with exponential backoff + */ +export class AxiosRetryInterceptor { + /** + * Setup retry interceptor on an axios instance + * + * @param axiosInstance - The axios instance to add retry capability to + * @param config - Resolved retry configuration + * @param logger - Logger instance for retry attempt logging + * @param clientName - Name of the client for logging purposes (e.g., 'API', 'PDP', 'OPA') + */ + static setupInterceptor( + axiosInstance: AxiosInstance, + config: IResolvedRetryConfig, + logger: Logger, + clientName = 'HTTP', + ): void { + if (!config.enabled) { + return; + } + + axiosInstance.interceptors.response.use( + // Success handler - pass through unchanged + (response) => response, + + // Error handler - implement retry logic + async (error: AxiosError) => { + const originalRequest = error.config as RetryableRequestConfig | undefined; + + // If no request config, cannot retry + if (!originalRequest) { + return Promise.reject(error); + } + + // Initialize or get current retry count + const currentRetryCount = originalRequest[RETRY_COUNT_KEY] ?? 0; + + // Check if we should retry this request + const method = (originalRequest.method ?? 'GET').toUpperCase(); + const shouldRetryMethod = config.retryMethods.includes(method); + const shouldRetryError = config.retryCondition(error); + const hasRetriesLeft = currentRetryCount < config.maxRetries; + + // If any condition fails, reject with original error + if (!shouldRetryMethod || !shouldRetryError || !hasRetriesLeft) { + return Promise.reject(error); + } + + // Increment retry count for next attempt + originalRequest[RETRY_COUNT_KEY] = currentRetryCount + 1; + + // Calculate delay before retry + const retryAfterHeader = error.response?.headers?.['retry-after'] as string | undefined; + const delay = calculateRetryDelay(currentRetryCount, config, retryAfterHeader); + + // Log retry attempt + const statusInfo = error.response?.status ?? 'network error'; + const url = originalRequest.url ?? 'unknown'; + logger.warn( + `[${clientName}] Request failed (${statusInfo}), ` + + `retrying in ${Math.round(delay)}ms (attempt ${currentRetryCount + 1}/${ + config.maxRetries + }): ` + + `${method} ${url}`, + ); + + // Wait for the calculated delay + await new Promise((resolve) => setTimeout(resolve, delay)); + + // Retry the request + return axiosInstance.request(originalRequest); + }, + ); + } +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..e02a88b --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,200 @@ +import { AxiosError } from 'axios'; + +/** + * HTTP status codes that should trigger a retry + */ +export const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; + +/** + * HTTP status codes that should NOT trigger a retry + */ +export const NON_RETRYABLE_STATUS_CODES = [400, 401, 403, 404, 422]; + +/** + * Default HTTP methods that are safe to retry + */ +export const DEFAULT_RETRY_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']; + +/** + * Function type for custom retry condition evaluation + */ +export type RetryConditionFn = (error: AxiosError) => boolean; + +/** + * Configuration options for the retry mechanism + */ +export interface IRetryConfig { + /** + * Whether retry is enabled. Defaults to true. + */ + enabled?: boolean; + + /** + * Maximum number of retry attempts. Defaults to 3. + */ + maxRetries?: number; + + /** + * Initial delay between retries in milliseconds. Defaults to 1000 (1 second). + */ + retryDelay?: number; + + /** + * Multiplier for exponential backoff. Defaults to 2. + * Each retry will wait: retryDelay * (backoffMultiplier ^ attemptNumber) + */ + backoffMultiplier?: number; + + /** + * Maximum delay between retries in milliseconds. Defaults to 30000 (30 seconds). + * Prevents exponential backoff from growing too large. + */ + maxDelay?: number; + + /** + * Custom function to determine if a request should be retried. + * If not provided, uses default retry condition (network errors + retryable status codes). + */ + retryCondition?: RetryConditionFn; + + /** + * Whether to respect the Retry-After header for 429 responses. Defaults to true. + */ + respectRetryAfter?: boolean; + + /** + * HTTP methods to retry. Defaults to ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']. + * POST is excluded by default as it may not be idempotent. + */ + retryMethods?: string[]; +} + +/** + * Resolved retry configuration with all defaults applied + */ +export interface IResolvedRetryConfig { + enabled: boolean; + maxRetries: number; + retryDelay: number; + backoffMultiplier: number; + maxDelay: number; + retryCondition: RetryConditionFn; + respectRetryAfter: boolean; + retryMethods: string[]; +} + +/** + * Default retry condition: retry on network errors and retryable HTTP status codes + */ +export function defaultRetryCondition(error: AxiosError): boolean { + // Network errors (no response) - connection refused, timeout, etc. + if (!error.response) { + return true; + } + + // Retryable status codes + return RETRYABLE_STATUS_CODES.includes(error.response.status); +} + +/** + * Default retry configuration values + */ +export const DEFAULT_RETRY_CONFIG: IResolvedRetryConfig = { + enabled: true, + maxRetries: 3, + retryDelay: 1000, + backoffMultiplier: 2, + maxDelay: 30000, + retryCondition: defaultRetryCondition, + respectRetryAfter: true, + retryMethods: DEFAULT_RETRY_METHODS, +}; + +/** + * Parse Retry-After header value + * Supports both seconds (integer) and HTTP-date formats + * + * @param value - The Retry-After header value + * @returns The delay in milliseconds, or null if parsing fails + */ +export function parseRetryAfter(value: string): number | null { + // Try parsing as seconds (integer) + const seconds = parseInt(value, 10); + if (!isNaN(seconds)) { + return seconds * 1000; + } + + // Try parsing as HTTP-date (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") + const date = Date.parse(value); + if (!isNaN(date)) { + return Math.max(0, date - Date.now()); + } + + return null; +} + +/** + * Calculate delay for next retry attempt using exponential backoff with jitter + * + * @param attemptNumber - The current retry attempt number (0-indexed) + * @param config - The resolved retry configuration + * @param retryAfterHeader - Optional Retry-After header value from response + * @returns The delay in milliseconds before the next retry + */ +export function calculateRetryDelay( + attemptNumber: number, + config: IResolvedRetryConfig, + retryAfterHeader?: string, +): number { + // Respect Retry-After header if present and configured + if (config.respectRetryAfter && retryAfterHeader) { + const retryAfterMs = parseRetryAfter(retryAfterHeader); + if (retryAfterMs !== null) { + return Math.min(retryAfterMs, config.maxDelay); + } + } + + // Exponential backoff: delay * (multiplier ^ attempt) + const exponentialDelay = config.retryDelay * Math.pow(config.backoffMultiplier, attemptNumber); + + // Add jitter (0-10% of the delay) to prevent thundering herd + const jitter = Math.random() * 0.1 * exponentialDelay; + + // Apply max delay cap + return Math.min(exponentialDelay + jitter, config.maxDelay); +} + +/** + * Resolve user-provided retry config with defaults + * + * @param userConfig - User-provided retry configuration or false to disable + * @returns Resolved configuration with all defaults applied + */ +export function resolveRetryConfig( + userConfig: IRetryConfig | false | undefined, +): IResolvedRetryConfig { + // If explicitly disabled, return disabled config + if (userConfig === false) { + return { + ...DEFAULT_RETRY_CONFIG, + enabled: false, + }; + } + + // If undefined or empty, use defaults + if (!userConfig) { + return DEFAULT_RETRY_CONFIG; + } + + // Merge user config with defaults + return { + enabled: userConfig.enabled ?? DEFAULT_RETRY_CONFIG.enabled, + maxRetries: userConfig.maxRetries ?? DEFAULT_RETRY_CONFIG.maxRetries, + retryDelay: userConfig.retryDelay ?? DEFAULT_RETRY_CONFIG.retryDelay, + backoffMultiplier: userConfig.backoffMultiplier ?? DEFAULT_RETRY_CONFIG.backoffMultiplier, + maxDelay: userConfig.maxDelay ?? DEFAULT_RETRY_CONFIG.maxDelay, + retryCondition: userConfig.retryCondition ?? DEFAULT_RETRY_CONFIG.retryCondition, + respectRetryAfter: userConfig.respectRetryAfter ?? DEFAULT_RETRY_CONFIG.respectRetryAfter, + retryMethods: userConfig.retryMethods ?? DEFAULT_RETRY_CONFIG.retryMethods, + }; +} diff --git a/yarn.lock b/yarn.lock index bf3d8fb..f1da6aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,22 +22,7 @@ dependencies: escape-string-regexp "^2.0.0" -"@babel/code-frame@^7.0.0", "@babel/code-frame@7.12.11": - version "7.12.11" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz" - integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== - dependencies: - "@babel/highlight" "^7.10.4" - -"@babel/code-frame@^7.22.10": - version "7.22.10" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz" - integrity sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA== - dependencies: - "@babel/highlight" "^7.22.10" - chalk "^2.4.2" - -"@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.10", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -46,6 +31,13 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + dependencies: + "@babel/highlight" "^7.10.4" + "@babel/compat-data@^7.22.9": version "7.22.9" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz" @@ -155,7 +147,7 @@ "@babel/traverse" "^7.22.10" "@babel/types" "^7.22.10" -"@babel/highlight@^7.10.4", "@babel/highlight@^7.22.10": +"@babel/highlight@^7.10.4": version "7.22.10" resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz" integrity sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ== @@ -3091,11 +3083,6 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - function-bind@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" @@ -3343,7 +3330,14 @@ globals@^11.1.0: resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^13.6.0, globals@^13.9.0: +globals@^13.6.0: + version "13.21.0" + resolved "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz" + integrity sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg== + dependencies: + type-fest "^0.20.2" + +globals@^13.9.0: version "13.21.0" resolved "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz" integrity sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg== @@ -6960,12 +6954,7 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^20.2.2: - version "20.2.9" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - -yargs-parser@^20.2.3: +yargs-parser@^20.2.2, yargs-parser@^20.2.3: version "20.2.9" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== From ef4842d6e35d29bc5c4e714b950f3e3066414ecd Mon Sep 17 00:00:00 2001 From: eli Date: Wed, 18 Feb 2026 10:08:38 -0600 Subject: [PATCH 2/2] Fix import sort order in retry tests Sort imports alphabetically to satisfy eslint sort-imports rule. Co-Authored-By: Claude Opus 4.5 --- src/tests/unit/retry.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tests/unit/retry.spec.ts b/src/tests/unit/retry.spec.ts index b455924..b234d4d 100644 --- a/src/tests/unit/retry.spec.ts +++ b/src/tests/unit/retry.spec.ts @@ -2,14 +2,14 @@ import test from 'ava'; import { AxiosError, AxiosHeaders } from 'axios'; import { + calculateRetryDelay, DEFAULT_RETRY_CONFIG, defaultRetryCondition, - calculateRetryDelay, + IRetryConfig, + NON_RETRYABLE_STATUS_CODES, parseRetryAfter, resolveRetryConfig, RETRYABLE_STATUS_CODES, - NON_RETRYABLE_STATUS_CODES, - IRetryConfig, } from '../../utils/retry'; // Helper to create mock AxiosError