Skip to content

[Bug]: APIRequestContext kills the process on an unhandled request-socket ECONNRESET when a server refuses a body before reading it #42074

Closed

Description

@donnacha

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 reached
node 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

  1. cipheraxat commented on Aug 3, 2026

    @cipheraxat
    Contributor

    I'd like to work on this. Could you please assign it to me?

    Plan: attach a Socket 'error' listener in APIRequestContext's _sendRequest (alongside the existing request.on('error')), and if response headers already arrived, ignore late ECONNRESET/EPIPE/ECONNABORTED so the 413 (or other early refusal) is returned like Node fetch, instead of crashing the process with an unhandled Socket error.

  2. github-actions commented on Aug 4, 2026

    @github-actions
    Contributor

    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 emits write ECONNRESET on the request socket. APIRequestContext never attaches an 'error' listener there — only request.on('error', ...) — so it becomes an unhandled 'error' event and takes the whole process down with exit(1), exactly as you described. try/catch can't help; it isn't a rejected promise. Plain fetch returns the 413 cleanly 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)
    it('should not crash the process when server refuses body before reading it', {
      annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42074' }
    }, async ({ context, server }) => {
      server.setRoute('/refuse', (req, res) => {
        res.writeHead(413, { 'content-type': 'application/json' });
        res.end(JSON.stringify({ error: 'too large' }));
        req.socket.destroy(); // body never read; cannot resynchronise -> write ECONNRESET on the request socket
      });
      // A large body keeps the client mid-write when the reset lands, losing the race reliably.
      const result = await context.request.post(server.PREFIX + '/refuse', {
        data: 'x'.repeat(80 * 1024 * 1024),
        headers: { 'content-type': 'text/plain' },
      }).catch(e => e);
      // Desired: resolve with the 413 (which the server did send in full) or reject so the caller can handle it.
      // Actual on tip-of-tree: the unhandled Socket 'error' reaches the process and kills it with exit(1).
      if (result instanceof Error)
        expect(result.message).toContain('apiRequestContext.post');
      else
        expect(result.status()).toBe(413);
    });
    What I ran
    • versions: tip-of-tree (npm 1.63.0-alpha-2026-08-04), and 1.62.0 (reported)
    • Node v24.18.0, Linux x64
    • exact repro from the issue (server.mjs + repro.mjs, 5 MB body): on tip-of-tree the process died with Error: write ECONNRESET / exit code 1 in 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.
    • against the same reset behaviour, browsers are unaffected: APIRequestContext-only, matching your CDP finding.
    • source: packages/playwright-core/src/server/fetch.ts attaches request.on('error', reject) but nothing that handles a late write error on the underlying socket, so an ECONNRESET after the response has arrived escapes to the process.

    Triaged by the Playwright bot - agent run

  3. added 3 commits that reference this issue on Aug 4, 2026
    df261bb
    259aefc
    24f08d0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions