diff --git a/README.md b/README.md index 94ba1b7..dc9836e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,58 @@ 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. Retries are **opt-in**: +they are **off** unless you pass a `retry` config (or `retry: { enabled: true }`). + +When enabled, the defaults are: + +- **3 retries** (up to 4 total 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) + +`maxRetries` is the number of retries _after_ the initial request, so the default of `3` means up to 4 total requests. + +> **Behavioral note** +> +> - Retries are opt-in — providing a `retry` config object turns them on; omitting it (or passing `retry: false`) leaves them off. +> - When enabled, PDP/OPA calls additionally retry `POST` because check operations are idempotent. The REST API does **not** retry `POST`, so non-idempotent writes are never repeated. +> - A custom `axiosInstance` applies to the REST API only; PDP and OPA calls use dedicated internal axios instances. + +### Customizing Retry Behavior + +```typescript +import { Permit } from 'permitio'; + +// Retries are off by default (opt-in) +const permitDefault = new Permit({ token: 'your-api-key' }); + +// Enable with custom retry configuration +const permitCustom = 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 + }, +}); + +// Explicitly disable retry +const permitNoRetry = new Permit({ + token: 'your-api-key', + retry: false, +}); + +// Different config for PDP vs REST API +const permitPdp = 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/package.json b/package.json index c8efff7..f5ce238 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "fix:prettier": "prettier --config .prettierrc \"src/**/*.{ts,css,less,scss,js}\" --write", "fix:lint": "eslint src --ext .ts --fix", "test": "run-s test:*", + "test:unit": "run-s build && ava --verbose 'build/tests/unit/**/*.spec.js'", "test:integration": "run-s build && ava --verbose build/tests/endpoints/**/*.spec.js", "test:module-imports": "run-s build && ava --verbose build/tests/module-imports/**/*.spec.js", "test:e2e:rbac": "run-s build && ava --verbose build/tests/e2e/rbac.e2e.spec.js", @@ -59,6 +60,7 @@ "dependencies": { "@bitauth/libauth": "^1.17.1", "axios": "^1.7.4", + "axios-retry": "^4.5.0", "lodash": "^4.17.21", "path-to-regexp": "^6.2.1", "pino": "8.11.0", diff --git a/src/config.ts b/src/config.ts index a021814..fa6b06d 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'; @@ -83,6 +84,10 @@ export interface IPermitConfig { * an optional custom axios instance, to control the behavior of the HTTP client * used to connect to the Permit REST API. * + * This instance applies to the REST API only. PDP and OPA calls use dedicated + * internal axios instances, so their retry policy can differ and non-idempotent + * POST writes on the shared REST client are never retried. + * * @see https://axios-http.com/docs/instance * @see https://axios-http.com/docs/req_config */ @@ -103,13 +108,33 @@ export interface IPermitConfig { */ factsSyncTimeoutPolicy: FactsSyncTimeoutPolicy | null; /** - * an optional custom axios instance for opa, to control the behavior of the HTTP client - * used to connect to the Permit REST API. + * an optional custom axios instance for OPA, to control the behavior of the HTTP + * client used to connect to OPA. This applies to OPA calls only and is separate + * from `axiosInstance` (REST API) and the dedicated internal PDP instance. * * @see https://axios-http.com/docs/instance * @see https://axios-http.com/docs/req_config */ opaAxiosInstance?: AxiosInstance; + + /** + * Configuration for automatic retry of failed requests. + * Retries are opt-in: when omitted or set to false, retries are disabled. + * Providing a config object enables them (3 retries with exponential backoff + * by default). + * + * @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..f5218da 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, @@ -137,18 +139,13 @@ export class Enforcer implements IEnforcer { opaBaseUrl.set('port', '8181'); opaBaseUrl.set('pathname', `${opaBaseUrl.pathname}v1/data/permit/`); const version = process.env.npm_package_version ?? 'unknown'; - if (config.axiosInstance) { - this.client = config.axiosInstance; - this.client.defaults.baseURL = `${this.config.pdp}/`; - this.client.defaults.headers.common['X-Permit-SDK-Version'] = `node:${version}`; - } else { - this.client = axios.create({ - baseURL: `${this.config.pdp}/`, - headers: { - 'X-Permit-SDK-Version': `node:${version}`, - }, - }); - } + // PDP gets its own dedicated axios instance so PDP-only POST retries never + // apply to the shared REST API client (config.axiosInstance) — REST writes + // must never be retried. + this.client = axios.create({ + baseURL: `${this.config.pdp}/`, + headers: { 'X-Permit-SDK-Version': `node:${version}` }, + }); if (config.opaAxiosInstance) { this.opaClient = config.opaAxiosInstance; this.opaClient.defaults.baseURL = opaBaseUrl.toString(); @@ -163,6 +160,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: [...new Set([...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..a96af18 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,7 @@ 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 } from './utils/retry'; export interface IPermitClient extends IEnforcer { /** @@ -140,6 +143,26 @@ 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. + // Strip POST from the REST retryMethods regardless of user config: REST + // writes are non-idempotent and must never be repeated. (This is symmetric + // with the enforcer, which ADDS POST for the idempotent PDP/OPA check calls.) + const resolvedRetryConfig = resolveRetryConfig(this.config.retry); + const restRetryConfig = { + ...resolvedRetryConfig, + retryMethods: resolvedRetryConfig.retryMethods.filter((m) => m !== 'POST'), + }; + // Skip the install when no methods remain (e.g. retryMethods: ['POST']), + // which would otherwise add an interceptor that can never retry. + if (resolvedRetryConfig.enabled && restRetryConfig.retryMethods.length > 0) { + AxiosRetryInterceptor.setupInterceptor( + this.config.axiosInstance, + restRetryConfig, + 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-interceptor.spec.ts b/src/tests/unit/retry-interceptor.spec.ts new file mode 100644 index 0000000..5fd1e09 --- /dev/null +++ b/src/tests/unit/retry-interceptor.spec.ts @@ -0,0 +1,233 @@ +import test from 'ava'; +import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; + +// Reaches the private REST (config.axiosInstance) and PDP (enforcer.client) +// instances for the behavior tests below. +interface PermitInternals { + config: { axiosInstance: AxiosInstance }; + enforcer: { client: AxiosInstance }; +} + +// Tiny delays keep every retry near-instant and deterministic (no real waits). +const tiny = { maxRetries: 2, retryDelay: 1, maxDelay: 5, backoffMultiplier: 1 }; + +// Installs a custom adapter that counts invocations and always rejects with a +// synthetic AxiosError carrying the live request config, so axios-retry can +// re-dispatch the request and we can observe how many times it ran. +function installRejectingAdapter(instance: AxiosInstance, status = 503): { count: () => number } { + let calls = 0; + instance.defaults.adapter = (config: InternalAxiosRequestConfig): Promise => { + calls += 1; + const error = new Error('synthetic failure') as AxiosError; + error.isAxiosError = true; + error.config = config; + error.toJSON = () => ({}); + error.response = { + status, + statusText: 'Error', + headers: {}, + config, + data: {}, + }; + return Promise.reject(error); + }; + return { count: () => calls }; +} + +// Installs an adapter that rejects with a synthetic 503 for the first +// `failTimes` calls, then resolves with a success response, so we can prove a +// retry actually recovers. +function installRejectThenResolveAdapter( + instance: AxiosInstance, + failTimes: number, +): { count: () => number } { + let calls = 0; + instance.defaults.adapter = (config: InternalAxiosRequestConfig) => { + calls += 1; + if (calls <= failTimes) { + const error = new Error('synthetic failure') as AxiosError; + error.isAxiosError = true; + error.config = config; + error.toJSON = () => ({}); + error.response = { status: 503, statusText: 'Error', headers: {}, config, data: {} }; + return Promise.reject(error); + } + return Promise.resolve({ + status: 200, + statusText: 'OK', + headers: {}, + config, + data: { ok: true }, + }); + }; + return { count: () => calls }; +} + +async function newPermit(overrides: Record): Promise { + const { Permit } = await import('../../index'); + return new Permit({ token: 'test', ...overrides }) as unknown as PermitInternals; +} + +test('REST and PDP use separate axios instances', async (t) => { + const permit = await newPermit({ retry: { maxRetries: 1 }, pdpRetry: { maxRetries: 1 } }); + + t.not(permit.config.axiosInstance, permit.enforcer.client); +}); + +test('REST instance does not retry POST while PDP instance does', async (t) => { + const permit = await newPermit({ retry: tiny, pdpRetry: tiny }); + + const rest = installRejectingAdapter(permit.config.axiosInstance); + const pdp = installRejectingAdapter(permit.enforcer.client); + + await t.throwsAsync(() => permit.config.axiosInstance.request({ method: 'POST', url: '/x' })); + await t.throwsAsync(() => permit.enforcer.client.request({ method: 'POST', url: '/x' })); + + // REST never retries POST -> exactly one adapter call. + t.is(rest.count(), 1); + // PDP retries POST (maxRetries: 2) -> initial call + two retries = three. + t.is(pdp.count(), 3); +}); + +test('a retryable GET retries up to maxRetries then rejects', async (t) => { + const permit = await newPermit({ retry: tiny }); + const rest = installRejectingAdapter(permit.config.axiosInstance); + + await t.throwsAsync(() => permit.config.axiosInstance.request({ method: 'GET', url: '/x' })); + + // maxRetries: 2 -> initial call + two retries = three total. + t.is(rest.count(), 3); +}); + +test('a non-retryable status (400) is not retried', async (t) => { + const permit = await newPermit({ retry: tiny }); + const rest = installRejectingAdapter(permit.config.axiosInstance, 400); + + await t.throwsAsync(() => permit.config.axiosInstance.request({ method: 'GET', url: '/x' })); + + t.is(rest.count(), 1); +}); + +test('a disallowed method on the REST instance is not retried', async (t) => { + const permit = await newPermit({ retry: tiny }); + const rest = installRejectingAdapter(permit.config.axiosInstance); + + // PUT is in DEFAULT_RETRY_METHODS but POST is not, so POST must not retry. + await t.throwsAsync(() => permit.config.axiosInstance.request({ method: 'POST', url: '/x' })); + + t.is(rest.count(), 1); +}); + +test('disabled retry (retry: false) installs no retry', async (t) => { + const permit = await newPermit({ retry: false }); + const rest = installRejectingAdapter(permit.config.axiosInstance); + + await t.throwsAsync(() => permit.config.axiosInstance.request({ method: 'GET', url: '/x' })); + + t.is(rest.count(), 1); +}); + +test('REST instance never retries POST even when the user opts POST in', async (t) => { + // POST is stripped from the REST retryMethods regardless of user config, so + // non-idempotent REST writes are never repeated. + const permit = await newPermit({ retry: { ...tiny, retryMethods: ['GET', 'POST'] } }); + const rest = installRejectingAdapter(permit.config.axiosInstance); + + await t.throwsAsync(() => permit.config.axiosInstance.request({ method: 'POST', url: '/x' })); + + t.is(rest.count(), 1); +}); + +test.serial('maps axios-retry retryCount to our 0-based attempt number', async (t) => { + // axios-retry passes a 1-based retryCount; the interceptor must subtract one + // so the first retry uses attempt 0. With jitter removed, attempt 0 -> 30ms + // and attempt 1 (the off-by-one bug) -> 90ms. We capture the delay axios-retry + // schedules via setTimeout instead of measuring wall-clock time, so the check + // is deterministic and concurrency-safe. + const realRandom = Math.random; + const realSetTimeout = global.setTimeout; + const scheduledDelays: number[] = []; + Math.random = () => 0; // strip jitter + // Capture the delay, then fire immediately so the retry still proceeds. + global.setTimeout = ((fn: () => void, delay?: number): ReturnType => { + scheduledDelays.push(delay ?? 0); + return realSetTimeout(fn, 0); + }) as typeof setTimeout; + try { + const permit = await newPermit({ + retry: { maxRetries: 1, retryDelay: 30, backoffMultiplier: 3, maxDelay: 10000 }, + }); + installRejectingAdapter(permit.config.axiosInstance); + + await t.throwsAsync(() => permit.config.axiosInstance.request({ method: 'GET', url: '/x' })); + + t.true(scheduledDelays.includes(30), `expected a 30ms delay, got ${scheduledDelays.join(',')}`); + t.false(scheduledDelays.includes(90), 'attempt-1 (off-by-one) delay must not be used'); + } finally { + Math.random = realRandom; + global.setTimeout = realSetTimeout; + } +}); + +test('a retry recovers: GET succeeds after one failed attempt', async (t) => { + const permit = await newPermit({ + retry: { maxRetries: 2, retryDelay: 1, maxDelay: 5, backoffMultiplier: 1 }, + }); + const rest = installRejectThenResolveAdapter(permit.config.axiosInstance, 1); + + const response = await permit.config.axiosInstance.request({ method: 'GET', url: '/x' }); + + // Initial failure + one retry that succeeds. + t.is(rest.count(), 2); + t.is(response.status, 200); + t.deepEqual(response.data, { ok: true }); +}); + +test.serial('Retry-After header drives the retry delay end-to-end', async (t) => { + // 429 with `retry-after: 2` (seconds) must produce a 2000ms scheduled delay, + // which exceeds the 10ms backoff and is under maxDelay, proving Retry-After + // flows through calculateRetryDelay via our retryDelay callback. The + // Retry-After branch returns the exact value with no jitter, so we only need + // to capture the setTimeout delay (nothing is actually awaited). + const realSetTimeout = global.setTimeout; + const scheduledDelays: number[] = []; + global.setTimeout = ((fn: () => void, delay?: number): ReturnType => { + scheduledDelays.push(delay ?? 0); + return realSetTimeout(fn, 0); + }) as typeof setTimeout; + try { + const permit = await newPermit({ + retry: { maxRetries: 1, retryDelay: 10, maxDelay: 30000, backoffMultiplier: 1 }, + }); + + let calls = 0; + permit.config.axiosInstance.defaults.adapter = ( + config: InternalAxiosRequestConfig, + ): Promise => { + calls += 1; + const error = new Error('rate limited') as AxiosError; + error.isAxiosError = true; + error.config = config; + error.toJSON = () => ({}); + error.response = { + status: 429, + statusText: 'Too Many Requests', + headers: { 'retry-after': '2' }, + config, + data: {}, + }; + return Promise.reject(error); + }; + + await t.throwsAsync(() => permit.config.axiosInstance.request({ method: 'GET', url: '/x' })); + + // 429 is retryable and GET is allowed, so it retried once. + t.is(calls, 2); + t.true( + scheduledDelays.includes(2000), + `expected a 2000ms delay, got ${scheduledDelays.join(',')}`, + ); + } finally { + global.setTimeout = realSetTimeout; + } +}); diff --git a/src/tests/unit/retry.spec.ts b/src/tests/unit/retry.spec.ts new file mode 100644 index 0000000..4d13dcd --- /dev/null +++ b/src/tests/unit/retry.spec.ts @@ -0,0 +1,372 @@ +import test from 'ava'; +import { AxiosError, AxiosHeaders } from 'axios'; + +import { + calculateRetryDelay, + DEFAULT_RETRY_CONFIG, + defaultRetryCondition, + IRetryConfig, + parseRetryAfter, + resolveRetryConfig, + RETRYABLE_STATUS_CODES, +} 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]); +}); + +// ============================================ +// 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 returns null for non digits-only delta-seconds', (t) => { + t.is(parseRetryAfter('5abc'), null); +}); + +test.serial('parseRetryAfter parses HTTP-date format', (t) => { + const realNow = Date.now; + try { + const fixedNow = Date.UTC(2015, 9, 21, 7, 28, 0); // "Wed, 21 Oct 2015 07:28:00 GMT" + Date.now = () => fixedNow; + + // 10 seconds in the future relative to the stubbed clock + const httpDate = new Date(fixedNow + 10000).toUTCString(); + t.is(parseRetryAfter(httpDate), 10000); + } finally { + Date.now = realNow; + } +}); + +test.serial('parseRetryAfter returns 0 for past HTTP-date', (t) => { + const realNow = Date.now; + try { + const fixedNow = Date.UTC(2015, 9, 21, 7, 28, 0); + Date.now = () => fixedNow; + + const httpDate = new Date(fixedNow - 10000).toUTCString(); + t.is(parseRetryAfter(httpDate), 0); + } finally { + Date.now = realNow; + } +}); + +// ============================================ +// 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.false(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 opts in when a config object is provided', (t) => { + const resolved = resolveRetryConfig({ maxRetries: 5 }); + + t.true(resolved.enabled); +}); + +test('resolveRetryConfig normalizes retry methods to uppercase', (t) => { + const resolved = resolveRetryConfig({ retryMethods: ['get', 'post'] }); + + t.deepEqual(resolved.retryMethods, ['GET', 'POST']); +}); + +test('resolveRetryConfig returns a copy that does not mutate the default', (t) => { + const resolved = resolveRetryConfig(undefined); + resolved.retryMethods.push('PATCH'); + + t.deepEqual(DEFAULT_RETRY_CONFIG.retryMethods, ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']); +}); + +test('DEFAULT_RETRY_CONFIG.retryMethods is frozen', (t) => { + t.true(Object.isFrozen(DEFAULT_RETRY_CONFIG.retryMethods)); +}); + +test('RETRYABLE_STATUS_CODES is frozen', (t) => { + t.true(Object.isFrozen(RETRYABLE_STATUS_CODES)); +}); + +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.false(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.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 off (opt-in 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..69cd418 --- /dev/null +++ b/src/utils/retry-interceptor.ts @@ -0,0 +1,62 @@ +import { AxiosError, AxiosInstance } from 'axios'; +import axiosRetry from 'axios-retry'; +import { Logger } from 'pino'; + +import { calculateRetryDelay, IResolvedRetryConfig } from './retry'; + +/** + * Installs retry behavior on an axios instance using the axios-retry library, + * driven by our resolved retry configuration (delay/condition policy). + */ +export class AxiosRetryInterceptor { + /** + * Setup retry 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; + } + + axiosRetry(axiosInstance, { + retries: config.maxRetries, + shouldResetTimeout: true, + + // Preserve our policy: per-instance method filtering (REST excludes POST, + // PDP includes it) plus our error condition (network + retryable status). + retryCondition: (error: AxiosError): boolean => { + const method = (error.config?.method ?? 'GET').toUpperCase(); + if (!config.retryMethods.includes(method)) { + return false; + } + return config.retryCondition(error); + }, + + // axios-retry's retryCount is 1-based (1 on the first retry); our + // calculateRetryDelay expects a 0-based attempt number, so subtract one. + retryDelay: (retryCount: number, error: AxiosError): number => { + const retryAfterHeader = error.response?.headers?.['retry-after'] as string | undefined; + return calculateRetryDelay(retryCount - 1, config, retryAfterHeader); + }, + + onRetry: (retryCount: number, error: AxiosError): void => { + const status = error.response?.status ?? 'network error'; + const method = (error.config?.method ?? 'GET').toUpperCase(); + const url = error.config?.url ?? 'unknown'; + logger.warn( + `[${clientName}] Request failed (${status}), ` + + `retry ${retryCount}/${config.maxRetries}: ${method} ${url}`, + ); + }, + }); + } +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..0cec80f --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,214 @@ +import { AxiosError } from 'axios'; + +/** + * HTTP status codes that should trigger a retry. + * Frozen so consumers cannot mutate the SDK-wide retry behavior. + */ +export const RETRYABLE_STATUS_CODES: readonly number[] = Object.freeze([ + 408, 429, 500, 502, 503, 504, +]); + +/** + * Default HTTP methods that are safe to retry. + * Frozen so the shared DEFAULT_RETRY_CONFIG array cannot be mutated by consumers. + */ +const DEFAULT_RETRY_METHODS: readonly string[] = Object.freeze([ + '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. Retries are opt-in: they are off unless a retry + * config object is supplied (which opts you in) or `enabled: true` is set + * explicitly. The default config is disabled. + */ + enabled?: boolean; + + /** + * Maximum number of retries after the initial request. Defaults to 3 + * (i.e. up to 4 total attempts). + */ + 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. + * Retries are opt-in, so the default config is disabled. + */ +export const DEFAULT_RETRY_CONFIG: IResolvedRetryConfig = Object.freeze({ + enabled: false, + maxRetries: 3, + retryDelay: 1000, + backoffMultiplier: 2, + maxDelay: 30000, + retryCondition: defaultRetryCondition, + respectRetryAfter: true, + // Cast keeps the public IResolvedRetryConfig.retryMethods type as string[]; + // the array is frozen at runtime and resolveRetryConfig always clones it + // before returning, so callers receive a mutable copy. + retryMethods: DEFAULT_RETRY_METHODS as string[], +}); + +/** + * 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 delta-seconds. Per the Retry-After spec this must be + // digits only, so reject values like "5abc" that parseInt would accept. + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) { + return Number(trimmed) * 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 { + // No retry config (undefined) or explicit `false` => retries are off. + // Return a fresh object with a cloned retryMethods array so callers can + // never mutate DEFAULT_RETRY_CONFIG or its array. + if (!userConfig) { + return { + ...DEFAULT_RETRY_CONFIG, + enabled: false, + retryMethods: [...DEFAULT_RETRY_CONFIG.retryMethods], + }; + } + + // Providing a retry config object opts you in, unless `enabled: false` is set. + const retryMethods = userConfig.retryMethods ?? DEFAULT_RETRY_CONFIG.retryMethods; + return { + enabled: userConfig.enabled ?? true, + 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, + // Normalize to uppercase so lowercase user methods match the interceptor, + // which uppercases the request method. + retryMethods: retryMethods.map((m) => m.toUpperCase()), + }; +} diff --git a/yarn.lock b/yarn.lock index bf3d8fb..4d08fd7 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== @@ -1282,6 +1274,13 @@ axios@0.27.2: follow-redirects "^1.14.9" form-data "^4.0.0" +axios-retry@^4.5.0: + version "4.5.0" + resolved "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz" + integrity sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ== + dependencies: + is-retry-allowed "^2.2.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" @@ -3091,11 +3090,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 +3337,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== @@ -3854,6 +3855,11 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-retry-allowed@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz" + integrity sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg== + is-shared-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz" @@ -6960,12 +6966,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==