From 7de5f9eabf6b6c7478c9d45e08bf5c42ae288a3b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 21 May 2026 07:34:30 -0700 Subject: [PATCH] fix(fetch): preserve error code in decompression pipeline for retry logic When a compressed HTTP response (gzip/br/deflate) encounters a network error like ECONNRESET during body streaming, the pipeline error callback and body error handler were wrapping the original error in a new Error, destroying the .code property. The retry logic in _sendRequestWithRetries checks e.code !== 'ECONNRESET' to decide whether to retry, so retries never happened for compressed responses. Fix: check if the error is a network error (ECONNRESET, EPIPE, ECONNABORTED) and pass it through unwrapped so the retry logic can see the code. Only wrap non-network errors as decompression failures. --- packages/playwright-core/src/server/fetch.ts | 20 +++++++++++++--- tests/library/browsercontext-fetch.spec.ts | 25 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 7299449c041ad..28f9c8f41b10c 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -522,10 +522,19 @@ export abstract class APIRequestContext extends SdkObject { // Brotli and deflate decompressors throw if the input stream is empty. const emptyStreamTransform = new SafeEmptyStreamTransform(notifyBodyFinished); body = pipeline(response, emptyStreamTransform, transform, e => { - if (e) - reject(new Error(`failed to decompress '${encoding}' encoding: ${e.message}`)); + if (e) { + if (isNetworkConnectionError(e)) + reject(e); + else + reject(new Error(`failed to decompress '${encoding}' encoding: ${e.message}`)); + } + }); + body.on('error', e => { + if (isNetworkConnectionError(e)) + reject(e); + else + reject(new Error(`failed to decompress '${encoding}' encoding: ${e}`)); }); - body.on('error', e => reject(new Error(`failed to decompress '${encoding}' encoding: ${e}`))); } else { body.on('error', reject); } @@ -804,6 +813,11 @@ function removeHeader(headers: { [name: string]: string }, name: string) { delete headers[existing[0]]; } +function isNetworkConnectionError(e: any): boolean { + const code = e?.code; + return code === 'ECONNRESET' || code === 'EPIPE' || code === 'ECONNABORTED'; +} + function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) { const { username, password } = credentials; const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64'); diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index 871e435b6df70..2ab27fa3cbd25 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -1401,3 +1401,28 @@ it('should retry on ECONNRESET', { expect(await response.text()).toBe('Hello!'); expect(requestCount).toBe(4); }); + +it('should retry ECONNRESET on compressed response', async ({ context, server }) => { + let requestCount = 0; + server.setRoute('/test-gzip', (req, res) => { + if (requestCount++ < 2) { + req.socket.destroy(); + return; + } + res.writeHead(200, { + 'Content-Encoding': 'gzip', + 'Content-Type': 'text/plain', + }); + const gzipStream = zlib.createGzip(); + pipeline(gzipStream, res, err => { + if (err) + console.log(`Server error: ${err}`); + }); + gzipStream.write('compressed-retry-ok'); + gzipStream.end(); + }); + const response = await context.request.get(server.PREFIX + '/test-gzip', { maxRetries: 3 }); + expect(response.status()).toBe(200); + expect(await response.text()).toBe('compressed-retry-ok'); + expect(requestCount).toBe(3); +});