Skip to content

[Bug]: APIRequestContext ignores the server's Keep-Alive timeout, producing ECONNRESET on socket reuse #42698

Description

@nick-vogel

Version

1.58.2

Steps to reproduce

playwright-core/lib/server/utils/happyEyeballs.js creates a module-level singleton http.Agent({ keepAlive: true }), shared by every request context in a worker. Node's agent does not parse the response's Keep-Alive: timeout=N header, so it will hold and reuse a socket the server has already committed to closing. The reuse is answered with a RST, which surfaces as apiRequestContext.<verb>: read ECONNRESET or socket hang up.

Reproduced against a Next.js 16 dev server, which is Node's HTTP server with the default keepAliveTimeout of 5s. It advertises the value on every response (Keep-Alive: timeout=5), and a raw socket idled against it is closed at 6.06s.

I swept request timings across that boundary:

  • idle server: 0 failures in 228 requests
  • same sweep, six concurrent readers: 2 failures in 87, both landing exactly at the 6000ms mark

The server was three hours old and 154 MB resident for both sweeps, so this is not memory pressure or server degradation. Concurrency widens the window between the client's decision to reuse a pooled socket and the write reaching the server; it is not itself the cause.

Expected behavior

A pooled socket is not reused after the server's advertised Keep-Alive: timeout has elapsed, so an idempotent request does not fail with ECONNRESET.

Actual behavior

The request throws read ECONNRESET.

What makes this hard to attribute: Chromium's network stack resends a request that failed on a socket it had reused, so page navigations never show this. Only APIRequestContext calls do. In a suite that mixes both, the failures cluster in whichever specs happen to use the request context, which reads as a bug in those specs rather than as a transport fault. It took a packet-level repro to see otherwise.

The consequence in our case was worse than a flaky test. A dropped cleanup PATCH at the end of a spec left fixture data in a shared database, and because our seed only creates what is absent, that state persisted and poisoned later runs.

Suggested fixes, in preference order

  1. Honour the Keep-Alive: timeout header when deciding whether a pooled socket is still usable.
  2. Default maxRetries above 0 for idempotent verbs.
  3. Document the hazard on APIRequestContext. maxRetries defaults to 0 and nothing in the docs points a user toward it for this failure.

Workaround

Wrap the request context so every idempotent verb carries maxRetries. That is narrow and safe: fetch.js retries only when e.code === 'ECONNRESET' and a status code never reaches that catch, so no real server failure is masked. We deliberately excluded POST, since the reset is raised on the read side and the server may already have processed the create, making a replay a duplicate row.

Setting Connection: close via use.extraHTTPHeaders also fixes it completely (0 failures in 87 under the load that gave 2), but Chromium forwards the header, so the browser loses keep-alive for every navigation and every asset to fix a fault that only ever touched the API context.

Environment

  • Playwright 1.58.2
  • Node v26.3.0
  • macOS (Darwin 25.4.0), arm64
  • Server under test: Next.js 16.2.11 dev server

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions