Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ import { WorkOS } from '@workos-inc/node';
const workos = new WorkOS('sk_1234');
```

### Automatic retries

The SDK automatically retries requests that fail with a transient error — a
network error, a request timeout (408), a rate limit (429), or a server error
(500, 502, 503, 504) — using exponential backoff with jitter. When the API
responds with a `Retry-After` header, the SDK waits for that duration instead
(capped at 60 seconds). Retried `POST` requests are automatically assigned an
`Idempotency-Key` header (unless one is already set) so a retried write is not
applied twice.

The default is 3 retries. Configure it client-wide, or per request on the
low-level HTTP methods:

```ts
// Client-wide: up to 5 retries per request
const workos = new WorkOS('sk_1234', { maxRetries: 5 });

// Per-request override: disable retries for this call only
await workos.get('/organizations', { maxRetries: 0 });
```

Set `maxRetries: 0` to disable automatic retries entirely.

## Public Client Mode (Browser/Mobile/CLI)

For apps that can't securely store secrets, initialize with just a client ID:
Expand Down
4 changes: 4 additions & 0 deletions src/common/exceptions/parse-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,25 @@ export class ParseError extends Error implements RequestException {
readonly rawBody: string;
readonly rawStatus: number;
readonly requestID: string;
readonly rawHeaders?: Headers;

constructor({
message,
rawBody,
rawStatus,
requestID,
rawHeaders,
}: {
message: string;
rawBody: string;
requestID: string;
rawStatus: number;
rawHeaders?: Headers;
}) {
super(message);
this.rawBody = rawBody;
this.rawStatus = rawStatus;
this.requestID = requestID;
this.rawHeaders = rawHeaders;
}
}
2 changes: 2 additions & 0 deletions src/common/interfaces/get-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export interface GetOptions {
warrantToken?: string;
/** Skip API key requirement check (for PKCE-safe methods) */
skipApiKeyCheck?: boolean;
/** Maximum number of retries for this request, overriding the client-wide `maxRetries`. */
maxRetries?: number;
}
5 changes: 5 additions & 0 deletions src/common/interfaces/http-client.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ export type RequestHeaders = Record<string, string | number | string[]>;
export type RequestOptions = {
params?: Record<string, any>;
headers?: RequestHeaders;
/**
* Maximum number of retries for this request, overriding the client-wide
* `maxRetries`. Set to `0` to disable retries for this request.
*/
maxRetries?: number;
};
export type ResponseHeaderValue = string | string[];
export type ResponseHeaders = Record<string, ResponseHeaderValue>;
Expand Down
2 changes: 2 additions & 0 deletions src/common/interfaces/patch-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ export interface PatchOptions {
idempotencyKey?: string;
/** Skip API key requirement check (for PKCE-safe methods) */
skipApiKeyCheck?: boolean;
/** Maximum number of retries for this request, overriding the client-wide `maxRetries`. */
maxRetries?: number;
}
2 changes: 2 additions & 0 deletions src/common/interfaces/post-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ export interface PostOptions {
warrantToken?: string;
/** Skip API key requirement check (for PKCE-safe methods) */
skipApiKeyCheck?: boolean;
/** Maximum number of retries for this request, overriding the client-wide `maxRetries`. */
maxRetries?: number;
}
2 changes: 2 additions & 0 deletions src/common/interfaces/put-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ export interface PutOptions {
idempotencyKey?: string;
/** Skip API key requirement check (for PKCE-safe methods) */
skipApiKeyCheck?: boolean;
/** Maximum number of retries for this request, overriding the client-wide `maxRetries`. */
maxRetries?: number;
}
7 changes: 7 additions & 0 deletions src/common/interfaces/workos-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ export interface WorkOSOptions {
fetchFn?: typeof fetch;
clientId?: string;
timeout?: number; // Timeout in milliseconds
/**
* Maximum number of automatic retries for transient failures (network
* errors and 408/429/5xx responses). Retries use exponential backoff with
* jitter and honor the `Retry-After` header (capped at 60 seconds).
* Defaults to 3. Set to `0` to disable automatic retries.
*/
maxRetries?: number;
}
243 changes: 240 additions & 3 deletions src/common/net/fetch-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,12 @@ describe('Fetch client', () => {
},
);

await expect(fetchClient.get('/users', {})).rejects.toThrow(ParseError);
await expect(
fetchClient.get('/users', { maxRetries: 0 }),
).rejects.toThrow(ParseError);

try {
await fetchClient.get('/users', {});
await fetchClient.get('/users', { maxRetries: 0 });
} catch (error) {
expect(error).toBeInstanceOf(ParseError);
const parseError = error as ParseError;
Expand Down Expand Up @@ -323,6 +325,241 @@ describe('Fetch client', () => {
});
});

describe('automatic retries', () => {
beforeEach(() => fetch.resetMocks());

it('retries requests on non-vault paths by default', async () => {
fetchOnce({}, { status: 500 });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

const response = await fetchClient.get('/organizations', {});

expect(fetch.mock.calls.length).toBe(2);
expect(await response.toJSON()).toEqual({ data: 'response' });
});

it('retries requests on a 429 status code', async () => {
fetchOnce({}, { status: 429 });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

const response = await fetchClient.get('/organizations', {});

expect(fetch.mock.calls.length).toBe(2);
expect(await response.toJSON()).toEqual({ data: 'response' });
});

it('honors the Retry-After header (delay-seconds) when retrying', async () => {
fetchOnce({}, { status: 429, headers: { 'Retry-After': '2' } });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await fetchClient.get('/organizations', {});

expect(mockSleep).toHaveBeenCalledTimes(1);
expect(mockSleep).toHaveBeenCalledWith(expect.any(Number), 2000);
});

it('attaches an Idempotency-Key to a retried POST that lacks one', async () => {
fetchOnce({}, { status: 500 });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await fetchClient.post('/organizations', { name: 'Test' }, {});

const firstHeaders = fetch.mock.calls[0][1]?.headers as Record<
string,
string
>;
const secondHeaders = fetch.mock.calls[1][1]?.headers as Record<
string,
string
>;
expect(firstHeaders['Idempotency-Key']).toMatch(/^retry-/);
expect(secondHeaders['Idempotency-Key']).toBe(
firstHeaders['Idempotency-Key'],
);
});

it('does not override a caller-provided Idempotency-Key on retry', async () => {
fetchOnce({}, { status: 500 });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await fetchClient.post(
'/organizations',
{ name: 'Test' },
{ headers: { 'Idempotency-Key': 'user-key' } },
);

const firstHeaders = fetch.mock.calls[0][1]?.headers as Record<
string,
string
>;
const secondHeaders = fetch.mock.calls[1][1]?.headers as Record<
string,
string
>;
expect(firstHeaders['Idempotency-Key']).toBe('user-key');
expect(secondHeaders['Idempotency-Key']).toBe('user-key');
});

it('retries a retryable status with a non-JSON error body', async () => {
fetch.mockResponseOnce('<html>503 Service Unavailable</html>', {
status: 503,
headers: { 'content-type': 'text/html' },
});
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

const response = await fetchClient.get('/organizations', {});

expect(fetch.mock.calls.length).toBe(2);
expect(await response.toJSON()).toEqual({ data: 'response' });
});

it('honors Retry-After on a retryable non-JSON error body', async () => {
fetch.mockResponseOnce('<html>503 Service Unavailable</html>', {
status: 503,
headers: { 'content-type': 'text/html', 'Retry-After': '7' },
});
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await fetchClient.get('/organizations', {});

expect(fetch.mock.calls.length).toBe(2);
expect(mockSleep).toHaveBeenCalledWith(expect.any(Number), 7000);
});

it('does not retry when maxRetries is 0', async () => {
const client = new FetchHttpClient('https://test.workos.com', {
maxRetries: 0,
});
fetchOnce({}, { status: 500 });

await expect(client.get('/organizations', {})).rejects.toThrow();

expect(fetch.mock.calls.length).toBe(1);
});

it('respects a per-request maxRetries override', async () => {
fetchOnce({}, { status: 500 });
fetchOnce({}, { status: 500 });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await expect(
fetchClient.get('/organizations', { maxRetries: 1 }),
).rejects.toThrow();

expect(fetch.mock.calls.length).toBe(2);
});

it('honors the Retry-After header (HTTP-date) when retrying', async () => {
const fixedNow = 1_700_000_000_000;
jest.spyOn(Date, 'now').mockReturnValue(fixedNow);
fetchOnce(
{},
{
status: 429,
headers: { 'Retry-After': new Date(fixedNow + 5_000).toUTCString() },
},
);
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await fetchClient.get('/organizations', {});

expect(mockSleep).toHaveBeenCalledTimes(1);
expect(mockSleep).toHaveBeenCalledWith(expect.any(Number), 5000);
});

it('caps a server-provided Retry-After delay at 60 seconds', async () => {
fetchOnce({}, { status: 429, headers: { 'Retry-After': '3600' } });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await fetchClient.get('/organizations', {});

expect(mockSleep).toHaveBeenCalledTimes(1);
expect(mockSleep).toHaveBeenCalledWith(expect.any(Number), 60_000);
});

it('falls back to computed backoff when Retry-After is unparseable', async () => {
fetchOnce({}, { status: 429, headers: { 'Retry-After': 'not-a-delay' } });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await fetchClient.get('/organizations', {});

expect(mockSleep).toHaveBeenCalledTimes(1);
expect(mockSleep).toHaveBeenCalledWith(expect.any(Number), null);
});

it('retries when the fetch function rejects with a network error', async () => {
fetch.mockRejectOnce(new TypeError('Failed to fetch'));
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

const response = await fetchClient.get('/organizations', {});

expect(fetch.mock.calls.length).toBe(2);
expect(await response.toJSON()).toEqual({ data: 'response' });
});

it('retries DELETE requests on transient failures', async () => {
fetchOnce({}, { status: 500 });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

const response = await fetchClient.delete('/organizations/org_123', {});

expect(fetch.mock.calls.length).toBe(2);
expect(await response.toJSON()).toEqual({ data: 'response' });
});

it('does not attach an Idempotency-Key to PUT or PATCH requests', async () => {
fetchOnce({}, { status: 500 });
fetchOnce({ data: 'response' });
const mockSleep = jest.spyOn(fetchClient, 'sleep');
mockSleep.mockImplementation(() => Promise.resolve());

await fetchClient.put('/organizations/org_123', { name: 'Test' }, {});

const putHeaders = fetch.mock.calls[0][1]?.headers as Record<
string,
string
>;
expect(putHeaders['Idempotency-Key']).toBeUndefined();

fetch.resetMocks();
fetchOnce({}, { status: 500 });
fetchOnce({ data: 'response' });

await fetchClient.patch('/organizations/org_123', { name: 'Test' }, {});

const patchHeaders = fetch.mock.calls[0][1]?.headers as Record<
string,
string
>;
expect(patchHeaders['Idempotency-Key']).toBeUndefined();
});
});

describe('FetchHttpClient with timeout', () => {
let client: FetchHttpClient;
let mockFetch: jest.Mock;
Expand All @@ -331,7 +568,7 @@ describe('FetchHttpClient with timeout', () => {
mockFetch = jest.fn();
client = new FetchHttpClient(
'https://api.example.com',
{ timeout: 100 },
{ timeout: 100, maxRetries: 0 },
mockFetch,
);
});
Expand Down
Loading