Skip to content

Commit 9fba31b

Browse files
committed
Add 503 retry logic and improve Retry-After handling
Introduces a shared utility for retrying fetch requests on 503 responses with exponential backoff and proper handling of Retry-After headers. Refactors backend and token key fetch logic to use this utility, updates tests for consistent timing, and ensures Retry-After values are parsed as seconds. Adds unit tests for retry utility.
1 parent 63ee32b commit 9fba31b

File tree

8 files changed

+194
-37
lines changed

8 files changed

+194
-37
lines changed

packages/backend/src/api/__tests__/factory.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { http, HttpResponse } from 'msw';
2-
import { describe, expect, it } from 'vitest';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
33

44
import jwksJson from '../../fixtures/jwks.json';
55
import userJson from '../../fixtures/user.json';
66
import { server, validateHeaders } from '../../mock-server';
77
import { createBackendApiClient } from '../factory';
88

99
describe('api.client', () => {
10+
beforeEach(() => {
11+
vi.useFakeTimers();
12+
vi.setSystemTime(new Date(0).getTime()); // set to epoch start for consistent retry-after calculations
13+
});
14+
1015
const apiClient = createBackendApiClient({
1116
apiUrl: 'https://api.clerk.test',
1217
secretKey: 'deadbeef',
@@ -153,7 +158,7 @@ describe('api.client', () => {
153158
expect(errResponse.clerkTraceId).toBe('mock_cf_ray');
154159
});
155160

156-
it('executes a failed backend API request and includes Retry-After header', async () => {
161+
it('executes a failed backend API request and includes Retry-After header for non 503', async () => {
157162
server.use(
158163
http.get(
159164
`https://api.clerk.test/v1/users/user_deadbeef`,
@@ -169,22 +174,22 @@ describe('api.client', () => {
169174
expect(errResponse.retryAfter).toBe(123);
170175
});
171176

172-
it('executes a failed backend API request and includes Retry-After header RFC1123 date', async () => {
177+
it('executes a failed backend API request and includes Retry-After header RFC1123 date for non 503', async () => {
173178
server.use(
174179
http.get(
175180
`https://api.clerk.test/v1/users/user_deadbeef`,
176181
validateHeaders(() => {
177182
return HttpResponse.json(
178183
{ errors: [] },
179-
{ status: 503, headers: { 'retry-after': new Date(new Date().getTime() + 60000).toUTCString() } },
184+
{ status: 429, headers: { 'retry-after': new Date(new Date().getTime() + 60000).toUTCString() } },
180185
);
181186
}),
182187
),
183188
);
184189

185190
const errResponse = await apiClient.users.getUser('user_deadbeef').catch(err => err);
186191

187-
expect(errResponse.status).toBe(503);
192+
expect(errResponse.status).toBe(429);
188193
expect(errResponse.retryAfter).not.toBeNaN();
189194
});
190195

packages/backend/src/api/request.ts

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ClerkAPIResponseError, parseError } from '@clerk/shared/error';
2+
import { getRetryAfterSeconds, with503Retry } from '@clerk/shared/utils';
23
import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/types';
34
import snakecaseKeys from 'snakecase-keys';
45

@@ -154,7 +155,7 @@ export function buildRequest(options: BuildRequestOptions) {
154155
let res: Response | undefined;
155156
try {
156157
if (formData) {
157-
res = await runtime.fetch(finalUrl.href, {
158+
res = await with503Retry(runtime.fetch)(finalUrl.href, {
158159
method,
159160
headers,
160161
body: formData,
@@ -177,7 +178,7 @@ export function buildRequest(options: BuildRequestOptions) {
177178
};
178179
};
179180

180-
res = await runtime.fetch(finalUrl.href, {
181+
res = await with503Retry(runtime.fetch)(finalUrl.href, {
181182
method,
182183
headers,
183184
...buildBody(),
@@ -196,7 +197,7 @@ export function buildRequest(options: BuildRequestOptions) {
196197
status: res?.status,
197198
statusText: res?.statusText,
198199
clerkTraceId: getTraceId(responseBody, res?.headers),
199-
retryAfter: getRetryAfter(res?.headers),
200+
retryAfter: getRetryAfterSeconds(res?.headers),
200201
};
201202
}
202203

@@ -224,7 +225,7 @@ export function buildRequest(options: BuildRequestOptions) {
224225
status: res?.status,
225226
statusText: res?.statusText,
226227
clerkTraceId: getTraceId(err, res?.headers),
227-
retryAfter: getRetryAfter(res?.headers),
228+
retryAfter: getRetryAfterSeconds(res?.headers),
228229
};
229230
}
230231
};
@@ -243,26 +244,6 @@ function getTraceId(data: unknown, headers?: Headers): string {
243244
return cfRay || '';
244245
}
245246

246-
function getRetryAfter(headers?: Headers): number | undefined {
247-
const retryAfter = headers?.get('Retry-After');
248-
if (!retryAfter) {
249-
return;
250-
}
251-
252-
const value = parseInt(retryAfter, 10);
253-
if (!isNaN(value)) {
254-
return value;
255-
}
256-
257-
const date = new Date(retryAfter);
258-
if (!isNaN(date.getTime())) {
259-
const value = date.getTime() - Date.now();
260-
return value > 0 ? value : 0;
261-
}
262-
263-
return;
264-
}
265-
266247
function parseErrors(data: unknown): ClerkAPIError[] {
267248
if (!!data && typeof data === 'object' && 'errors' in data) {
268249
const errors = data.errors as ClerkAPIErrorJSON[];

packages/backend/src/tokens/__tests__/keys.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ describe('tokens.loadClerkJWKFromRemote(options)', () => {
168168
kid: 'ins_whatever',
169169
skipJwksCache: true,
170170
});
171-
void vi.advanceTimersByTimeAsync(10000);
171+
void vi.advanceTimersByTimeAsync(60000);
172172
await promise;
173173
}).rejects.toThrowError('Error loading Clerk JWKS from https://api.clerk.com/v1/jwks with code=503');
174174
});

packages/backend/src/tokens/keys.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { with503Retry } from '@clerk/shared/utils';
2+
13
import {
24
API_URL,
35
API_VERSION,
@@ -185,7 +187,7 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string
185187
const url = new URL(apiUrl);
186188
url.pathname = joinPaths(url.pathname, apiVersion, '/jwks');
187189

188-
const response = await runtime.fetch(url.href, {
190+
const response = await with503Retry(runtime.fetch)(url.href, {
189191
headers: {
190192
Authorization: `Bearer ${key}`,
191193
'Clerk-API-Version': SUPPORTED_BAPI_VERSION,

packages/clerk-js/src/core/resources/Base.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,12 @@ export abstract class BaseResource {
152152
const value = parseInt(retryAfter, 10);
153153
if (!isNaN(value)) {
154154
apiResponseOptions.retryAfter = value;
155-
}
156-
157-
const date = new Date(retryAfter);
158-
if (!isNaN(date.getTime())) {
159-
const value = date.getTime() - Date.now();
160-
apiResponseOptions.retryAfter = value > 0 ? value : 0;
155+
} else {
156+
const date = new Date(retryAfter);
157+
if (!isNaN(date.getTime())) {
158+
const value = Math.ceil((date.getTime() - Date.now()) / 1000);
159+
apiResponseOptions.retryAfter = value > 0 ? value : 0;
160+
}
161161
}
162162
}
163163
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getRetryAfterMs } from '../retry';
4+
5+
describe('api.retry', () => {
6+
it('parses retry-after values correctly', () => {
7+
expect(getRetryAfterMs(new Headers({ 'Retry-After': '120' }))).toBe(120000);
8+
expect(getRetryAfterMs(new Headers({ 'Retry-After': '0' }))).toBe(0);
9+
expect(getRetryAfterMs(new Headers({ 'Retry-After': ' 45 ' }))).toBe(45000);
10+
expect(
11+
getRetryAfterMs(new Headers({ 'Retry-After': new Date(new Date().getTime() + 60000).toUTCString() })),
12+
).toBeGreaterThan(0);
13+
expect(getRetryAfterMs(new Headers({ 'Retry-After': 'Wed, 21 Oct 2000 07:28:00 GMT' }))).toBe(0); // past date
14+
expect(getRetryAfterMs(new Headers({ 'Retry-After': 'invalid-date' }))).toBeUndefined();
15+
expect(getRetryAfterMs(new Headers({}))).toBeUndefined();
16+
expect(getRetryAfterMs(new Headers({ 'Retry-After': '60, 120' }))).toBe(60000); // multiple headers, use first
17+
});
18+
});

packages/shared/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { noop } from './noop';
66
export * from './runtimeEnvironment';
77
export { handleValueOrFn } from './handleValueOrFn';
88
export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge';
9+
export * from './retry';

packages/shared/src/utils/retry.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// maximum number of retries on 503 responses
2+
const MAX_RETRIES = 5;
3+
4+
// base delay in ms for exponential backoff
5+
const BASE_DELAY_MS = 500;
6+
7+
// longest delay we will allow even if more is specified by Retry-After header is 1 minute.
8+
const MAX_DELAY_MS = 60000;
9+
10+
/**
11+
* wraps a fetch function to retry on 503 responses only, passing all other responses through and
12+
* respecting the abort controller signal.
13+
*
14+
* if server provides a Retry-After header, that is respected (within reason), otherwise we use exponential
15+
* backoff based on the number of attempts.
16+
*
17+
* retry is attempted up to MAX_RETRIES times with exponential backoff between 0 and 2^n * BASE_DELAY_MS.
18+
*
19+
*/
20+
export function with503Retry(fetch: typeof globalThis.fetch) {
21+
return async function fetchWithRetry503(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
22+
// want to respect abort signals if provided
23+
const abortSignal = init?.signal;
24+
25+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
26+
// fetch will throw if already aborted
27+
const response = await fetch(input, init);
28+
29+
if (response.status !== 503) {
30+
// If not a 503, return the response immediately
31+
return response;
32+
}
33+
34+
if (attempt >= MAX_RETRIES) {
35+
// return the last response
36+
return response;
37+
}
38+
39+
// if there is a retry after, we'll respect that as the server is explicitly indicating when to retry.
40+
const retryAfterMs = getRetryAfterMs(response.headers);
41+
42+
if (retryAfterMs !== undefined) {
43+
// if the value is 0, we retry immediately as it is explicitly indicated by server. Jitter is not
44+
// applied in this case, as the server is instructing the wait time due to some knowledge it has.
45+
if (retryAfterMs > 0) {
46+
// note that we clamp the maximum delay to avoid excessively long waits, even if server indicates longer
47+
// that could result in a muisconfiguration or error on server side causing request to delay for years
48+
// (or millennia?)
49+
await waitForTimeoutOrCancel(Math.min(retryAfterMs, MAX_DELAY_MS), abortSignal);
50+
}
51+
continue; // Proceed to next attempt
52+
}
53+
54+
// no Retry-After header, so we use exponential backoff.
55+
56+
// Calculate delay
57+
const delay = Math.random() * Math.pow(2, attempt) * BASE_DELAY_MS;
58+
59+
// Wait for the delay before retrying, but abort if signal is triggered
60+
await waitForTimeoutOrCancel(delay, abortSignal);
61+
62+
// Proceed to next attempt
63+
}
64+
65+
// This point should never be reached
66+
throw new Error('Unexpected error in fetchWithRetry503');
67+
};
68+
}
69+
70+
/**
71+
* Helper function to wait for a timeout or abort with Aborted if signal is triggered
72+
*/
73+
async function waitForTimeoutOrCancel(delay: number, signal: AbortSignal | null | undefined): Promise<void> {
74+
if (!signal) {
75+
return new Promise(resolve => setTimeout(resolve, delay));
76+
}
77+
return await new Promise((resolve, reject) => {
78+
const onAbort = () => {
79+
signal.removeEventListener('abort', onAbort);
80+
clearTimeout(timeoutId); // timeoutId is defined, hoisting.
81+
// Reject the promise if aborted using standard DOMException for AbortError
82+
reject(new DOMException('Aborted', 'AbortError'));
83+
};
84+
signal.addEventListener('abort', onAbort);
85+
const timeoutId = setTimeout(() => {
86+
signal.removeEventListener('abort', onAbort);
87+
resolve();
88+
}, delay);
89+
});
90+
}
91+
92+
/**
93+
* either returns number of milliseconds to wait as instructed by the server, or undefined
94+
* if no valid Retry-After header is present.
95+
*
96+
* note that 0 is a valid retry-after value, and explicitly indicates no wait and that the
97+
* client should retry immediately.
98+
*
99+
* Handles both delta-seconds and HTTP-date formats, returning the number of milliseconds to
100+
* wait from now if HTTP-date is provided. If it is in the past, returns 0.
101+
*
102+
* does not clamp the upper bound value, that must be handled by the caller.
103+
*
104+
*/
105+
export function getRetryAfterMs(headers?: Headers): number | undefined {
106+
const retryAfter = headers?.get('Retry-After');
107+
if (!retryAfter) {
108+
return;
109+
}
110+
111+
const intValue = parseInt(retryAfter, 10);
112+
if (!isNaN(intValue)) {
113+
if (intValue < 0) {
114+
// invalid, treat as no header present
115+
return;
116+
} else if (intValue === 0) {
117+
// explicit immediate retry
118+
return 0;
119+
}
120+
return Math.ceil(intValue) * 1000; // return whole integers as milliseconds only.
121+
}
122+
123+
// reminder: https://jsdate.wtf/
124+
const date = new Date(retryAfter);
125+
if (!isNaN(date.getTime())) {
126+
const value = Math.ceil(date.getTime() - Date.now());
127+
if (value < 0) {
128+
// date is in the past, so we return 0
129+
return 0;
130+
}
131+
return value;
132+
}
133+
134+
// otherwise the date was invalid so we treat as no header present
135+
return;
136+
}
137+
138+
/**
139+
* returns number of full seconds to wait as instructed by the server, or undefined
140+
* if no valid Retry-After header is present.
141+
*
142+
* @see getRetryAfterMs
143+
*/
144+
export function getRetryAfterSeconds(headers?: Headers): number | undefined {
145+
const ms = getRetryAfterMs(headers);
146+
if (ms === undefined) {
147+
return;
148+
}
149+
return Math.ceil(ms / 1000);
150+
}

0 commit comments

Comments
 (0)