From 5bf3599c043fd0d99967319c0cfa6b829e5117eb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:55:37 +0000 Subject: [PATCH 1/4] Add configurable automatic retries to the HTTP client Co-Authored-By: adam --- .../interfaces/get-options.interface.ts | 2 + .../interfaces/http-client.interface.ts | 5 + .../interfaces/patch-options.interface.ts | 2 + .../interfaces/post-options.interface.ts | 2 + .../interfaces/put-options.interface.ts | 2 + .../interfaces/workos-options.interface.ts | 7 + src/common/net/fetch-client.spec.ts | 112 +++++++- src/common/net/fetch-client.ts | 256 ++++++++++-------- src/common/net/http-client.ts | 80 +++++- src/index.ts | 1 + src/index.worker.ts | 1 + src/workos.spec.ts | 10 +- src/workos.ts | 5 + 13 files changed, 354 insertions(+), 131 deletions(-) diff --git a/src/common/interfaces/get-options.interface.ts b/src/common/interfaces/get-options.interface.ts index 93c7fd1d5..494fba798 100644 --- a/src/common/interfaces/get-options.interface.ts +++ b/src/common/interfaces/get-options.interface.ts @@ -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; } diff --git a/src/common/interfaces/http-client.interface.ts b/src/common/interfaces/http-client.interface.ts index b90b0fb7b..be6d7decd 100644 --- a/src/common/interfaces/http-client.interface.ts +++ b/src/common/interfaces/http-client.interface.ts @@ -2,6 +2,11 @@ export type RequestHeaders = Record; export type RequestOptions = { params?: Record; 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; diff --git a/src/common/interfaces/patch-options.interface.ts b/src/common/interfaces/patch-options.interface.ts index fe235ad12..8240a15aa 100644 --- a/src/common/interfaces/patch-options.interface.ts +++ b/src/common/interfaces/patch-options.interface.ts @@ -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; } diff --git a/src/common/interfaces/post-options.interface.ts b/src/common/interfaces/post-options.interface.ts index 7c9120c86..eb38f2986 100644 --- a/src/common/interfaces/post-options.interface.ts +++ b/src/common/interfaces/post-options.interface.ts @@ -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; } diff --git a/src/common/interfaces/put-options.interface.ts b/src/common/interfaces/put-options.interface.ts index 1ac3c5863..8c225700a 100644 --- a/src/common/interfaces/put-options.interface.ts +++ b/src/common/interfaces/put-options.interface.ts @@ -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; } diff --git a/src/common/interfaces/workos-options.interface.ts b/src/common/interfaces/workos-options.interface.ts index 1bc8ad035..f86fde70e 100644 --- a/src/common/interfaces/workos-options.interface.ts +++ b/src/common/interfaces/workos-options.interface.ts @@ -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. Defaults to 3. Set to `0` to + * disable automatic retries. + */ + maxRetries?: number; } diff --git a/src/common/net/fetch-client.spec.ts b/src/common/net/fetch-client.spec.ts index ecab6dabc..caa1bd72c 100644 --- a/src/common/net/fetch-client.spec.ts +++ b/src/common/net/fetch-client.spec.ts @@ -323,6 +323,116 @@ 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('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); + }); +}); + describe('FetchHttpClient with timeout', () => { let client: FetchHttpClient; let mockFetch: jest.Mock; @@ -331,7 +441,7 @@ describe('FetchHttpClient with timeout', () => { mockFetch = jest.fn(); client = new FetchHttpClient( 'https://api.example.com', - { timeout: 100 }, + { timeout: 100, maxRetries: 0 }, mockFetch, ); }); diff --git a/src/common/net/fetch-client.ts b/src/common/net/fetch-client.ts index 0ed0c12fc..2ef2a1159 100644 --- a/src/common/net/fetch-client.ts +++ b/src/common/net/fetch-client.ts @@ -6,12 +6,15 @@ import { RequestOptions, ResponseHeaders, } from '../interfaces/http-client.interface'; -import { HttpClient, HttpClientError, HttpClientResponse } from './http-client'; +import { + HttpClient, + HttpClientError, + HttpClientOptions, + HttpClientResponse, +} from './http-client'; import { ParseError } from '../exceptions/parse-error'; -interface FetchHttpClientOptions extends RequestInit { - timeout?: number; -} +type FetchHttpClientOptions = HttpClientOptions; const DEFAULT_FETCH_TIMEOUT = 60_000; // 60 seconds export class FetchHttpClient extends HttpClient implements HttpClientInterface { @@ -47,16 +50,13 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { options.params, ); - if (HttpClient.isPathRetryable(path)) { - return await this.fetchRequestWithRetry( - resourceURL, - 'GET', - null, - options.headers, - ); - } else { - return await this.fetchRequest(resourceURL, 'GET', null, options.headers); - } + return await this.fetchRequestWithRetry( + resourceURL, + 'GET', + null, + options.headers, + options.maxRetries, + ); } async post( @@ -70,27 +70,16 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { options.params, ); - if (HttpClient.isPathRetryable(path)) { - return await this.fetchRequestWithRetry( - resourceURL, - 'POST', - HttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } else { - return await this.fetchRequest( - resourceURL, - 'POST', - HttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } + return await this.fetchRequestWithRetry( + resourceURL, + 'POST', + HttpClient.getBody(entity), + { + ...HttpClient.getContentTypeHeader(entity), + ...options.headers, + }, + options.maxRetries, + ); } async put( @@ -104,27 +93,16 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { options.params, ); - if (HttpClient.isPathRetryable(path)) { - return await this.fetchRequestWithRetry( - resourceURL, - 'PUT', - HttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } else { - return await this.fetchRequest( - resourceURL, - 'PUT', - HttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } + return await this.fetchRequestWithRetry( + resourceURL, + 'PUT', + HttpClient.getBody(entity), + { + ...HttpClient.getContentTypeHeader(entity), + ...options.headers, + }, + options.maxRetries, + ); } async patch( @@ -138,27 +116,16 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { options.params, ); - if (HttpClient.isPathRetryable(path)) { - return await this.fetchRequestWithRetry( - resourceURL, - 'PATCH', - HttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } else { - return await this.fetchRequest( - resourceURL, - 'PATCH', - HttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } + return await this.fetchRequestWithRetry( + resourceURL, + 'PATCH', + HttpClient.getBody(entity), + { + ...HttpClient.getContentTypeHeader(entity), + ...options.headers, + }, + options.maxRetries, + ); } async delete( @@ -171,21 +138,13 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { options.params, ); - if (HttpClient.isPathRetryable(path)) { - return await this.fetchRequestWithRetry( - resourceURL, - 'DELETE', - null, - options.headers, - ); - } else { - return await this.fetchRequest( - resourceURL, - 'DELETE', - null, - options.headers, - ); - } + return await this.fetchRequestWithRetry( + resourceURL, + 'DELETE', + null, + options.headers, + options.maxRetries, + ); } async deleteWithBody( @@ -199,27 +158,16 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { options.params, ); - if (HttpClient.isPathRetryable(path)) { - return await this.fetchRequestWithRetry( - resourceURL, - 'DELETE', - HttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } else { - return await this.fetchRequest( - resourceURL, - 'DELETE', - HttpClient.getBody(entity), - { - ...HttpClient.getContentTypeHeader(entity), - ...options.headers, - }, - ); - } + return await this.fetchRequestWithRetry( + resourceURL, + 'DELETE', + HttpClient.getBody(entity), + { + ...HttpClient.getContentTypeHeader(entity), + ...options.headers, + }, + options.maxRetries, + ); } private async fetchRequest( @@ -322,7 +270,19 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { method: string, body?: any, headers?: RequestHeaders, + maxRetries?: number, ): Promise { + const maxRetryAttempts = maxRetries ?? this.MAX_RETRY_ATTEMPTS; + + // Attach an idempotency key to retryable write requests that don't have + // one so retried POST/PUT/PATCH calls are not applied more than once by + // the API. Generated once so every attempt shares the same key. + const requestHeaders = FetchHttpClient.withIdempotencyKey( + method, + headers, + maxRetryAttempts, + ); + let response: HttpClientResponseInterface; let retryAttempts = 1; @@ -330,14 +290,19 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { let requestError: any = null; try { - response = await this.fetchRequest(url, method, body, headers); + response = await this.fetchRequest(url, method, body, requestHeaders); } catch (e) { requestError = e; } - if (this.shouldRetryRequest(requestError, retryAttempts)) { + if ( + this.shouldRetryRequest(requestError, retryAttempts, maxRetryAttempts) + ) { retryAttempts++; - await this.sleep(retryAttempts); + await this.sleep( + retryAttempts, + FetchHttpClient.getRetryAfterMs(requestError), + ); return makeRequest(); } @@ -351,8 +316,12 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { return makeRequest(); } - private shouldRetryRequest(requestError: any, retryAttempt: number): boolean { - if (retryAttempt > this.MAX_RETRY_ATTEMPTS) { + private shouldRetryRequest( + requestError: any, + retryAttempt: number, + maxRetryAttempts: number, + ): boolean { + if (retryAttempt > maxRetryAttempts) { return false; } @@ -371,6 +340,57 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { return false; } + + private static withIdempotencyKey( + method: string, + headers: RequestHeaders | undefined, + maxRetryAttempts: number, + ): RequestHeaders | undefined { + const isWriteMethod = + method === 'POST' || method === 'PUT' || method === 'PATCH'; + + if ( + !isWriteMethod || + maxRetryAttempts <= 0 || + FetchHttpClient.hasHeader(headers, 'Idempotency-Key') + ) { + return headers; + } + + return { + ...headers, + 'Idempotency-Key': HttpClient.generateIdempotencyKey(), + }; + } + + private static hasHeader( + headers: RequestHeaders | undefined, + name: string, + ): boolean { + if (!headers) { + return false; + } + + const target = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === target); + } + + private static getRetryAfterMs(requestError: any): number | null { + if (!(requestError instanceof HttpClientError)) { + return null; + } + + const headers = requestError.response?.headers; + let value: string | null | undefined; + + if (headers && typeof headers.get === 'function') { + value = headers.get('Retry-After'); + } else if (headers && typeof headers === 'object') { + value = headers['Retry-After'] ?? headers['retry-after']; + } + + return HttpClient.parseRetryAfter(value); + } } // tslint:disable-next-line diff --git a/src/common/net/http-client.ts b/src/common/net/http-client.ts index 9efad4b87..1b7aed8a0 100644 --- a/src/common/net/http-client.ts +++ b/src/common/net/http-client.ts @@ -7,16 +7,31 @@ import { ResponseHeaders, } from '../interfaces/http-client.interface'; +export interface HttpClientOptions extends RequestInit { + /** Per-request timeout in milliseconds. */ + timeout?: number; + /** + * Maximum number of retries for transient failures. Set to `0` to disable + * automatic retries entirely. Defaults to {@link DEFAULT_MAX_RETRY_ATTEMPTS}. + */ + maxRetries?: number; +} + +export const DEFAULT_MAX_RETRY_ATTEMPTS = 3; + export abstract class HttpClient implements HttpClientInterface { - readonly MAX_RETRY_ATTEMPTS = 3; + readonly MAX_RETRY_ATTEMPTS: number; readonly BACKOFF_MULTIPLIER = 1.5; readonly MINIMUM_SLEEP_TIME_IN_MILLISECONDS = 500; - readonly RETRY_STATUS_CODES = [408, 500, 502, 504]; + readonly MAXIMUM_SLEEP_TIME_IN_MILLISECONDS = 8_000; + readonly RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504]; constructor( readonly baseURL: string, - readonly options?: RequestInit, - ) {} + readonly options?: HttpClientOptions, + ) { + this.MAX_RETRY_ATTEMPTS = options?.maxRetries ?? DEFAULT_MAX_RETRY_ATTEMPTS; + } abstract get( path: string, @@ -93,21 +108,66 @@ export abstract class HttpClient implements HttpClientInterface { return JSON.stringify(entity); } - static isPathRetryable(path: string): boolean { - return path.startsWith('/vault/') || path.startsWith('/audit_logs/events'); + /** + * Generate a random idempotency key used to make retried write requests + * safe. Mirrors the behavior of the other WorkOS SDKs (Kotlin, Go, Ruby), + * which attach an `Idempotency-Key` header to retried POST/PUT/PATCH + * requests that did not already specify one. + */ + static generateIdempotencyKey(): string { + return `retry-${globalThis.crypto.randomUUID()}`; + } + + /** + * Parse a `Retry-After` header value into milliseconds. Supports both the + * delay-seconds form (e.g. `120`) and the HTTP-date form. Returns `null` + * when the value is absent or unparseable so the caller falls back to the + * computed exponential backoff. + */ + static parseRetryAfter( + headerValue: string | null | undefined, + ): number | null { + if (headerValue == null) { + return null; + } + + const trimmed = headerValue.trim(); + if (trimmed === '') { + return null; + } + + const asSeconds = Number(trimmed); + if (!Number.isNaN(asSeconds)) { + return asSeconds < 0 ? 0 : asSeconds * 1000; + } + + const asDate = Date.parse(trimmed); + if (!Number.isNaN(asDate)) { + const delta = asDate - Date.now(); + return delta < 0 ? 0 : delta; + } + + return null; } private getSleepTimeInMilliseconds(retryAttempt: number): number { - const sleepTime = + const sleepTime = Math.min( this.MINIMUM_SLEEP_TIME_IN_MILLISECONDS * - Math.pow(this.BACKOFF_MULTIPLIER, retryAttempt); + Math.pow(this.BACKOFF_MULTIPLIER, retryAttempt), + this.MAXIMUM_SLEEP_TIME_IN_MILLISECONDS, + ); const jitter = Math.random() + 0.5; return sleepTime * jitter; } - sleep = (retryAttempt: number) => + sleep = (retryAttempt: number, retryAfterMs?: number | null) => new Promise((resolve) => - setTimeout(resolve, this.getSleepTimeInMilliseconds(retryAttempt)), + setTimeout( + resolve, + retryAfterMs != null + ? retryAfterMs + : this.getSleepTimeInMilliseconds(retryAttempt), + ), ); } diff --git a/src/index.ts b/src/index.ts index d3e4bb370..f00652919 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ class WorkOSNode extends WorkOS { const opts = { ...options.config, timeout: options.timeout, + maxRetries: options.maxRetries, headers, }; diff --git a/src/index.worker.ts b/src/index.worker.ts index 08f482cf5..f1b0b4bbb 100644 --- a/src/index.worker.ts +++ b/src/index.worker.ts @@ -56,6 +56,7 @@ class WorkOSWorker extends WorkOS { return new FetchHttpClient(this.baseURL, { ...options.config, + maxRetries: options.maxRetries, headers, }); } diff --git a/src/workos.spec.ts b/src/workos.spec.ts index 6b75f9219..6b62cda6f 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -326,7 +326,10 @@ describe('WorkOS', () => { }, ); - const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + const workos = new WorkOS({ + apiKey: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + maxRetries: 0, + }); await expect(workos.post('/path', {})).rejects.toStrictEqual( new GenericServerException(500, undefined, {}, 'a-request-id'), @@ -448,7 +451,10 @@ describe('WorkOS', () => { }, ); - const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + const workos = new WorkOS({ + apiKey: 'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU', + maxRetries: 0, + }); await expect(workos.get('/path')).rejects.toStrictEqual( new RateLimitExceededException( diff --git a/src/workos.ts b/src/workos.ts index 6bceec035..82ce3ba30 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -205,6 +205,7 @@ export class WorkOS { return new FetchHttpClient(this.baseURL, { ...options.config, timeout: options.timeout, + maxRetries: options.maxRetries, headers, }) as HttpClient; } @@ -249,6 +250,7 @@ export class WorkOS { res = await this.client.post(path, entity, { params: options.query, headers: requestHeaders, + maxRetries: options.maxRetries, }); } catch (error) { this.handleHttpError({ path, error }); @@ -281,6 +283,7 @@ export class WorkOS { res = await this.client.get(path, { params: options.query, headers: requestHeaders, + maxRetries: options.maxRetries, }); } catch (error) { this.handleHttpError({ path, error }); @@ -312,6 +315,7 @@ export class WorkOS { res = await this.client.put(path, entity, { params: options.query, headers: requestHeaders, + maxRetries: options.maxRetries, }); } catch (error) { this.handleHttpError({ path, error }); @@ -343,6 +347,7 @@ export class WorkOS { res = await this.client.patch(path, entity, { params: options.query, headers: requestHeaders, + maxRetries: options.maxRetries, }); } catch (error) { this.handleHttpError({ path, error }); From 2050d02e4614529da40eed2e715651f0fb58f68e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:02:20 +0000 Subject: [PATCH 2/4] Retry retryable statuses with non-JSON error bodies Co-Authored-By: adam --- src/common/net/fetch-client.spec.ts | 21 +++++++++++++++++++-- src/common/net/fetch-client.ts | 10 ++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/common/net/fetch-client.spec.ts b/src/common/net/fetch-client.spec.ts index caa1bd72c..ba2fd9483 100644 --- a/src/common/net/fetch-client.spec.ts +++ b/src/common/net/fetch-client.spec.ts @@ -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; @@ -408,6 +410,21 @@ describe('automatic retries', () => { expect(secondHeaders['Idempotency-Key']).toBe('user-key'); }); + it('retries a retryable status with a non-JSON error body', async () => { + fetch.mockResponseOnce('503 Service Unavailable', { + 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('does not retry when maxRetries is 0', async () => { const client = new FetchHttpClient('https://test.workos.com', { maxRetries: 0, diff --git a/src/common/net/fetch-client.ts b/src/common/net/fetch-client.ts index 2ef2a1159..8144e9300 100644 --- a/src/common/net/fetch-client.ts +++ b/src/common/net/fetch-client.ts @@ -336,6 +336,16 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { ) { return true; } + + // A retryable status can arrive with a non-JSON body (e.g. an HTML error + // page from a proxy), which `fetchRequest` surfaces as a `ParseError`. + // Retry those based on the underlying status. + if ( + requestError instanceof ParseError && + this.RETRY_STATUS_CODES.includes(requestError.rawStatus) + ) { + return true; + } } return false; From 9c71ffac5d7f7a315e224f9f2bf9fa914b41c6df Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:07:24 +0000 Subject: [PATCH 3/4] Honor Retry-After for retryable non-JSON responses Co-Authored-By: adam --- src/common/exceptions/parse-error.ts | 4 ++++ src/common/net/fetch-client.spec.ts | 15 +++++++++++++++ src/common/net/fetch-client.ts | 10 ++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/common/exceptions/parse-error.ts b/src/common/exceptions/parse-error.ts index 09e7c249e..1a4856097 100644 --- a/src/common/exceptions/parse-error.ts +++ b/src/common/exceptions/parse-error.ts @@ -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; } } diff --git a/src/common/net/fetch-client.spec.ts b/src/common/net/fetch-client.spec.ts index ba2fd9483..28e5fc8f9 100644 --- a/src/common/net/fetch-client.spec.ts +++ b/src/common/net/fetch-client.spec.ts @@ -425,6 +425,21 @@ describe('automatic retries', () => { expect(await response.toJSON()).toEqual({ data: 'response' }); }); + it('honors Retry-After on a retryable non-JSON error body', async () => { + fetch.mockResponseOnce('503 Service Unavailable', { + 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, diff --git a/src/common/net/fetch-client.ts b/src/common/net/fetch-client.ts index 8144e9300..a2371f254 100644 --- a/src/common/net/fetch-client.ts +++ b/src/common/net/fetch-client.ts @@ -228,6 +228,7 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { rawBody, requestID, rawStatus: res.status, + rawHeaders: res.headers, }); } throw error; @@ -386,11 +387,16 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { } private static getRetryAfterMs(requestError: any): number | null { - if (!(requestError instanceof HttpClientError)) { + let headers: any; + + if (requestError instanceof HttpClientError) { + headers = requestError.response?.headers; + } else if (requestError instanceof ParseError) { + headers = requestError.rawHeaders; + } else { return null; } - const headers = requestError.response?.headers; let value: string | null | undefined; if (headers && typeof headers.get === 'function') { From ed7282407a4314726aa59adf79425ea1e3adad50 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 16 Jul 2026 14:04:44 -0700 Subject: [PATCH 4/4] fix(retries): scope auto idempotency keys to POST and cap Retry-After - Restrict automatic Idempotency-Key injection to POST requests, matching the Kotlin and Go SDKs and the API, which honors the header on create (POST) endpoints. PUT/PATCH no longer get an auto-generated key. - Cap server-provided Retry-After delays at 60 seconds so a large delay-seconds value or far-future HTTP-date can't hang callers. - Parse Retry-After delay-seconds strictly per RFC 9110, rejecting exotic numeric forms like Infinity, hex, and exponent notation. - Document automatic retries and maxRetries configuration in the README. - Add tests: HTTP-date Retry-After, 60s cap, unparseable-value fallback, network-error retry, DELETE retry, and no auto key on PUT/PATCH. Co-Authored-By: Claude Fable 5 --- README.md | 23 +++++ .../interfaces/workos-options.interface.ts | 4 +- src/common/net/fetch-client.spec.ts | 95 +++++++++++++++++++ src/common/net/fetch-client.ts | 13 ++- src/common/net/http-client.ts | 35 +++++-- 5 files changed, 151 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index bdb561702..8202d02d1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/common/interfaces/workos-options.interface.ts b/src/common/interfaces/workos-options.interface.ts index f86fde70e..a28927119 100644 --- a/src/common/interfaces/workos-options.interface.ts +++ b/src/common/interfaces/workos-options.interface.ts @@ -13,8 +13,8 @@ export interface WorkOSOptions { /** * 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. Defaults to 3. Set to `0` to - * disable automatic retries. + * jitter and honor the `Retry-After` header (capped at 60 seconds). + * Defaults to 3. Set to `0` to disable automatic retries. */ maxRetries?: number; } diff --git a/src/common/net/fetch-client.spec.ts b/src/common/net/fetch-client.spec.ts index 28e5fc8f9..d318f26a2 100644 --- a/src/common/net/fetch-client.spec.ts +++ b/src/common/net/fetch-client.spec.ts @@ -463,6 +463,101 @@ describe('automatic retries', () => { 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', () => { diff --git a/src/common/net/fetch-client.ts b/src/common/net/fetch-client.ts index a2371f254..32cc18987 100644 --- a/src/common/net/fetch-client.ts +++ b/src/common/net/fetch-client.ts @@ -275,9 +275,11 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { ): Promise { const maxRetryAttempts = maxRetries ?? this.MAX_RETRY_ATTEMPTS; - // Attach an idempotency key to retryable write requests that don't have - // one so retried POST/PUT/PATCH calls are not applied more than once by - // the API. Generated once so every attempt shares the same key. + // Attach an idempotency key to retryable POST requests that don't have + // one so retried calls are not applied more than once by the API. + // POST-only to match the Kotlin and Go SDKs and the API, which honors + // `Idempotency-Key` on create (POST) endpoints. Generated once so every + // attempt shares the same key. const requestHeaders = FetchHttpClient.withIdempotencyKey( method, headers, @@ -357,11 +359,8 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { headers: RequestHeaders | undefined, maxRetryAttempts: number, ): RequestHeaders | undefined { - const isWriteMethod = - method === 'POST' || method === 'PUT' || method === 'PATCH'; - if ( - !isWriteMethod || + method !== 'POST' || maxRetryAttempts <= 0 || FetchHttpClient.hasHeader(headers, 'Idempotency-Key') ) { diff --git a/src/common/net/http-client.ts b/src/common/net/http-client.ts index 1b7aed8a0..1de6ea3d9 100644 --- a/src/common/net/http-client.ts +++ b/src/common/net/http-client.ts @@ -19,6 +19,13 @@ export interface HttpClientOptions extends RequestInit { export const DEFAULT_MAX_RETRY_ATTEMPTS = 3; +/** + * Upper bound on a server-provided `Retry-After` delay. Caps how long a + * single retry can sleep so an aggressive proxy or an HTTP-date far in the + * future can't hang the caller indefinitely. + */ +export const MAXIMUM_RETRY_AFTER_TIME_IN_MILLISECONDS = 60_000; + export abstract class HttpClient implements HttpClientInterface { readonly MAX_RETRY_ATTEMPTS: number; readonly BACKOFF_MULTIPLIER = 1.5; @@ -110,9 +117,9 @@ export abstract class HttpClient implements HttpClientInterface { /** * Generate a random idempotency key used to make retried write requests - * safe. Mirrors the behavior of the other WorkOS SDKs (Kotlin, Go, Ruby), - * which attach an `Idempotency-Key` header to retried POST/PUT/PATCH - * requests that did not already specify one. + * safe. Mirrors the behavior of the other WorkOS SDKs (Kotlin, Go), which + * attach an `Idempotency-Key` header to POST requests that did not already + * specify one, so a retried request is not applied more than once. */ static generateIdempotencyKey(): string { return `retry-${globalThis.crypto.randomUUID()}`; @@ -120,9 +127,10 @@ export abstract class HttpClient implements HttpClientInterface { /** * Parse a `Retry-After` header value into milliseconds. Supports both the - * delay-seconds form (e.g. `120`) and the HTTP-date form. Returns `null` - * when the value is absent or unparseable so the caller falls back to the - * computed exponential backoff. + * delay-seconds form (e.g. `120`) and the HTTP-date form. The result is + * capped at {@link MAXIMUM_RETRY_AFTER_TIME_IN_MILLISECONDS}. Returns + * `null` when the value is absent or unparseable so the caller falls back + * to the computed exponential backoff. */ static parseRetryAfter( headerValue: string | null | undefined, @@ -136,15 +144,22 @@ export abstract class HttpClient implements HttpClientInterface { return null; } - const asSeconds = Number(trimmed); - if (!Number.isNaN(asSeconds)) { - return asSeconds < 0 ? 0 : asSeconds * 1000; + // RFC 9110 delay-seconds: a non-negative decimal integer. Using a strict + // pattern instead of Number() avoids honoring exotic forms like + // `Infinity`, hex, or exponent notation. + if (/^\d+$/.test(trimmed)) { + return Math.min( + Number(trimmed) * 1000, + MAXIMUM_RETRY_AFTER_TIME_IN_MILLISECONDS, + ); } const asDate = Date.parse(trimmed); if (!Number.isNaN(asDate)) { const delta = asDate - Date.now(); - return delta < 0 ? 0 : delta; + return delta < 0 + ? 0 + : Math.min(delta, MAXIMUM_RETRY_AFTER_TIME_IN_MILLISECONDS); } return null;