PRACTICAL GUIDE / Playwright API maxRetries
Retry connection resets without hiding API failures
Use request-level retries for ECONNRESET, prove the extra attempt, protect non-idempotent calls, and preserve useful failure evidence in CI.
In this guide7 sections
- Know exactly what the option retries
- Prove the retry with a server that really resets the socket
- Separate reset handling from status handling
- Keep repeated writes out of the danger zone
- Capture enough evidence to classify the first failure
- Test the retry budget as a failure policy
- Roll out one narrow policy and know when to refuse it
What you will learn
- Know exactly what the option retries
- Prove the retry with a server that really resets the socket
- Separate reset handling from status handling
- Keep repeated writes out of the danger zone
A read-only API test fails once a week with ECONNRESET, then succeeds when someone reruns it by hand. Raising the Playwright test retry count makes the report greener, but it also repeats setup and every request before the failure. The narrower control belongs on the individual API request, and it handles a much smaller class of failures than its name suggests.
Know exactly what the option retries
maxRetries is an option on APIRequestContext request methods such as get(), post(), and fetch(). Playwright added it in version 1.46. The default is zero, so no request retry occurs unless a test opts in. A value of one allows one additional attempt after the initial attempt fails with the supported network error.
The official API reference is unusually specific about scope: currently only ECONNRESET is retried. That phrase should drive both design and review. A server that returns 500, 503, or 429 completed the HTTP exchange and produced a response. Playwright will return that APIResponse unless failOnStatusCode asks it to throw, but maxRetries will not send another request because of the status. A DNS lookup error is not a reset. A connection refusal is not a reset. A Playwright request timeout is not a reset. Increasing the number cannot turn those failures into supported cases.
Connection reset means the TCP connection was closed unexpectedly while the client was using it. In CI, that can come from a proxy, load balancer, service restart, stale keep-alive connection, or the service process itself. The test sees a transport failure rather than an HTTP status and has no response body to inspect. Retrying once can be reasonable when the operation is safe to repeat and the transient reset is outside the product behavior under test.
The number is a maximum attempt count, not a delay policy you can configure. Retries are not immediate. playwright-core 1.61.1 waits before each one, starting at 250 ms and doubling, and it logs Received ECONNRESET, will retry after 250ms. before the first wait. What the option does not expose is any control over that schedule: there is no backoff setting, no jitter, no status-code list, and no callback that approves each retry. Do not write prose or helper names that imply those controls exist. If a service requires exponential backoff for 429 or 503 responses, that belongs in application code, a service client with an explicit policy, or a purpose-built test helper. It is not what this Playwright option provides.
Request retries and test retries protect different boundaries. A request retry repeats one HTTP call while the current test attempt remains alive. A test retry begins another test attempt and reruns hooks, fixtures, data creation, authentication, and assertions according to the suite's structure. The second boundary is far larger. If a beforeEach creates an account and the body submits a payment before the reset occurs, rerunning the whole test can create more state than repeating a safe read.
Neither mechanism proves the service is reliable. A successful retry says the eventual request completed within the permitted attempts. It also says the first transport path failed. Whether that should pass a release gate depends on the test's purpose. A functional smoke test may tolerate one infrastructure reset while retaining evidence. A reliability test should often fail on the first reset because the reset itself is the behavior being measured.
Choose the smallest number that matches a documented tolerance. One extra attempt is usually enough to distinguish a single stale connection from a sustained outage. Five retries can multiply load during an incident and add a long, variable tail to CI. There is no honest universal setting. The endpoint's semantics, test purpose, timeout, and upstream capacity determine the acceptable cost.
Prove the retry with a server that really resets the socket
A mock that returns status 500 cannot test this feature because an HTTP response is not ECONNRESET. Build a focused contract test with a local HTTP server that destroys the first connection and answers the second. The oracle below can fail: remove maxRetries, and the request throws before a response exists; change the server to reset twice, and one allowed retry is exhausted; stop resetting, and the call-count assertion fails.
import { once } from 'node:events';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { expect, test } from '@playwright/test';
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
}
test('retries one connection reset and returns the second response', async ({ request }) => {
let calls = 0;
const server = createServer((incoming, outgoing) => {
calls += 1;
if (calls === 1) {
incoming.socket.destroy();
return;
}
outgoing.writeHead(200, {
'connection': 'close',
'content-type': 'application/json',
});
outgoing.end(JSON.stringify({ state: 'ready' }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address() as AddressInfo;
try {
const response = await request.get(
`http://127.0.0.1:${address.port}/health`,
{ maxRetries: 1 },
);
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ state: 'ready' });
expect(calls).toBe(2);
} finally {
await closeServer(server);
}
});This is a test of Playwright wiring and your chosen policy, not a performance measurement. The two calls are produced deliberately by the fixture. Do not publish their duration as evidence of how fast a production retry will be. A local socket reset avoids DNS, TLS, proxy, and deployment layers that contribute to real latency.
Keep this contract test small and run it when upgrading Playwright or changing a shared API helper. Product API tests do not need to manufacture resets individually. They need confidence that the wrapper passes the intended option and that the Playwright version in CI supports it. One controlled failure gives clearer evidence than dozens of product tests that merely happen to set the same number.
There is a subtle resource boundary in the fixture. The server must close even when the request throws or an assertion fails, which is why cleanup lives in finally. Without that cleanup, a failed test can leave an open handle and make the worker hang. The Connection: close response also keeps this example from depending on an idle keep-alive socket during server shutdown.
Run the same contract once with the retry limit exhausted. A server that resets both the first and second connections should cause request.get() to reject when maxRetries is one. That negative case proves the helper has a cap. It also guards against a future wrapper accidentally substituting a larger number. Do not make the negative case assert an entire platform-specific error string; assert rejection and retain the actual error for diagnosis.
Separate reset handling from status handling
Teams often add maxRetries: 3 after seeing intermittent 503 responses. That change cannot affect the failure because the server returned a valid response. A small local test makes the distinction visible: even with a high reset retry limit, one 503 response produces one server call.
import { once } from 'node:events';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { expect, test } from '@playwright/test';
test('does not retry an HTTP 503 response', async ({ request }) => {
let calls = 0;
const server = createServer((_incoming, outgoing) => {
calls += 1;
outgoing.writeHead(503, {
'connection': 'close',
'content-type': 'application/json',
'retry-after': '2',
});
outgoing.end(JSON.stringify({ error: 'maintenance' }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address() as AddressInfo;
try {
const response = await request.get(
`http://127.0.0.1:${address.port}/inventory`,
{ maxRetries: 4 },
);
expect(response.status()).toBe(503);
expect(await response.json()).toEqual({ error: 'maintenance' });
expect(calls).toBe(1);
} finally {
await new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
}
});That test also shows why failOnStatusCode should be chosen separately. With its default behavior, the response exists and the test can inspect status, headers, and body. With failOnStatusCode: true, Playwright throws for a non-success status, which may be convenient for setup calls, but the throw still does not make the status eligible for maxRetries. Do not catch every thrown error and label it a network failure.
For a 503, decide what the product contract says. A health check used only to wait for a test environment may have an explicit polling policy with a deadline. A checkout API returning 503 may need to fail the test immediately and preserve the response body. A service client may honor Retry-After. Those are HTTP policies with their own code and tests. Keeping them outside reset handling makes an incident report much easier to read.
Timeouts need the same separation. Each API request has a timeout option, which bounds how long Playwright waits. A timeout can occur because the server never responds, because the route is slow, or because the limit is too small for the environment. maxRetries does not grant another attempt for that timeout under the documented behavior. If a timeout is expected during a long asynchronous job, poll the job's status with a bounded product-level loop instead of pretending it was a reset.
DNS and connection refusal point to setup or availability. A misspelled host, missing container network alias, closed port, or service that never started will not improve through this option. The useful evidence is the resolved URL, service startup log, and error code. A broad catch block that sleeps and calls again can turn a quick configuration failure into several minutes of opaque CI delay.
The practical rule is to classify before retrying. If an APIResponse exists, handle its HTTP contract. If no response exists, retain the thrown error and determine whether it is the specifically supported reset. If the test cannot distinguish those cases, improve its evidence before increasing any limit.
Keep repeated writes out of the danger zone
The API accepts maxRetries on methods that can modify state, but availability in the type definition is not a safety guarantee. A connection can reset after the server has committed a write but before the client receives the response. Retrying an ordinary create request may therefore create two orders, two users, or two charges. From the client's point of view, “no response” does not mean “the server did nothing.”
GET, HEAD, PUT with replacement semantics, and DELETE are often described as idempotent at the HTTP-method level, but an endpoint can still violate those expectations through side effects. A GET that records a billable event is not operationally harmless. A DELETE that triggers a new asynchronous job on every call is not safely repeatable. Review the actual service contract rather than granting safety based on the method name.
For a write, use request-level retry only when the service provides a tested idempotency mechanism. The client sends a stable operation key, and the server guarantees that repeated requests with that key resolve to one business operation. The test must verify the guarantee. Merely adding an Idempotency-Key header proves nothing if the server ignores it.
import { randomUUID } from 'node:crypto';
import { expect, test } from '@playwright/test';
test('deduplicates a retried order key at the service boundary', async ({ request }) => {
const operationKey = randomUUID();
const payload = { sku: 'QA-BOOK', quantity: 1 };
const headers = { 'idempotency-key': operationKey };
const first = await request.post('/api/orders', { data: payload, headers });
expect(first.status()).toBe(201);
const created = await first.json() as { id: string };
const repeated = await request.post('/api/orders', { data: payload, headers });
expect([200, 201]).toContain(repeated.status());
const replayed = await repeated.json() as { id: string };
expect(replayed.id).toBe(created.id);
const lookup = await request.get(`/api/orders/by-key/${operationKey}`);
expect(lookup.status()).toBe(200);
const records = await lookup.json() as Array<{ id: string }>;
expect(records.map(record => record.id)).toEqual([created.id]);
});This example intentionally does not add maxRetries to the POST. It first proves the server's deduplication contract directly. Only after that contract exists should a separate transport-reset test enable one retry for the write. The lookup assertion can fail if the server creates duplicates, so it is not an oracle that merely checks its fixture.
Some APIs return 200 for a replay and 201 for the initial creation, while others return the original 201 response. The example accepts either status because identity and record count are the contract under examination. Your real test should use the exact documented response rule if the service defines one. Do not copy this status set into an unrelated API.
If the endpoint has no idempotency contract, let the reset fail and reconcile state explicitly. Query by a client-generated business reference to determine whether the operation committed. Cleanup may then delete the created record or flag it for manual review. That is slower than an automatic retry, but it avoids silently duplicating production-like state.
Test-level retries require the same scrutiny. A test that performs unsafe writes before failing can repeat them on its next attempt. Seed unique data per attempt, make cleanup resilient, and avoid using retries as the first response to a reset in the middle of a transaction. The report should preserve the failed first attempt because that is where the ambiguous write occurred.
Capture enough evidence to classify the first failure
When APIRequestContext cannot produce a response, Playwright throws from the request call. On a Node-based Linux runner, the useful fragment often includes ECONNRESET, sometimes alongside text such as read or socket hang up. The exact prefix and system-call wording can vary with platform and network layer. Match the error category for triage, but retain the complete text rather than asserting one frozen sentence.
A response-status failure looks different. With failOnStatusCode disabled, no exception occurs and response.status() contains the server's status. With it enabled, the thrown text describes the unexpected status, and server headers or body may still need separate logging before the call is changed. A request timeout identifies its timeout boundary. These shapes are close enough in a one-line CI summary that attaching structured context saves time.
import { randomUUID } from 'node:crypto';
import { expect, test } from '@playwright/test';
test('records request identity when a reset escapes the retry limit', async ({ request }, testInfo) => {
const requestId = randomUUID();
const url = '/api/catalog';
try {
const response = await request.get(url, {
headers: { 'x-test-request-id': requestId },
maxRetries: 1,
timeout: 10_000,
});
await testInfo.attach('api-result', {
body: Buffer.from(JSON.stringify({ requestId, url, status: response.status() })),
contentType: 'application/json',
});
expect(response.status()).toBe(200);
} catch (error) {
await testInfo.attach('api-error', {
body: Buffer.from(JSON.stringify({ requestId, url, error: String(error) }, null, 2)),
contentType: 'application/json',
});
throw error;
}
});The generated identifier connects test evidence to proxy or service logs if those systems record the header. It does not expose Playwright's internal attempt count through APIResponse; that field does not exist. In a production-like environment, search upstream logs for the identifier and compare timestamps and request handling. One inbound request followed by a reset points to a different layer than two inbound requests where the first connection closed before its response reached the client.
Be careful about secrets in attachments. Record the method, sanitized URL, request identifier, status, and error. Do not dump authorization headers, cookies, full personal payloads, or an unfiltered response body. A retry investigation rarely needs them, and HTML reports are often retained or shared beyond the service team.
The trace can show API activity and the test step that owned it, but traces do not replace service telemetry. A client knows that its connection failed. It may not know whether the server received or committed the request. Use the trace for chronology and the upstream logs for the other side of the boundary.
Compare first and retried test attempts. If attempt one shows ECONNRESET and attempt two passes without a request-level retry, the suite has demonstrated test rerun behavior, not safe request recovery. That distinction belongs in triage notes. Otherwise a team may believe a shared API client is resilient when only the test runner is repeating the workflow.
Test the retry budget as a failure policy
The success contract test proves one reset can recover. The companion policy test should prove the next reset escapes. Configure a local server to destroy both accepted connections, call the read helper with a budget of one, and assert that the promise rejects. Also assert that the server observed exactly two requests. If a later refactor silently raises the limit, the call count fails even though the final operation still rejects after more work.
Keep that exhausted-budget case local. Producing resets against a shared staging load balancer can disturb other suites and makes the oracle depend on infrastructure timing. A controlled server proves the client's cap. Separate environment monitoring proves whether the real proxy is resetting connections. Combining those jobs creates a test that is unsafe to run and difficult to reproduce.
Treat the request timeout as another independent limit. The official API documents both options but does not describe maxRetries as a timeout multiplier, and it does not publish the retry schedule as a contract. Avoid promising a calculated worst-case duration from the option values alone. Remember that the internal waits add to wall-clock time, so two retries spend at least 750 ms sleeping before the third attempt even starts. Measure the wrapper in a controlled test if runtime is a release concern, and put the suite's outer test timeout above the request operation with enough room for attachment and cleanup. If the outer test timeout fires first, the report can lose the network error the policy was meant to expose.
Define the release meaning before enabling the helper. A deployment smoke test may pass after one reset but annotate the run and alert the platform team. A contract test for a customer-facing availability target should fail on the first reset. A setup helper that reads immutable fixture data may retry because its purpose is preparing another assertion. One numeric option cannot express those three policies, so separate helpers or test annotations are clearer than a global default.
Review endpoints at the resource level. A GET may trigger lazy generation, cache population, audit records, or metered work. Repeating it might be technically idempotent in returned state while still costing money or loading a dependency. Ask the service owner what happens if the connection closes after the request arrives. If the answer is unknown, leave retries off until the contract is understood.
Proxy translation is another near-miss. Some gateways turn an upstream reset into an HTTP 502 or 503 before the response reaches Playwright. From APIRequestContext that is a completed HTTP response, so maxRetries correctly does nothing. Service and proxy logs may still contain an upstream reset, but the client policy sees a status. Decide at which layer recovery belongs instead of assuming the original low-level cause controls Playwright behavior.
Use the policy tests during upgrades. Pin the Playwright version in CI, run the one-reset recovery case and the two-reset exhaustion case, then inspect the official API notes before changing the wrapper. The documentation says “currently” only ECONNRESET, which leaves room for future behavior to evolve. A contract test will expose that evolution, while copied prose or a type check will not.
Retire the retry when its original cause disappears. If a proxy upgrade eliminates stale keep-alive resets, keeping the option everywhere continues to widen latency and side-effect boundaries without benefit. Remove it from a canary group first, watch reset evidence, and then simplify the shared helper. Resilience controls should have owners and review dates, not become permanent folklore.
Roll out one narrow policy and know when to refuse it
Centralize a safe read helper rather than scattering different retry counts across tests. Keep its name specific, its timeout finite, and its accepted methods narrow. A wrapper that only exposes GET makes reviewers ask before adding a write. It also gives the suite one place to change when Playwright's documented behavior changes.
import { test as base, type APIResponse } from '@playwright/test';
type ReadApi = {
resilientGet: (path: string) => Promise<APIResponse>;
};
export const test = base.extend<ReadApi>({
resilientGet: async ({ request }, use) => {
const get = (path: string) => request.get(path, {
maxRetries: 1,
timeout: 10_000,
});
await use(get);
},
});
export { expect } from '@playwright/test';Adopt the helper in a small group of read-only smoke tests first. Keep a count from service or proxy telemetry of how often resets occur, because the final response does not tell the test that a retry happened. If reset frequency grows, remove the tolerance from the release gate or escalate the infrastructure problem. A retry budget should not become permanent camouflage for a degrading network.
Prefer the built-in request fixture for test-scoped calls. Playwright owns its lifecycle and gives each test an isolated API request context. If a worker fixture creates a standalone context with playwright.request.newContext(), that fixture must dispose it during teardown. The API documentation notes that response resources remain available until disposal, so a long-lived context that downloads large bodies can create a separate memory problem while the team investigates resets.
Be explicit about cookie ownership. page.request and browserContext.request share the browser context's cookie jar, while a standalone request context has isolated cookies. A reset helper moved from one kind to the other can change authentication behavior even though its get() call looks identical. Record which client the suite wraps, and add an authentication contract test before migrating existing calls.
Leave failOnStatusCode visible at call sites that need a different HTTP policy. Baking it into a generic reset helper can turn an expected 404 assertion into an exception and make status failures resemble transport failures in a short report. A narrow read helper can supply the reset limit and timeout while the test remains responsible for acceptable response codes.
Set Playwright test retries independently. One test retry on CI can still be useful for collecting a trace on the retry while preserving the failed attempt. It should not be described as the reset policy. Reviewers need to see both settings and understand why each exists.
Do not use maxRetries for browser navigation, page fetch() calls, or UI requests and assume it will apply. This option belongs to APIRequestContext calls made by the test. Product browser traffic follows the application's networking stack and its own retry behavior. Testing that behavior requires observing the page request and user result, not configuring the test's separate API client.
Refuse the retry for a load test, latency test, fail-fast health assertion, or resilience test where the first reset is the signal. Refuse it for writes without verified idempotency. Refuse it for an endpoint that returns an HTTP error you need to investigate. Refuse it when the environment is misconfigured and the error is not ECONNRESET.
The cost is concrete. Every allowed retry can add another request's full timeout and another unit of load during failure. It can obscure a flaky proxy, complicate log correlation, and turn an ambiguous write into duplicate state. Used on one safe read with retained evidence, the option is a sharp tool. Applied to every API call, it changes what a green test means without telling the reader why.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official developer.mozilla.org reference
developer.mozilla.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
What errors does maxRetries handle in Playwright API tests?
Playwright currently retries only ECONNRESET network errors through this option. HTTP status codes, DNS failures, and request timeouts are not converted into retryable resets.
Does maxRetries retry a 503 response?
A 503 is an HTTP response, so maxRetries does not send the request again. Assert or handle the status according to the service contract, and do not increase reset retries to mask an unhealthy upstream service.
Is maxRetries safe for POST requests?
Safety depends on the endpoint, not the Playwright method signature. Use a retry only when the operation is idempotent or the service enforces an idempotency key that the test verifies.
How is a request retry different from a Playwright test retry?
One request retry stays inside the current test attempt and repeats the affected HTTP operation. A test retry reruns test setup and the whole test body, which has a wider side-effect boundary.
Can APIResponse tell me whether Playwright retried?
The response object does not expose a request-attempt count. Use a controlled reset server in a contract test, or correlate repeated attempts in upstream logs with a test-generated request identifier.
RELATED GUIDES
Continue the learning route
GUIDE 01
Test localStorage Directly with the Playwright API
Learn Playwright localStorage API testing through practical setup, failure analysis, CI evidence, security boundaries, and measurable release gates for QA and SDET teams.
GUIDE 02
Test sessionStorage Directly with the Playwright API
A practical guide to Playwright sessionStorage API testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 03
Assert API TLS Security Details with Playwright
Learn Playwright API response TLS security details with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.
GUIDE 04
Deterministic Timer and Date Tests with the Playwright Clock API
Control dates, timers, intervals, and inactivity flows with Playwright Clock API examples that keep time-dependent browser tests deterministic.
GUIDE 05
Use the Playwright Credentials API for Virtual Passkeys
Master Playwright Credentials API virtual authenticator with implementation examples, failure analysis, evidence design, CI controls, and release-ready QA checklists.