Skip to content

Commit dfe5ab5

Browse files
authored
fix(fetch): preserve error code in decompression pipeline for retry logic (#40946)
1 parent 7444bca commit dfe5ab5

2 files changed

Lines changed: 77 additions & 5 deletions

File tree

packages/playwright-core/src/server/fetch.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,6 @@ export abstract class APIRequestContext extends SdkObject {
482482
return;
483483
}
484484
}
485-
response.on('aborted', () => reject(new Error('aborted')));
486-
487485
const chunks: Buffer[] = [];
488486
const notifyBodyFinished = () => {
489487
const body = Buffer.concat(chunks);
@@ -522,11 +520,21 @@ export abstract class APIRequestContext extends SdkObject {
522520
// Brotli and deflate decompressors throw if the input stream is empty.
523521
const emptyStreamTransform = new SafeEmptyStreamTransform(notifyBodyFinished);
524522
body = pipeline(response, emptyStreamTransform, transform, e => {
525-
if (e)
526-
reject(new Error(`failed to decompress '${encoding}' encoding: ${e.message}`));
523+
if (e) {
524+
if (isNetworkConnectionError(e))
525+
reject(e);
526+
else
527+
reject(new Error(`failed to decompress '${encoding}' encoding: ${e.message}`));
528+
}
529+
});
530+
body.on('error', e => {
531+
if (isNetworkConnectionError(e))
532+
reject(e);
533+
else
534+
reject(new Error(`failed to decompress '${encoding}' encoding: ${e}`));
527535
});
528-
body.on('error', e => reject(new Error(`failed to decompress '${encoding}' encoding: ${e}`)));
529536
} else {
537+
response.on('aborted', () => reject(new Error('aborted')));
530538
body.on('error', reject);
531539
}
532540

@@ -804,6 +812,11 @@ function removeHeader(headers: { [name: string]: string }, name: string) {
804812
delete headers[existing[0]];
805813
}
806814

815+
function isNetworkConnectionError(e: any): boolean {
816+
const code = e?.code;
817+
return code === 'ECONNRESET' || code === 'EPIPE' || code === 'ECONNABORTED';
818+
}
819+
807820
function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) {
808821
const { username, password } = credentials;
809822
const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64');

tests/library/browsercontext-fetch.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,3 +1401,62 @@ it('should retry on ECONNRESET', {
14011401
expect(await response.text()).toBe('Hello!');
14021402
expect(requestCount).toBe(4);
14031403
});
1404+
1405+
it('should retry ECONNRESET on compressed response', async ({ context, server }) => {
1406+
let requestCount = 0;
1407+
server.setRoute('/test-gzip', (req, res) => {
1408+
if (requestCount++ < 2) {
1409+
req.socket.destroy();
1410+
return;
1411+
}
1412+
res.writeHead(200, {
1413+
'Content-Encoding': 'gzip',
1414+
'Content-Type': 'text/plain',
1415+
});
1416+
const gzipStream = zlib.createGzip();
1417+
pipeline(gzipStream, res, err => {
1418+
if (err)
1419+
console.log(`Server error: ${err}`);
1420+
});
1421+
gzipStream.write('compressed-retry-ok');
1422+
gzipStream.end();
1423+
});
1424+
const response = await context.request.get(server.PREFIX + '/test-gzip', { maxRetries: 3 });
1425+
expect(response.status()).toBe(200);
1426+
expect(await response.text()).toBe('compressed-retry-ok');
1427+
expect(requestCount).toBe(3);
1428+
});
1429+
1430+
it('should retry ECONNRESET mid-stream during gzip decompression', async ({ context, server }) => {
1431+
let requestCount = 0;
1432+
server.setRoute('/test-gzip-midstream', (req, res) => {
1433+
requestCount++;
1434+
if (requestCount <= 2) {
1435+
// Send response headers to make client enter the decompression pipeline,
1436+
// then destroy the socket. This exercises the fix: without it, the
1437+
// pipeline error callback wraps the error, stripping .code for retry.
1438+
res.writeHead(200, {
1439+
'Content-Encoding': 'gzip',
1440+
'Content-Type': 'text/plain',
1441+
});
1442+
res.flushHeaders();
1443+
req.socket.destroy();
1444+
return;
1445+
}
1446+
res.writeHead(200, {
1447+
'Content-Encoding': 'gzip',
1448+
'Content-Type': 'text/plain',
1449+
});
1450+
const gzipStream = zlib.createGzip();
1451+
pipeline(gzipStream, res, err => {
1452+
if (err)
1453+
console.log(`Server error: ${err}`);
1454+
});
1455+
gzipStream.write('midstream-retry-ok');
1456+
gzipStream.end();
1457+
});
1458+
const response = await context.request.get(server.PREFIX + '/test-gzip-midstream', { maxRetries: 3 });
1459+
expect(response.status()).toBe(200);
1460+
expect(await response.text()).toBe('midstream-retry-ok');
1461+
expect(requestCount).toBe(3);
1462+
});

0 commit comments

Comments
 (0)