Skip to content

Commit cc0469d

Browse files
authored
refactor(core): consolidate fetch retry helper (#16155)
Co-authored-by: mbenhamd <16720467+mbenhamd@users.noreply.github.com>
1 parent b490c40 commit cc0469d

6 files changed

Lines changed: 205 additions & 76 deletions

File tree

.changeset/four-foxes-act.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Added configurable response-based retry handling to `fetchWithRetry`.
6+
7+
Developers can now pass `shouldRetryResponse` to control which non-OK HTTP responses should be retried while network failures continue to retry automatically.
8+
9+
```ts
10+
await fetchWithRetry(url, requestOptions, 3, {
11+
shouldRetryResponse: response => response.status === 429 || response.status >= 500,
12+
});
13+
```
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { downloadFromUrl } from './download-assets';
4+
5+
describe('downloadFromUrl', () => {
6+
afterEach(() => {
7+
vi.restoreAllMocks();
8+
vi.unstubAllGlobals();
9+
});
10+
11+
function mockRetryDelays() {
12+
const delays: number[] = [];
13+
14+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, delay?: number) => {
15+
if (delay && delay > 100) {
16+
delays.push(delay);
17+
}
18+
if (typeof fn === 'function') fn();
19+
return 0 as unknown as ReturnType<typeof setTimeout>;
20+
}) as typeof setTimeout);
21+
22+
return delays;
23+
}
24+
25+
it('should not retry client error responses', async () => {
26+
const delays = mockRetryDelays();
27+
const mockFetch = vi.fn().mockResolvedValue(new Response('not found', { status: 404, statusText: 'Not Found' }));
28+
vi.stubGlobal('fetch', mockFetch);
29+
30+
await expect(
31+
downloadFromUrl({ url: new URL('https://example.com/missing.png'), downloadRetries: 3 }),
32+
).rejects.toThrow('Failed to download asset');
33+
34+
expect(mockFetch).toHaveBeenCalledTimes(1);
35+
expect(delays).toEqual([]);
36+
});
37+
38+
it('should retry server error responses', async () => {
39+
const delays = mockRetryDelays();
40+
const response = new Response('image-data', {
41+
status: 200,
42+
headers: { 'content-type': 'image/png' },
43+
});
44+
const mockFetch = vi
45+
.fn()
46+
.mockResolvedValueOnce(new Response('server error', { status: 500, statusText: 'Server Error' }))
47+
.mockResolvedValueOnce(response);
48+
vi.stubGlobal('fetch', mockFetch);
49+
50+
await expect(
51+
downloadFromUrl({ url: new URL('https://example.com/image.png'), downloadRetries: 3 }),
52+
).resolves.toEqual({
53+
data: new Uint8Array(await new Response('image-data').arrayBuffer()),
54+
mediaType: 'image/png',
55+
});
56+
57+
expect(mockFetch).toHaveBeenCalledTimes(2);
58+
expect(delays).toEqual([2000]);
59+
});
60+
});

packages/core/src/agent/message-list/prompt/download-assets.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export const downloadFromUrl = async ({ url, downloadRetries }: { url: URL; down
1313
method: 'GET',
1414
},
1515
downloadRetries,
16+
{
17+
shouldRetryResponse: response => response.status >= 500,
18+
},
1619
);
1720

