Skip to content
Merged
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Comment thread
zeevmoney marked this conversation as resolved.
```

## Documentation

[Read the documentation at Permit.io website](https://docs.permit.io/sdk/nodejs/quickstart-nodejs#add-the-sdk-to-your-js-code)
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 27 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*/
Expand All @@ -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;
}

/**
Expand Down
35 changes: 23 additions & 12 deletions src/enforcement/enforcer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}` },
});
Comment thread
zeevmoney marked this conversation as resolved.
if (config.opaAxiosInstance) {
this.opaClient = config.opaAxiosInstance;
this.opaClient.defaults.baseURL = opaBaseUrl.toString();
Expand All @@ -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();
}

Expand Down
23 changes: 23 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
/**
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading