Repository navigation
Description
Version
1.62.0
Steps to reproduce
When a server responds to a POST without reading the request body and then tears the connection, Node emits write ECONNRESET on the request socket. APIRequestContext attaches no 'error' listener there, so it surfaces as an unhandled 'error' event and the process exits(1) — it is not a rejected promise, so try/catch around the call does not help.
This is not a contrived server. It is what Cloudflare's workerd does for a bounded-body refusal (a 413 or 429 decided before the body is read): it cannot resynchronise a stream it never consumed, so it resets. Any server that refuses a large upload early behaves this way.
server.mjs
import { createServer } from 'node:http';
createServer((req, res) => {
res.writeHead(413, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'too large' }));
res.socket?.destroy(); // body never read; cannot resynchronise
}).listen(3999, () => console.log('ready'));repro.mjs
import { request } from '@playwright/test';
const ctx = await request.newContext();
try {
const r = await ctx.post('http://127.0.0.1:3999/api/x', {
data: 'x'.repeat(5 * 1024 * 1024),
headers: { 'content-type': 'text/plain' },
});
console.log('status:', r.status());
console.log('body:', await r.text());
} catch (e) {
console.log('CAUGHT:', e.message); // never reached
}
await ctx.dispose();
console.log('finished cleanly'); // never reachednode server.mjs &
node repro.mjs
Expected behavior
The request either resolves with the 413 and its body — which the server did send, in full, before resetting — or rejects so the caller can handle it. Plain fetch in the same Node version does the former:
status: 413 | body: {"error":"too large"}
finished cleanly
Actual behavior
The process dies before either console.log, and the catch does not run:
node:events:497
throw er; // Unhandled 'error' event
^
Error: write ECONNRESET
at WriteWrap.onWriteComplete [as oncomplete] (node:internal/stream_base_commons:87:19)
Emitted 'error' event on Socket instance at:
at emitErrorNT (node:internal/streams/destroy:170:8)
errno: -104,
code: 'ECONNRESET',
syscall: 'write'
exit code: 1
Under @playwright/test the failure takes a second form: the status resolves and then the response is disposed, so .status() passes and a subsequent .json() or .text() fails with Error: write ECONNRESET. Either way a single request takes down more than itself.
Browsers are unaffected, which is what makes this specific to APIRequestContext. Against the same reset behaviour, instrumented over CDP with three request shapes × 15 requests (in-page fetch, a real form-submit navigation, and page.request.post): zero Network.loadingFailed events, connectionReused true on most, and every request resolved. Chromium reuses the poisoned socket and recovers transparently.
Additional context
Found while testing a Cloudflare Workers app whose rate limiter and body-size guard both refuse before reading the request body — deliberately, so an oversized or rate-limited request costs nothing to reject. Two tests assert exactly that property, and they are the only two of 641 that cannot run without a workaround.
The workaround is a ~150-line buffering proxy in front of the server, which reads each request body fully before forwarding so the upstream never resets a stream mid-write. It works, but it puts our own code in the request path of every test and has already produced two defects of its own — including one that re-framed a truncated upstream response as a complete 200, which would have turned a real server failure into a passing test. We would very much rather delete it.
A listener on the request socket that surfaces the error through the normal request-failure path — rather than letting it reach the process — would be enough.
Environment
- Playwright 1.62.0
- Node v22.19.0
- Linux 6.8.0-136-generic (x64)
I could not find an existing issue for this; the nearest are #38668 and #30978, both about retrying after ECONNRESET rather than the unhandled listener, and both closed.
Activity
cipheraxat commentedon Aug 3, 2026
I'd like to work on this. Could you please assign it to me?
Plan: attach a Socket
'error'listener inAPIRequestContext's_sendRequest(alongside the existingrequest.on('error')), and if response headers already arrived, ignore lateECONNRESET/EPIPE/ECONNABORTEDso the 413 (or other early refusal) is returned like Nodefetch, instead of crashing the process with an unhandled Socket error.github-actions commentedon Aug 4, 2026
Hi, I'm the Playwright bot and I took a first look at this.
Reproduced on tip-of-tree (npm
1.63.0-alpha-2026-08-04). When a server writes a response and then destroys the socket without draining the request body, Node emitswrite ECONNRESETon the request socket.APIRequestContextnever attaches an'error'listener there — onlyrequest.on('error', ...)— so it becomes an unhandled'error'event and takes the whole process down withexit(1), exactly as you described.try/catchcan't help; it isn't a rejected promise. Plainfetchreturns the413cleanly against the same server. Looks like a real bug.It's a race, so the reproduction rate is timing-sensitive — how much of the upload is still in-flight when the reset lands. On Node v24.18.0 here it fires roughly 1-in-4 with your 5 MB body, and more often once the body is larger. You reported it reliably on Node v22.19.0; the code path (no socket error listener) is the same regardless, so this isn't version- or Node-specific — just a latent unhandled listener.
Condensed repro (self-contained,
tests/library/browsercontext-fetch.spec.ts)What I ran
1.63.0-alpha-2026-08-04), and 1.62.0 (reported)server.mjs+repro.mjs, 5 MB body): on tip-of-tree the process died withError: write ECONNRESET/exit code 1in 2 of the first 3 runs; subsequent batches landed ~1-in-8 at 5 MB and ~2-in-5 at 80 MB — a timing race, not a flake in the bug's existence.APIRequestContext-only, matching your CDP finding.packages/playwright-core/src/server/fetch.tsattachesrequest.on('error', reject)but nothing that handles a late write error on the underlying socket, so anECONNRESETafter the response has arrived escapes to the process.Triaged by the Playwright bot - agent run
fix(fetch): do not crash on write ECONNRESET after early refusal
fix(fetch): do not crash on write ECONNRESET after early refusal
fix(fetch): do not crash on write ECONNRESET after early refusal
fix(fetch): do not crash on late EPIPE after refused body