From 63ee32b6a31455ec9944113b26e43a1d06112257 Mon Sep 17 00:00:00 2001 From: Henry Snopek Date: Mon, 24 Nov 2025 22:28:53 -0600 Subject: [PATCH] feat: retry on 503, add Retry-After date (RFC1123) support --- .../backend/src/api/__tests__/factory.test.ts | 19 +++++++++++++++++ packages/backend/src/api/request.ts | 12 ++++++++--- packages/clerk-js/src/core/resources/Base.ts | 8 ++++++- .../src/core/resources/__tests__/Base.test.ts | 21 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/api/__tests__/factory.test.ts b/packages/backend/src/api/__tests__/factory.test.ts index e3e0f697461..12878fe982b 100644 --- a/packages/backend/src/api/__tests__/factory.test.ts +++ b/packages/backend/src/api/__tests__/factory.test.ts @@ -169,6 +169,25 @@ describe('api.client', () => { expect(errResponse.retryAfter).toBe(123); }); + it('executes a failed backend API request and includes Retry-After header RFC1123 date', async () => { + server.use( + http.get( + `https://api.clerk.test/v1/users/user_deadbeef`, + validateHeaders(() => { + return HttpResponse.json( + { errors: [] }, + { status: 503, headers: { 'retry-after': new Date(new Date().getTime() + 60000).toUTCString() } }, + ); + }), + ), + ); + + const errResponse = await apiClient.users.getUser('user_deadbeef').catch(err => err); + + expect(errResponse.status).toBe(503); + expect(errResponse.retryAfter).not.toBeNaN(); + }); + it('executes a failed backend API request and ignores invalid Retry-After header', async () => { server.use( http.get( diff --git a/packages/backend/src/api/request.ts b/packages/backend/src/api/request.ts index c9d321f505e..014e44c786c 100644 --- a/packages/backend/src/api/request.ts +++ b/packages/backend/src/api/request.ts @@ -250,11 +250,17 @@ function getRetryAfter(headers?: Headers): number | undefined { } const value = parseInt(retryAfter, 10); - if (isNaN(value)) { - return; + if (!isNaN(value)) { + return value; + } + + const date = new Date(retryAfter); + if (!isNaN(date.getTime())) { + const value = date.getTime() - Date.now(); + return value > 0 ? value : 0; } - return value; + return; } function parseErrors(data: unknown): ClerkAPIError[] { diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index cc1b5db9819..228faccd91a 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -146,13 +146,19 @@ export abstract class BaseResource { assertProductionKeysOnDev(status, errors); const apiResponseOptions: ConstructorParameters[1] = { data: errors, status }; - if (status === 429 && headers) { + if ((status === 429 || status == 503) && headers) { const retryAfter = headers.get('retry-after'); if (retryAfter) { const value = parseInt(retryAfter, 10); if (!isNaN(value)) { apiResponseOptions.retryAfter = value; } + + const date = new Date(retryAfter); + if (!isNaN(date.getTime())) { + const value = date.getTime() - Date.now(); + apiResponseOptions.retryAfter = value > 0 ? value : 0; + } } } diff --git a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts index 0eb8022b2a2..526e7ebc771 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Base.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Base.test.ts @@ -38,6 +38,27 @@ describe('BaseResource', () => { expect(errResponse.retryAfter).toBe(60); }); + it('populates retryAfter on 503 error responses', async () => { + BaseResource.clerk = { + // @ts-expect-error - We're not about to mock the entire FapiClient + getFapiClient: () => { + return { + request: vi.fn().mockResolvedValue({ + payload: {}, + status: 503, + statusText: 'Service Unavailable', + headers: new Headers({ 'Retry-After': new Date(new Date().getTime() + 60000).toUTCString() }), + }), + }; + }, + __internal_setCountry: vi.fn(), + }; + const resource = new TestResource(); + const errResponse = await resource.fetch().catch(err => err); + console.dir(errResponse); + expect(errResponse.retryAfter).toBe(60); + }); + it('does not populate retryAfter on invalid header', async () => { BaseResource.clerk = { // @ts-expect-error - We're not about to mock the entire FapiClient