fetchWithValidatedRedirects stalls when a fetch wrapper retains a response clone
Description
Following up on the shared-helper note in #18048: fetchWithValidatedRedirects stalls before following a redirect if a custom fetch wrapper retains a clone of the redirect response.
The helper awaits cancelResponseBody(response) before requesting the next URL. Response.clone() splits the body into two streams, and cancellation of one branch can wait for the other branch. The SDK call therefore waits until the wrapper reads or cancels its retained copy. This can deadlock a wrapper that processes its saved responses after the download finishes.
#18048 fixed the xAI-specific HTTP 202 handler and noted this redirect path as an unverified follow-up. The reproduction below confirms the shared helper is also affected.
Reproduction
No network calls or API keys needed. Install @ai-sdk/[email protected] and [email protected], save as repro.mts, and run node repro.mts with Node 24:
import { fetchWithValidatedRedirects } from '@ai-sdk/provider-utils';
const requests: string[] = [];
let savedResponse: Response | undefined;
let state = 'pending';
const entered = Promise.withResolvers<void>();
const fakeFetch: typeof fetch = async input => {
const url = String(input);
requests.push(url);
if (url.endsWith('/start')) {
const response = new Response('Moved', {
status: 302, headers: { location: 'https://example.test/final' },
});
// Model a fetch wrapper retaining a copy of the response.
savedResponse = response.clone();
entered.resolve();
return response;
}
return new Response('Synthetic file');
};
const download = fetchWithValidatedRedirects({
url: 'https://example.test/start', fetch: fakeFetch,
}).then(response => { state = 'resolved'; return response; });
try {
await entered.promise;
await new Promise(resolve => setTimeout(resolve, 100));
console.log({ state, requests });
// Expected: resolved, with both /start and /final requested.
} finally {
// Reading the retained clone releases the shared cancellation promise.
await savedResponse?.arrayBuffer();
const response = await download;
console.log('After reading clone:', state, await response.text());
}Actual output:
{ state: 'pending', requests: [ 'https://example.test/start' ] }
After reading clone: resolved Synthetic fileExpected: the SDK requests /final and resolves without waiting for the wrapper to read its saved copy.
Removing response.clone() makes the redirect complete immediately. In local tests on Node 22.20.0 and 24.13.0, all five redirect statuses (301, 302, 303, 307, 308) stall with an unread clone. Nine controls pass: those statuses without cloning, reading or cancelling the clone, a redirect with no body, and a non-redirect response with a retained clone. Each test drains retained bodies in cleanup.
The reproduction passes strict TypeScript checking. Both helpers are unchanged on main at 12845693d7a6a517dc633d6b6a2e4f5bfd24d2ec (source comparison; runtime tests used the published package).
I also checked #18571 / #18572: those concern cancellation rejecting and replacing the original error, rather than cancellation remaining pending before a redirect.
I used AI assistance to investigate and prepare the reproduction and tests.
AI SDK Version
@ai-sdk/provider-utils:5.0.40zod:4.4.3- Node.js:
22.20.0and24.13.0
Code of Conduct
- I agree to follow this project's Code of Conduct
Source: vercel/ai