Skip to content

Commit 60a9a51

Browse files
authored
feat(clerk-js,backend,shared): Expose retryAfter header on ClerkAPIResponseError (#5480)
1 parent 743f1f1 commit 60a9a51

File tree

7 files changed

+130
-7
lines changed

7 files changed

+130
-7
lines changed

.changeset/fair-ants-invent.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/backend': minor
4+
'@clerk/shared': minor
5+
---
6+
7+
Expose `retryAfter` value on `ClerkAPIResponseError` for 429 responses.

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

+32
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,38 @@ describe('api.client', () => {
152152
expect(errResponse.clerkTraceId).toBe('mock_cf_ray');
153153
});
154154

155+
it('executes a failed backend API request and includes Retry-After header', async () => {
156+
server.use(
157+
http.get(
158+
`https://api.clerk.test/v1/users/user_deadbeef`,
159+
validateHeaders(() => {
160+
return HttpResponse.json({ errors: [] }, { status: 429, headers: { 'retry-after': '123' } });
161+
}),
162+
),
163+
);
164+
165+
const errResponse = await apiClient.users.getUser('user_deadbeef').catch(err => err);
166+
167+
expect(errResponse.status).toBe(429);
168+
expect(errResponse.retryAfter).toBe(123);
169+
});
170+
171+
it('executes a failed backend API request and ignores invalid Retry-After header', async () => {
172+
server.use(
173+
http.get(
174+
`https://api.clerk.test/v1/users/user_deadbeef`,
175+
validateHeaders(() => {
176+
return HttpResponse.json({ errors: [] }, { status: 429, headers: { 'retry-after': 'abc' } });
177+
}),
178+
),
179+
);
180+
181+
const errResponse = await apiClient.users.getUser('user_deadbeef').catch(err => err);
182+
183+
expect(errResponse.status).toBe(429);
184+
expect(errResponse.retryAfter).toBe(undefined);
185+
});
186+
155187
it('executes a successful backend API request to delete a domain', async () => {
156188
const DOMAIN_ID = 'dmn_123';
157189
server.use(

packages/backend/src/api/request.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type ClerkBackendApiResponse<T> =
3838
clerkTraceId?: string;
3939
status?: number;
4040
statusText?: string;
41+
retryAfter?: number;
4142
};
4243

4344
export type RequestFunction = ReturnType<typeof buildRequest>;
@@ -135,6 +136,7 @@ export function buildRequest(options: BuildRequestOptions) {
135136
status: res?.status,
136137
statusText: res?.statusText,
137138
clerkTraceId: getTraceId(responseBody, res?.headers),
139+
retryAfter: getRetryAfter(res?.headers),
138140
};
139141
}
140142

@@ -162,6 +164,7 @@ export function buildRequest(options: BuildRequestOptions) {
162164
status: res?.status,
163165
statusText: res?.statusText,
164166
clerkTraceId: getTraceId(err, res?.headers),
167+
retryAfter: getRetryAfter(res?.headers),
165168
};
166169
}
167170
};
@@ -180,6 +183,16 @@ function getTraceId(data: unknown, headers?: Headers): string {
180183
return cfRay || '';
181184
}
182185

186+
function getRetryAfter(headers?: Headers): number | undefined {
187+
const retryAfter = headers?.get('Retry-After');
188+
if (!retryAfter) return;
189+
190+
const value = parseInt(retryAfter, 10);
191+
if (isNaN(value)) return;
192+
193+
return value;
194+
}
195+
183196
function parseErrors(data: unknown): ClerkAPIError[] {
184197
if (!!data && typeof data === 'object' && 'errors' in data) {
185198
const errors = data.errors as ClerkAPIErrorJSON[];
@@ -193,7 +206,7 @@ type LegacyRequestFunction = <T>(requestOptions: ClerkBackendApiRequestOptions)
193206
// TODO(dimkl): Will be probably be dropped in next major version
194207
function withLegacyRequestReturn(cb: any): LegacyRequestFunction {
195208
return async (...args) => {
196-
const { data, errors, totalCount, status, statusText, clerkTraceId } = await cb(...args);
209+
const { data, errors, totalCount, status, statusText, clerkTraceId, retryAfter } = await cb(...args);
197210
if (errors) {
198211
// instead of passing `data: errors`, we have set the `error.errors` because
199212
// the errors returned from callback is already parsed and passing them as `data`
@@ -202,6 +215,7 @@ function withLegacyRequestReturn(cb: any): LegacyRequestFunction {
202215
data: [],
203216
status,
204217
clerkTraceId,
218+
retryAfter,
205219
});
206220
error.errors = errors;
207221
throw error;

packages/clerk-js/bundlewatch.config.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"files": [
33
{ "path": "./dist/clerk.js", "maxSize": "582.6kB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "79.9kB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "80kB" },
55
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
66
{ "path": "./dist/ui-common*.js", "maxSize": "96KB" },
77
{ "path": "./dist/vendors*.js", "maxSize": "30KB" },

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

+12-4
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,18 @@ export abstract class BaseResource {
127127

128128
assertProductionKeysOnDev(status, errors);
129129

130-
throw new ClerkAPIResponseError(message || statusText, {
131-
data: errors,
132-
status: status,
133-
});
130+
const apiResponseOptions: ConstructorParameters<typeof ClerkAPIResponseError>[1] = { data: errors, status };
131+
if (status === 429 && headers) {
132+
const retryAfter = headers.get('retry-after');
133+
if (retryAfter) {
134+
const value = parseInt(retryAfter, 10);
135+
if (!isNaN(value)) {
136+
apiResponseOptions.retryAfter = value;
137+
}
138+
}
139+
}
140+
141+
throw new ClerkAPIResponseError(message || statusText, apiResponseOptions);
134142
}
135143

136144
return null;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { BaseResource } from '../internal';
2+
3+
class TestResource extends BaseResource {
4+
constructor() {
5+
super();
6+
}
7+
8+
fetch() {
9+
return this._baseGet();
10+
}
11+
12+
fromJSON() {
13+
return this;
14+
}
15+
}
16+
17+
describe('BaseResource', () => {
18+
it('populates retryAfter on 429 error responses', async () => {
19+
BaseResource.clerk = {
20+
// @ts-expect-error - We're not about to mock the entire FapiClient
21+
getFapiClient: () => {
22+
return {
23+
request: jest.fn().mockResolvedValue({
24+
payload: {},
25+
status: 429,
26+
statusText: 'Too Many Requests',
27+
headers: new Headers({ 'Retry-After': '60' }),
28+
}),
29+
};
30+
},
31+
__internal_setCountry: jest.fn(),
32+
};
33+
const resource = new TestResource();
34+
const errResponse = await resource.fetch().catch(err => err);
35+
console.dir(errResponse);
36+
expect(errResponse.retryAfter).toBe(60);
37+
});
38+
39+
it('does not populate retryAfter on invalid header', async () => {
40+
BaseResource.clerk = {
41+
// @ts-expect-error - We're not about to mock the entire FapiClient
42+
getFapiClient: () => {
43+
return {
44+
request: jest.fn().mockResolvedValue({
45+
payload: {},
46+
status: 429,
47+
statusText: 'Too Many Requests',
48+
headers: new Headers({ 'Retry-After': 'abcd' }),
49+
}),
50+
};
51+
},
52+
__internal_setCountry: jest.fn(),
53+
};
54+
const resource = new TestResource();
55+
const errResponse = await resource.fetch().catch(err => err);
56+
console.dir(errResponse);
57+
expect(errResponse.retryAfter).toBe(undefined);
58+
});
59+
});

packages/shared/src/error.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface ClerkAPIResponseOptions {
2525
data: ClerkAPIErrorJSON[];
2626
status: number;
2727
clerkTraceId?: string;
28+
retryAfter?: number;
2829
}
2930

3031
// For a comprehensive Metamask error list, please see
@@ -119,17 +120,19 @@ export class ClerkAPIResponseError extends Error {
119120
status: number;
120121
message: string;
121122
clerkTraceId?: string;
123+
retryAfter?: number;
122124

123125
errors: ClerkAPIError[];
124126

125-
constructor(message: string, { data, status, clerkTraceId }: ClerkAPIResponseOptions) {
127+
constructor(message: string, { data, status, clerkTraceId, retryAfter }: ClerkAPIResponseOptions) {
126128
super(message);
127129

128130
Object.setPrototypeOf(this, ClerkAPIResponseError.prototype);
129131

130132
this.status = status;
131133
this.message = message;
132134
this.clerkTraceId = clerkTraceId;
135+
this.retryAfter = retryAfter;
133136
this.clerkError = true;
134137
this.errors = parseErrors(data);
135138
}

0 commit comments

Comments
 (0)