Skip to content

Comments

Add retry mechanism for HTTP requests#120

Open
EliMoshkovich wants to merge 2 commits intomainfrom
PER-13890-adding-node-sdk
Open

Add retry mechanism for HTTP requests#120
EliMoshkovich wants to merge 2 commits intomainfrom
PER-13890-adding-node-sdk

Conversation

@EliMoshkovich
Copy link
Collaborator

  • 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:

- 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>
@linear
Copy link

linear bot commented Feb 18, 2026

Sort imports alphabetically to satisfy eslint sort-imports rule.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-After parsing) and an Axios response interceptor to perform retries.
  • Wires retry configuration into Permit (API client) and Enforcer (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.

Comment on lines +28 to +88
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);
},
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +176
// For PDP calls, enable POST retry since check operations are idempotent
const pdpRetryWithPost = {
...pdpRetryConfig,
retryMethods: [...pdpRetryConfig.retryMethods, 'POST'],
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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'])],

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +149
// 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);
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;
}

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +58
```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 },
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +124
// Try parsing as seconds (integer)
const seconds = parseInt(value, 10);
if (!isNaN(seconds)) {
return seconds * 1000;
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;
}

Copilot uses AI. Check for mistakes.
Comment on lines +184 to +187
// If undefined or empty, use defaults
if (!userConfig) {
return DEFAULT_RETRY_CONFIG;
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +189 to +199
// 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,
};
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
import { calculateRetryDelay, IResolvedRetryConfig } from './retry';

// Symbols to track retry state on request config (avoids polluting the config object)
const RETRY_COUNT_KEY = '__permitRetryCount';
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const RETRY_COUNT_KEY = '__permitRetryCount';
const RETRY_COUNT_KEY = Symbol('permitRetryCount');

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant