Skip to content

Commit e35b104

Browse files
authored
feat(testing): log response diagnostics when FAPI retries are exhausted (#8848)
1 parent 8744728 commit e35b104

3 files changed

Lines changed: 122 additions & 4 deletions

File tree

.changeset/large-pugs-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/testing': patch
3+
---
4+
5+
When a Frontend API request exhausts its retries in the Playwright `setupClerkTestingToken` helper, the warning now includes response diagnostics (`cf-ray`, `retry-after`, `content-type`, and a truncated response body) so rate-limit responses can be attributed to their source. Network-error retry exhaustion now includes the error message in the warning as well.

packages/testing/src/playwright/__tests__/setupClerkTestingToken.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,108 @@ describe('setupClerkTestingToken', () => {
311311
warnSpy.mockRestore();
312312
});
313313

314+
it('logs response diagnostics after exhausting retries on retryable status', async () => {
315+
const { context, getRouteHandler } = createMockContext();
316+
await setupClerkTestingToken({ context });
317+
318+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
319+
320+
const route = {
321+
request: () => ({ url: () => 'https://clerk.example.com/v1/client' }),
322+
fetch: vi.fn(() =>
323+
Promise.resolve({
324+
status: () => 429,
325+
headers: () => ({
326+
'cf-ray': '8f7a2b3c4d5e6f70-IAD',
327+
'retry-after': '12',
328+
'content-type': 'application/json',
329+
}),
330+
text: () => Promise.resolve('{"errors":[{"code":"too_many_requests"}]}'),
331+
json: () => Promise.resolve({}),
332+
}),
333+
),
334+
fulfill: vi.fn(() => Promise.resolve()),
335+
continue: vi.fn(() => Promise.resolve()),
336+
} as unknown as Route;
337+
338+
const handlerPromise = getRouteHandler()!(route);
339+
await vi.advanceTimersByTimeAsync(60_000);
340+
await handlerPromise;
341+
342+
expect(route.fulfill).toHaveBeenCalledTimes(1);
343+
expect(warnSpy).toHaveBeenCalledTimes(1);
344+
const warning = warnSpy.mock.calls[0][0] as string;
345+
expect(warning).toContain('cf-ray: 8f7a2b3c4d5e6f70-IAD');
346+
expect(warning).toContain('retry-after: 12');
347+
expect(warning).toContain('content-type: application/json');
348+
expect(warning).toContain('body: {"errors":[{"code":"too_many_requests"}]}');
349+
350+
warnSpy.mockRestore();
351+
});
352+
353+
it('truncates long response bodies in diagnostics', async () => {
354+
const { context, getRouteHandler } = createMockContext();
355+
await setupClerkTestingToken({ context });
356+
357+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
358+
359+
const route = {
360+
request: () => ({ url: () => 'https://clerk.example.com/v1/client' }),
361+
fetch: vi.fn(() =>
362+
Promise.resolve({
363+
status: () => 429,
364+
headers: () => ({}),
365+
text: () => Promise.resolve('x'.repeat(2000)),
366+
json: () => Promise.resolve({}),
367+
}),
368+
),
369+
fulfill: vi.fn(() => Promise.resolve()),
370+
continue: vi.fn(() => Promise.resolve()),
371+
} as unknown as Route;
372+
373+
const handlerPromise = getRouteHandler()!(route);
374+
await vi.advanceTimersByTimeAsync(60_000);
375+
await handlerPromise;
376+
377+
expect(warnSpy).toHaveBeenCalledTimes(1);
378+
const warning = warnSpy.mock.calls[0][0] as string;
379+
expect(warning).toContain('cf-ray: n/a');
380+
expect(warning).toContain(`body: ${'x'.repeat(500)}`);
381+
expect(warning).not.toContain('x'.repeat(501));
382+
383+
warnSpy.mockRestore();
384+
});
385+
386+
it('still fulfills the response when diagnostics cannot be read', async () => {
387+
const { context, getRouteHandler } = createMockContext();
388+
await setupClerkTestingToken({ context });
389+
390+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
391+
392+
// No headers()/text() on the response: diagnostics are best-effort and must not break fulfillment.
393+
const route = {
394+
request: () => ({ url: () => 'https://clerk.example.com/v1/client' }),
395+
fetch: vi.fn(() =>
396+
Promise.resolve({
397+
status: () => 429,
398+
json: () => Promise.resolve({}),
399+
}),
400+
),
401+
fulfill: vi.fn(() => Promise.resolve()),
402+
continue: vi.fn(() => Promise.resolve()),
403+
} as unknown as Route;
404+
405+
const handlerPromise = getRouteHandler()!(route);
406+
await vi.advanceTimersByTimeAsync(60_000);
407+
await handlerPromise;
408+
409+
expect(route.fulfill).toHaveBeenCalledTimes(1);
410+
expect(route.continue).not.toHaveBeenCalled();
411+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('failed with status 429 after 4 attempts'));
412+
413+
warnSpy.mockRestore();
414+
});
415+
314416
it('retries on thrown errors and warns after exhausting retries', async () => {
315417
const { context, getRouteHandler } = createMockContext();
316418
await setupClerkTestingToken({ context });
@@ -332,7 +434,10 @@ describe('setupClerkTestingToken', () => {
332434

333435
expect(route.fetch).toHaveBeenCalledTimes(4);
334436
expect(route.continue).toHaveBeenCalledTimes(1);
335-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('failed after 4 attempts'), networkError);
437+
expect(warnSpy).toHaveBeenCalledTimes(1);
438+
const warning = warnSpy.mock.calls[0][0] as string;
439+
expect(warning).toContain('failed after 4 attempts');
440+
expect(warning).toContain('net::ERR_CONNECTION_REFUSED');
336441

337442
warnSpy.mockRestore();
338443
errorSpy.mockRestore();

packages/testing/src/playwright/setupClerkTestingToken.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,17 @@ export const setupClerkTestingToken = async ({ context, options, page }: SetupCl
8686
continue;
8787
}
8888

89+
// Diagnostics are best-effort: never let them prevent fulfilling the response.
90+
let diagnostics = '';
91+
try {
92+
const headers = response.headers();
93+
diagnostics = `\n cf-ray: ${headers['cf-ray'] ?? 'n/a'}, retry-after: ${headers['retry-after'] ?? 'n/a'}, content-type: ${headers['content-type'] ?? 'n/a'}`;
94+
diagnostics += `\n body: ${(await response.text()).slice(0, 500)}`;
95+
} catch {
96+
// ignore
97+
}
8998
console.warn(
90-
`[Clerk Testing] FAPI request failed with status ${status} after ${MAX_ROUTE_RETRIES + 1} attempts: ${route.request().url()}`,
99+
`[Clerk Testing] FAPI request failed with status ${status} after ${MAX_ROUTE_RETRIES + 1} attempts: ${route.request().url()}${diagnostics}`,
91100
);
92101
await route.fulfill({ response });
93102
return;
@@ -121,8 +130,7 @@ export const setupClerkTestingToken = async ({ context, options, page }: SetupCl
121130
}
122131

123132
console.warn(
124-
`[Clerk Testing] FAPI request failed after ${MAX_ROUTE_RETRIES + 1} attempts: ${route.request().url()}`,
125-
error,
133+
`[Clerk Testing] FAPI request failed after ${MAX_ROUTE_RETRIES + 1} attempts: ${route.request().url()} (${String(error)})`,
126134
);
127135
await route.continue({ url: urlString }).catch(console.error);
128136
return;

0 commit comments

Comments
 (0)