1821
if (!response.ok) {

packages/core/src/utils.test.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ describe('fetchWithRetry', () => {
383383
vi.unstubAllGlobals();
384384
});
385385

386-
it('should use exponential backoff delays capped at 10 seconds', async () => {
386+
function mockRetryDelays() {
387387
const delays: number[] = [];
388388

389389
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, delay?: number) => {
@@ -395,6 +395,104 @@ describe('fetchWithRetry', () => {
395395
return 0 as unknown as ReturnType<typeof setTimeout>;
396396
}) as typeof setTimeout);
397397

398+
return delays;
399+
}
400+
401+
it('should return a successful response without retrying', async () => {
402+
const response = new Response('ok', { status: 200 });
403+
const mockFetch = vi.fn().mockResolvedValue(response);
404+
vi.stubGlobal('fetch', mockFetch);
405+
406+
await expect(fetchWithRetry('https://example.com', { method: 'POST' }, 3)).resolves.toBe(response);
407+
408+
expect(mockFetch).toHaveBeenCalledTimes(1);
409+
expect(mockFetch).toHaveBeenCalledWith('https://example.com', { method: 'POST' });
410+
});
411+
412+
it('should retry a failed response and return a later success', async () => {
413+
const delays = mockRetryDelays();
414+
const response = new Response('ok', { status: 200 });
415+
const mockFetch = vi
416+
.fn()
417+
.mockResolvedValueOnce(new Response('error', { status: 500, statusText: 'Server Error' }))
418+
.mockResolvedValueOnce(response);
419+
vi.stubGlobal('fetch', mockFetch);
420+
421+
await expect(fetchWithRetry('https://example.com', {}, 3)).resolves.toBe(response);
422+
423+
expect(mockFetch).toHaveBeenCalledTimes(2);
424+
expect(delays).toEqual([2000]);
425+
});
426+
427+
it('should retry network failures until retries are exhausted', async () => {
428+
const delays = mockRetryDelays();
429+
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'));
430+
vi.stubGlobal('fetch', mockFetch);
431+
432+
await expect(fetchWithRetry('https://example.com', {}, 3)).rejects.toThrow('Network error');
433+
434+
expect(mockFetch).toHaveBeenCalledTimes(3);
435+
expect(delays).toEqual([2000, 4000]);
436+
});
437+
438+
it.each([404, 408, 429])('should preserve public retry behavior for %s responses by default', async status => {
439+
const delays = mockRetryDelays();
440+
const mockFetch = vi.fn().mockResolvedValue(new Response('error', { status }));
441+
vi.stubGlobal('fetch', mockFetch);
442+
443+
await expect(fetchWithRetry('https://example.com/missing', {}, 2)).rejects.toThrow(`status: ${status}`);
444+
445+
expect(mockFetch).toHaveBeenCalledTimes(2);
446+
expect(delays).toEqual([2000]);
447+
});
448+
449+
it('should not retry a response when shouldRetryResponse returns false', async () => {
450+
const delays = mockRetryDelays();
451+
const mockFetch = vi.fn().mockResolvedValue(new Response('not found', { status: 404, statusText: 'Not Found' }));
452+
vi.stubGlobal('fetch', mockFetch);
453+
454+
await expect(
455+
fetchWithRetry('https://example.com/missing', {}, 3, {
456+
shouldRetryResponse: response => response.status >= 500,
457+
}),
458+
).rejects.toThrow('status: 404 Not Found');
459+
460+
expect(mockFetch).toHaveBeenCalledTimes(1);
461+
expect(delays).toEqual([]);
462+
});
463+
464+
it('should retry network errors even when the error message contains a 4xx status', async () => {
465+
const delays = mockRetryDelays();
466+
const response = new Response('ok', { status: 200 });
467+
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('upstream status: 404')).mockResolvedValueOnce(response);
468+
vi.stubGlobal('fetch', mockFetch);
469+
470+
await expect(
471+
fetchWithRetry('https://example.com/transient', {}, 3, {
472+
shouldRetryResponse: response => response.status >= 500,
473+
}),
474+
).resolves.toBe(response);
475+
476+
expect(mockFetch).toHaveBeenCalledTimes(2);
477+
expect(delays).toEqual([2000]);
478+
});
479+
480+
it('should throw the last response error after exhausting retries', async () => {
481+
const delays = mockRetryDelays();
482+
const mockFetch = vi
483+
.fn()
484+
.mockResolvedValueOnce(new Response('first', { status: 500, statusText: 'First Error' }))
485+
.mockResolvedValueOnce(new Response('second', { status: 503, statusText: 'Second Error' }));
486+
vi.stubGlobal('fetch', mockFetch);
487+
488+
await expect(fetchWithRetry('https://example.com/flaky', {}, 2)).rejects.toThrow('status: 503 Second Error');
489+
490+
expect(mockFetch).toHaveBeenCalledTimes(2);
491+
expect(delays).toEqual([2000]);
492+
});
493+
494+
it('should use exponential backoff delays capped at 10 seconds', async () => {
495+
const delays = mockRetryDelays();
398496
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'));
399497
vi.stubGlobal('fetch', mockFetch);
400498

packages/core/src/utils.ts

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import type { Workspace } from './workspace/workspace';
2323

2424
// Re-export Zod utilities for external use (isZodType is defined locally below)
2525
export { getZodTypeName, getZodDef, isZodArray, isZodObject } from './utils/zod-utils';
26+
export { fetchWithRetry } from './utils/fetchWithRetry';
27+
export type { FetchWithRetryOptions } from './utils/fetchWithRetry';
2628

2729
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
2830

@@ -691,47 +693,6 @@ export function parseFieldKey(key: string): FieldKey {
691693
return key as FieldKey;
692694
}
693695

694-
/**
695-
* Performs a fetch request with automatic retries using exponential backoff
696-
* @param url The URL to fetch from
697-
* @param options Standard fetch options
698-
* @param maxRetries Maximum number of retry attempts
699-
* @param validateResponse Optional function to validate the response beyond HTTP status
700-
* @returns The fetch Response if successful
701-
*/
702-
export async function fetchWithRetry(
703-
url: string,
704-
options: RequestInit = {},
705-
maxRetries: number = 3,
706-
): Promise<Response> {
707-
let retryCount = 0;
708-
let lastError: Error | null = null;
709-
710-
while (retryCount < maxRetries) {
711-
try {
712-
const response = await fetch(url, options);
713-
714-
if (!response.ok) {
715-
throw new Error(`Request failed with status: ${response.status} ${response.statusText}`);
716-
}
717-
718-
return response;
719-
} catch (error) {
720-
lastError = error instanceof Error ? error : new Error(String(error));
721-
retryCount++;
722-
723-
if (retryCount >= maxRetries) {
724-
break;
725-
}
726-
727-
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000);
728-
await new Promise(resolve => setTimeout(resolve, delay));
729-
}
730-
}
731-
732-
throw lastError || new Error('Request failed after multiple retry attempts');
733-
}
734-
735696
/**
736697
* Removes specific keys from an object.
737698
* @param obj - The original object

packages/core/src/utils/fetchWithRetry.ts

Lines changed: 28 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,53 @@
1+
export interface FetchWithRetryOptions {
2+
shouldRetryResponse?: (response: Response) => boolean;
3+
}
4+
5+
const defaultShouldRetryResponse = () => true;
6+
17
/**
2-
* Fetches a URL with retry logic
3-
* @param url The URL to fetch
4-
* @param options Fetch options
5-
* @param maxRetries Maximum number of retry attempts
6-
* @returns The fetch Response if successful
8+
* Performs a fetch request with automatic retries using exponential backoff.
9+
* Network failures are always retried. Non-OK responses are retried unless
10+
* `shouldRetryResponse` returns false.
711
*/
812
export async function fetchWithRetry(
913
url: string,
1014
options: RequestInit = {},
1115
maxRetries: number = 3,
16+
retryOptions: FetchWithRetryOptions = {},
1217
): Promise<Response> {
1318
let retryCount = 0;
1419
let lastError: Error | null = null;
20+
const shouldRetryResponse = retryOptions.shouldRetryResponse ?? defaultShouldRetryResponse;
1521

1622
while (retryCount < maxRetries) {
23+
let response: Response | undefined;
24+
1725
try {
18-
const response = await fetch(url, options);
26+
response = await fetch(url, options);
27+
} catch (error) {
28+
lastError = error instanceof Error ? error : new Error(String(error));
29+
}
1930

31+
if (response) {
2032
if (!response.ok) {
21-
// Only retry on server errors (5xx) or network failures
22-
// Don't retry on client errors (4xx)
23-
if (response.status >= 400 && response.status < 500) {
24-
throw new Error(`Request failed with status: ${response.status} ${response.statusText}`);
25-
}
26-
2733
lastError = new Error(`Request failed with status: ${response.status} ${response.statusText}`);
28-
retryCount++;
2934

30-
if (retryCount >= maxRetries) {
35+
if (!shouldRetryResponse(response)) {
3136
throw lastError;
3237
}
33-
34-
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000);
35-
await new Promise(resolve => setTimeout(resolve, delay));
36-
continue;
38+
} else {
39+
return response;
3740
}
41+
}
3842

39-
return response;
40-
} catch (error) {
41-
lastError = error instanceof Error ? error : new Error(String(error));
42-
43-
// If it's a client error (4xx), don't retry
44-
if (lastError.message.includes('status: 4')) {
45-
throw lastError;
46-
}
47-
48-
retryCount++;
49-
50-
if (retryCount >= maxRetries) {
51-
break;
52-
}
43+
retryCount++;
5344

54-
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000);
55-
await new Promise(resolve => setTimeout(resolve, delay));
45+
if (retryCount >= maxRetries) {
46+
break;
5647
}
48+
49+
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000);
50+
await new Promise(resolve => setTimeout(resolve, delay));
5751
}
5852

5953
throw lastError || new Error('Request failed after multiple retry attempts');

0 commit comments

Comments
 (0)