Conversation
- 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 <noreply@anthropic.com>
Sort imports alphabetically to satisfy eslint sort-imports rule. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds first-class HTTP retry support to the SDK (REST API + PDP/OPA clients), including exponential backoff, optional Retry-After handling, and user-configurable retry behavior via retry / pdpRetry options.
Changes:
- Introduces retry utilities (
resolveRetryConfig, backoff/jitter delay calculation,Retry-Afterparsing) and an Axios response interceptor to perform retries. - Wires retry configuration into
Permit(API client) andEnforcer(PDP/OPA clients, with POST retries enabled for PDP/OPA). - Adds unit tests for retry utilities and documents configuration in the README.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Lockfile adjustments from dependency resolution changes. |
| src/utils/retry.ts | Retry configuration/types, default condition, Retry-After parsing, and delay calculation utilities. |
| src/utils/retry-interceptor.ts | Axios response interceptor implementing retry logic + logging. |
| src/index.ts | Exposes retry types/constants and installs retry interceptor for REST API calls. |
| src/enforcement/enforcer.ts | Installs retry interceptor for PDP/OPA clients and enables POST retries for idempotent checks. |
| src/config.ts | Adds retry and pdpRetry options to SDK configuration. |
| src/tests/unit/retry.spec.ts | Adds AVA unit tests for retry utilities and basic SDK export/config checks. |
| README.md | Documents retry configuration and examples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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); | ||
| }, |
There was a problem hiding this comment.
Retry behavior is implemented in this interceptor, but there are no unit tests that assert actual retry execution (e.g., retry count limiting, delay calculation usage, Retry-After handling, method filtering). Adding focused tests around AxiosRetryInterceptor.setupInterceptor() (with a stubbed axios instance and fake timers/Math.random) would help prevent regressions in the core feature.
| // For PDP calls, enable POST retry since check operations are idempotent | ||
| const pdpRetryWithPost = { | ||
| ...pdpRetryConfig, | ||
| retryMethods: [...pdpRetryConfig.retryMethods, 'POST'], |
There was a problem hiding this comment.
pdpRetryWithPost appends 'POST' to retryMethods without deduplicating. If the user already includes POST, this can create duplicates. Consider ensuring uniqueness (e.g., via new Set(...)) when constructing the PDP method list.
| // For PDP calls, enable POST retry since check operations are idempotent | |
| const pdpRetryWithPost = { | |
| ...pdpRetryConfig, | |
| retryMethods: [...pdpRetryConfig.retryMethods, 'POST'], | |
| // For PDP calls, ensure POST is retried (check operations are idempotent), without duplicates | |
| const pdpRetryWithPost = { | |
| ...pdpRetryConfig, | |
| retryMethods: [...new Set([...pdpRetryConfig.retryMethods, 'POST'])], |
| // 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); |
There was a problem hiding this comment.
This test can be flaky because it depends on wall-clock timing (it asserts the parsed delay is within a 2s window). Consider stubbing Date.now() (or passing a "now" value into parseRetryAfter) so the test uses fixed timestamps and remains deterministic across slow CI environments.
| // 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); | |
| const originalNow = Date.now; | |
| try { | |
| const fixedNow = 1700000000000; // arbitrary fixed timestamp | |
| (Date as any).now = () => fixedNow; | |
| const futureDate = new Date(fixedNow + 10000); | |
| const httpDate = futureDate.toUTCString(); | |
| const result = parseRetryAfter(httpDate); | |
| t.is(result, 10000); | |
| } finally { | |
| (Date as any).now = originalNow; | |
| } | |
| }); | |
| test('parseRetryAfter returns 0 for past HTTP-date', (t) => { | |
| const originalNow = Date.now; | |
| try { | |
| const fixedNow = 1700000000000; // arbitrary fixed timestamp | |
| (Date as any).now = () => fixedNow; | |
| const pastDate = new Date(fixedNow - 10000); | |
| const httpDate = pastDate.toUTCString(); | |
| const result = parseRetryAfter(httpDate); | |
| t.is(result, 0); | |
| } finally { | |
| (Date as any).now = originalNow; | |
| } |
| ```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 }, | ||
| }); |
There was a problem hiding this comment.
The README example redeclares const permit multiple times within the same code block, which won't type-check if copy/pasted. Consider using different variable names (e.g., permitDefault, permitCustom, ...) or splitting into separate code blocks.
| // Try parsing as seconds (integer) | ||
| const seconds = parseInt(value, 10); | ||
| if (!isNaN(seconds)) { | ||
| return seconds * 1000; |
There was a problem hiding this comment.
parseRetryAfter() uses parseInt() which will accept partially-numeric strings (e.g. "5abc" -> 5). Per the Retry-After spec, delta-seconds should be digits only; consider validating with a digits-only regex (or Number() + string check) before converting, otherwise invalid headers may be treated as valid delays.
| // Try parsing as seconds (integer) | |
| const seconds = parseInt(value, 10); | |
| if (!isNaN(seconds)) { | |
| return seconds * 1000; | |
| // Try parsing as seconds (integer). Per spec, this must be digits only. | |
| const trimmed = value.trim(); | |
| if (/^\d+$/.test(trimmed)) { | |
| const seconds = Number(trimmed); | |
| if (!Number.isNaN(seconds)) { | |
| return seconds * 1000; | |
| } |
| // If undefined or empty, use defaults | ||
| if (!userConfig) { | ||
| return DEFAULT_RETRY_CONFIG; | ||
| } |
There was a problem hiding this comment.
resolveRetryConfig(undefined) returns the DEFAULT_RETRY_CONFIG object by reference. Since DEFAULT_RETRY_CONFIG is exported and mutable, consumers could mutate it and unintentionally affect retry behavior globally. Consider returning a shallow copy (and cloning retryMethods) or freezing the default config to prevent external mutation.
| // 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, | ||
| }; |
There was a problem hiding this comment.
AxiosRetryInterceptor uppercases the request method, but resolveRetryConfig() passes through retryMethods as provided. If a user supplies lowercase methods (common in axios configs), retries will never trigger. Consider normalizing retryMethods to uppercase when resolving config (or normalizing both sides in the interceptor).
| import { calculateRetryDelay, IResolvedRetryConfig } from './retry'; | ||
|
|
||
| // Symbols to track retry state on request config (avoids polluting the config object) | ||
| const RETRY_COUNT_KEY = '__permitRetryCount'; |
There was a problem hiding this comment.
Comment says "Symbols to track retry state" but the implementation uses a string key ('__permitRetryCount'). Either switch to a Symbol to avoid any chance of key collision or update the comment to reflect the actual approach.
| const RETRY_COUNT_KEY = '__permitRetryCount'; | |
| const RETRY_COUNT_KEY = Symbol('permitRetryCount'); |
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
What kind of change does this PR introduce? (Bug fix, feature, docs update, ...)
What is the current behavior? (You can also link to an open issue here)
What is the new behavior (if this is a feature change)?
Other information: