PRACTICAL GUIDE / Playwright failOnStatusCode requests

Make each API call fail at the line that matters

Choose Playwright status handling per API call, preserve useful negative-response evidence, and separate HTTP failures from transport errors.

By The Testing AcademyUpdated August 7, 202620 min read
All field guides
In this guide6 sections
  1. Know exactly where Playwright changes control flow
  2. Put strict and inspectable calls in the same runnable spec
  3. Prove whether the failure is HTTP, assertion, or transport
  4. Separate a service error from an intermediary error
  5. Watch for near-misses that produce the same red line
  6. Roll the policy through an existing suite without losing coverage
  7. Leave strict status handling off when the response is the evidence

What you will learn

  • Know exactly where Playwright changes control flow
  • Put strict and inspectable calls in the same runnable spec
  • Prove whether the failure is HTTP, assertion, or transport
  • Watch for near-misses that produce the same red line

A cleanup call returns 500 in CI, but the test keeps running and fails ten steps later with a missing record. The report points at a UI assertion even though the first broken contract was an HTTP response. On another day, the same endpoint must return 404 and throwing early would destroy the evidence the test was written to inspect.

That tension is why status handling belongs at the request that understands the response. A blanket rule sounds tidy. In a real suite, it either lets broken setup leak into the scenario or makes negative API tests awkward and less informative.

Know exactly where Playwright changes control flow

An HTTP server can answer successfully at the transport layer and still report an application failure. A 404 has a valid status line, headers, and usually a body. Nothing about receiving it means the TCP connection, TLS negotiation, or HTTP exchange failed. Playwright's APIRequestContext follows that distinction by default: methods such as get(), post(), and fetch() return an APIResponse for every HTTP status.

With the default behavior, this code reaches the second line even if the server sends 500:

TypeScript
const response = await request.get('/internal/health');
console.log(response.status());

The promise rejected only if the request itself could not complete, for example because DNS lookup failed, the connection was refused, the request timed out, or redirect handling exceeded its configured limit. An HTTP 500 is data. Your test must decide what that data means.

Passing failOnStatusCode: true changes that decision for one call. Playwright rejects the request promise when the final response status is outside the 2xx and 3xx ranges. Execution jumps to the nearest catch, or the test fails at the await when no code catches it. No APIResponse is assigned to the variable on that path, so code after the call cannot inspect the response with status(), json(), or text().

The option is available on the individual request methods. It can also be set when creating an isolated request context. The context value is a useful default; the call-site value is where exceptions to that policy should be visible. A suite can therefore make infrastructure helpers strict while allowing a test for a rejected business operation to collect the response.

There are two boundaries people often confuse with this one. First, response.ok() reports whether the status is between 200 and 299. Second, await expect(response).toBeOK() asserts that same successful range and can produce Playwright assertion output. Neither changes whether the request promise resolves. A 302 is accepted by failOnStatusCode: true, but response.ok() is false and toBeOK() fails. That difference matters in authentication tests, where an unexpected login redirect can otherwise look like a successful request.

Automatic redirects add another layer. API request methods follow redirects unless told not to. If /admin answers 302 and /login answers 200, the response you receive normally represents the final 200. Strict status handling sees no failure. To test the redirect itself, stop following with maxRedirects: 0, then assert status and location explicitly. Do not expect failOnStatusCode to reject a 3xx response because the documented range deliberately includes redirects.

The option does not retry HTTP errors. maxRetries addresses a narrow transport case, currently connection resets, rather than 429, 500, or any other status. Combining the names mentally leads to a bad diagnosis: raising maxRetries will not make a strict 503 request run again. If the product contract permits retrying 503, write that loop as domain logic with a limit and recorded attempts.

Put strict and inspectable calls in the same runnable spec

The following Playwright Test file creates its own local HTTP server, so it does not depend on a sample service or undocumented endpoint. It demonstrates two genuinely different jobs. The inventory lookup owns a 404 contract and needs the body. The seed operation is test setup, so a conflict must stop the test at the setup line.

TypeScript
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { test, expect } from '@playwright/test';

let server: Server;
let baseURL: string;

test.beforeAll(async () => {
  server = createServer((req, res) => {
    res.setHeader('content-type', 'application/json');

    if (req.method === 'GET' && req.url === '/inventory/SKU-404') {
      res.writeHead(404);
      res.end(JSON.stringify({
        code: 'ITEM_NOT_FOUND',
        itemId: 'SKU-404',
      }));
      return;
    }

    if (req.method === 'POST' && req.url === '/test-data/orders') {
      res.writeHead(409);
      res.end(JSON.stringify({
        code: 'ORDER_ALREADY_EXISTS',
        orderId: 'order-17',
      }));
      return;
    }

    res.writeHead(500);
    res.end(JSON.stringify({ code: 'UNEXPECTED_ROUTE' }));
  });

  await new Promise<void>((resolve) => {
    server.listen(0, '127.0.0.1', resolve);
  });

  const address = server.address() as AddressInfo;
  baseURL = `http://127.0.0.1:${address.port}`;
});

test.afterAll(async () => {
  await new Promise<void>((resolve, reject) => {
    server.close((error) => error ? reject(error) : resolve());
  });
});

test('the inventory API describes an unknown item', async ({ request }) => {
  const response = await request.get(`${baseURL}/inventory/SKU-404`, {
    failOnStatusCode: false,
  });

  try {
    expect(response.status()).toBe(404);
    expect(response.headers()['content-type']).toContain('application/json');
    await expect(response.json()).resolves.toEqual({
      code: 'ITEM_NOT_FOUND',
      itemId: 'SKU-404',
    });
  } finally {
    await response.dispose();
  }
});

test('test-data setup stops on a duplicate order', async ({ request }) => {
  await expect(request.post(`${baseURL}/test-data/orders`, {
    data: { orderId: 'order-17' },
    failOnStatusCode: true,
  })).rejects.toThrow(/409 Conflict/);
});

The first test says more than "the call failed." It proves the API uses the expected status, media type, error code, and resource identifier. If the service accidentally returns a proxy-generated HTML 404, the media-type assertion catches it before json() produces a distracting parse error. The finally block releases that response body even if one of the assertions fails.

The second test has no useful recovery path. Seed data is a precondition. Continuing after a 409 would run the scenario against an order that may have been created by another worker or a previous failed cleanup. Strict handling assigns the failure to the POST. The cost is reduced access to the structured error response, which is acceptable here because the scenario cannot proceed safely.

A codebase often needs both behaviors through one context. The next example sets a strict default on an isolated APIRequestContext, then opts one expected rejection back into normal response handling. It is also runnable as a Playwright spec when API_BASE_URL points to the service under test.

TypeScript
import { test, expect, request as apiRequest } from '@playwright/test';

test('strict helpers coexist with a negative permission check', async () => {
  const api = await apiRequest.newContext({
    baseURL: process.env.API_BASE_URL ?? 'http://127.0.0.1:3000',
    extraHTTPHeaders: {
      authorization: `Bearer ${process.env.API_TOKEN ?? 'local-test-token'}`,
    },
    failOnStatusCode: true,
  });

  try {
    const created = await api.post('/projects', {
      data: { name: `contract-${Date.now()}` },
    });
    const project = await created.json() as { id: string };
    await created.dispose();

    const forbidden = await api.delete(`/projects/${project.id}`, {
      headers: { authorization: 'Bearer read-only-test-token' },
      failOnStatusCode: false,
    });

    try {
      expect(forbidden.status()).toBe(403);
      await expect(forbidden.json()).resolves.toMatchObject({
        code: 'INSUFFICIENT_PERMISSION',
      });
    } finally {
      await forbidden.dispose();
    }
  } finally {
    await api.dispose();
  }
});

The endpoint names are ordinary application contracts rather than Playwright APIs. Substitute the paths and payloads from your service, but keep the ownership rule: setup inherits strict handling, while the negative assertion overrides it. The isolated context is disposed in finally, which also releases any response bodies a failed intermediate assertion did not dispose individually.

Avoid hiding this choice inside a wrapper named get or send. A reviewer should be able to tell why a 403 is inspected while a 500 from setup throws. If a wrapper is justified, give it a contract-shaped name such as seedProjectOrFail or fetchDeniedProject, and keep the status policy close to that behavior.

Prove whether the failure is HTTP, assertion, or transport

The line at which a test stops is the first useful clue. With strict handling, the stack points at request.get() or request.post(). With default handling followed by expect(response.status()).toBe(200), the stack points at the expectation. A connection refusal or timeout also points at the request call, so location alone is not enough.

Run the smallest failing test with Playwright's API debug channel enabled:

Shell
DEBUG=pw:api npx playwright test tests/api/orders.spec.ts --workers=1

Use --workers=1 only for diagnosis. It removes interleaved logs and shared-data races from the first pass, but it can conceal a concurrency defect if it becomes the permanent configuration. Once the request is understood, reproduce again with the normal worker count.

For a strict HTTP rejection, the error names the API request method and contains the status, such as 409 Conflict or 500 Internal Server Error. Current Playwright output also includes request and response details in its call log, with exact formatting varying by version. The important signature is that an HTTP response exists. Capture the status, URL, method, a safe correlation header, and a short redacted body in CI logs. Never dump authorization headers, cookies, or an unrestricted production response.

A useful application-side record for the earlier setup failure looks like this:

Example
request=POST http://127.0.0.1:4173/test-data/orders
status=409 Conflict
content-type=application/json
x-request-id=qa-ci-8f31c
body={"code":"ORDER_ALREADY_EXISTS","orderId":"order-17"}

Treat the text of Playwright's thrown error as diagnostic output, not as an application interface. A helper that uses a regular expression to extract a status from that message is brittle across Playwright releases and can confuse a status with a number in the URL. If later code needs the numeric status or an error field, request the response with strict handling disabled and assert those values directly. If later code only needs to stop, let the original exception keep its stack.

Server-side correlation closes the remaining gap. Send a test-run identifier in an application-approved header, then match it with the service log or gateway record. The server record can show whether a 500 came from the application, an upstream dependency, or a gateway timeout while Playwright saw the same HTTP status in every case. Keep that identifier free of secrets and stable across the single attempt. A random identifier regenerated inside a retry makes the first failure harder to trace.

Separate a service error from an intermediary error

Two strict requests can fail with the same method, URL, and 500 Internal Server Error line while having different owners. In one case the application accepted the request and raised an internal error. In the other, a reverse proxy or gateway generated the 500 because it could not obtain a usable upstream response. failOnStatusCode deliberately treats both as the same control-flow decision. The status is sufficient to stop setup, but it is not sufficient to assign the incident.

Read the response producer evidence before using the stack as a routing signal. A response generated by the application will often use the service's normal media type and stable error envelope. For example, the useful fields might be an application error code and a request identifier that also appears in that service's logs. An intermediary-generated response may instead have a generic body, a different media type, and only an edge or gateway identifier. The broken value is not simply “500.” It is “500 with no matching application receipt” or “500 with an application receipt that records a handled dependency failure.” A normal application receipt beside the failed attempt proves the request crossed the edge boundary, even if the application later failed.

Treat header names and body shape as clues, not verdicts. Gateways can be configured to emit JSON that resembles an application response. Applications can fall back to HTML during an error-handler failure. A server header can be removed, rewritten, or shared by several layers. Status text is especially misleading because Internal Server Error says nothing about which process selected the status. The precise separator is a correlated sequence across the ingress record and the application record. If the ingress saw the attempt and the application did not, the gateway or the path to its upstream owns the next investigation. If both saw it under the same identity, the application team can inspect its own outcome and dependency calls.

For one diagnostic canary, leave strict handling disabled long enough to record the numeric status, final URL, content type, a safe bounded body excerpt, and approved correlation identifiers, then fail with explicit assertions. That canary costs extra assertion and redaction code, and its behavior differs from the strict helper used by the rest of setup. Keep it narrow. Do not issue a second request merely to obtain a body, especially for a write operation, because the duplicate can change state and erase the original evidence.

The test owner should make the first handoff, because that owner can tie the response to the exact attempt. Include the UTC time window, test and retry identity, method, sanitized origin and path, observed status, final URL, content type, bounded redacted excerpt, and every safe correlation value. The gateway team adds the ingress disposition and upstream selection. The service team adds whether its handler received the request and which stable error code it produced. This packet prevents three teams from rerunning a flaky write with different identifiers and comparing unrelated events.

This separation does not catch a semantically wrong success. If the intermediary serves a cached 200, or the application returns 200 with an error document, strict status handling stays quiet. Content and state assertions still own that class of failure.

That is an HTTP rejection. Compare it with a transport failure:

Example
apiRequestContext.post: connect ECONNREFUSED 127.0.0.1:4173
Call log:
  - → POST http://127.0.0.1:4173/test-data/orders

No response status, response headers, or application error body exists in the second case. Switching failOnStatusCode cannot fix it because the option is evaluated only after a response arrives. Check service startup, host binding, container networking, TLS trust, proxy settings, and request timeout instead.

An assertion failure has a third shape. The call returns an APIResponse, and Playwright reports expected and received values from expect. For toBeOK(), the failure attaches the response log because the assertion operates on the returned object. This is often better evidence for a direct API test than an early throw, especially when the response body explains a validation rule.

Trace Viewer helps only when a trace was recorded for the Playwright Test run. Open the retained trace with:

Shell
npx playwright show-trace test-results/api-orders-retry1/trace.zip

Look in the test step timeline for the API request call and the step immediately after it. If the request step is red and there is no assertion step, strict handling or a transport error stopped control flow. Inspect the request's status and response details to separate those cases. If the request step completed and an expectation is red, the response was returned and the assertion rejected its content. A trace cannot manufacture a missing server response; absent status data remains evidence of a lower-level failure.

Keep the first failing attempt when retries are enabled. A retry that receives 200 after an initial 503 says the service or environment was unstable. It does not convert the original request into a correct result. The report should preserve both attempts and the request identifier from the first one.

Watch for near-misses that produce the same red line

Authentication redirects are the most common false reassurance. An expired token hits /api/profile, the gateway sends 302 to /login, and the login page returns 200. Strict status handling does not throw because the final status is successful. Parsing the HTML as JSON then fails, or a weak test checks only for 200 and passes. Record the final URL with response.url(), assert the expected content type, and test authentication endpoints with a contract-specific assertion.

Here is a complete local example that exposes both redirect behaviors:

TypeScript
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { test, expect } from '@playwright/test';

test('an authentication redirect is not an HTTP-status exception', async ({ request }) => {
  const server = createServer((req, res) => {
    if (req.url === '/private') {
      res.writeHead(302, { location: '/login' });
      res.end();
      return;
    }

    res.writeHead(200, { 'content-type': 'text/html' });
    res.end('<h1>Sign in</h1>');
  });

  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address() as AddressInfo;
  const origin = `http://127.0.0.1:${port}`;

  try {
    const followed = await request.get(`${origin}/private`, {
      failOnStatusCode: true,
    });
    expect(followed.status()).toBe(200);
    expect(followed.url()).toBe(`${origin}/login`);
    expect(followed.headers()['content-type']).toContain('text/html');
    await followed.dispose();

    const redirect = await request.get(`${origin}/private`, {
      failOnStatusCode: true,
      maxRedirects: 0,
    });
    expect(redirect.status()).toBe(302);
    expect(redirect.headers().location).toBe('/login');
    await redirect.dispose();
  } finally {
    await new Promise<void>((resolve, reject) => {
      server.close((error) => error ? reject(error) : resolve());
    });
  }
});

A malformed JSON body is another near-miss. Status 200 passes strict handling, then response.json() throws a parsing error. The response is present and the server contract is still broken, but changing status policy will not improve the message. Assert content-type, retain a bounded text sample when parsing fails, and report that the representation is invalid.

Timeouts can be mistaken for status failures because both reject the same awaited call. Search for a numeric HTTP status in the error and check whether the server logged a request identifier. A timeout after headers but before the complete body may appear differently from a connection timeout, yet neither becomes a 5xx automatically. Do not relabel infrastructure timeouts as server errors without a response.

Finally, a 204 response can break helpers that blindly parse every successful body as JSON. Strict handling rightly accepts 204. json() then fails because there is no document to parse. Test the status first and skip body parsing when the endpoint contract says "No Content."

Roll the policy through an existing suite without losing coverage

Do not flip a context-wide default across hundreds of tests in one commit and fix whatever turns red. That migration changes control flow, stack locations, cleanup execution, response disposal, and sometimes the diagnostic artifacts people rely on. Inventory call sites by purpose before changing behavior.

A focused search gives the review team a concrete starting set without changing execution:

Shell
rg -n "request\.(get|post|put|patch|delete|head|fetch)\(" tests e2e

Do not count matches and call the inventory complete. Follow fixture aliases and wrappers far enough to identify the owning operation. A call named client.post() may still be Playwright, while a helper named createOrder() may hide three Playwright calls. Record the current context default and any method-level override beside each owner.

Classify the calls by the decision their caller can make:

Call roleUsual status policyEvidence that must remain
Seed required test dataStrictOperation name, URL, status in the thrown diagnostic, correlation ID
Remove data in teardownStrict, inside broader cleanup protectionOriginal test failure plus cleanup failure, without one replacing the other
Verify a 4xx contractInspectableExact status, stable error code, safe body fields
Poll an asynchronous jobInspectableOrdered statuses, attempt count, elapsed limit
Verify a redirectInspectable with redirects disabled3xx status and a constrained location value
Survey several health endpointsInspectable and aggregatedOne bounded row per endpoint, including transport failures separately

Teardown deserves special care because a strict cleanup exception can mask the product failure that triggered cleanup. If the test runner or helper collects multiple errors, retain both. Otherwise catch the cleanup error long enough to attach it safely, then rethrow according to the suite's established policy. Never turn cleanup failure into a warning by default; leaked data can contaminate later workers. The goal is two visible failures with clear ownership, not a green result or a replaced stack.

Start with setup and cleanup helpers. These calls create users, seed orders, reset feature flags, or remove test data. Their callers normally cannot continue meaningfully after 4xx or 5xx. Add failOnStatusCode: true at the individual call, run the relevant project, and confirm the error now points at the helper. Check cleanup inside finally; an early throw must not skip release of a browser context, request context, server, or already-created resource.

Next, classify contract tests that expect 4xx responses. Make their intent explicit with failOnStatusCode: false, even though false is the default. The extra text earns its place during migration because it tells a reviewer that the non-success response is deliberate. Assert the exact status and a stable error field. Avoid snapshotting an entire error object when it contains timestamps, localized messages, or request IDs.

Then inspect polling code. A job endpoint may legitimately return 404 until replication catches up, 202 while work is queued, and 200 when complete. Strict handling on the 404 call would force expected state into exception control flow. Keep the response inspectable and implement a bounded poll that records each status. The trade-off is more code and more decisions at the caller, but those decisions are the business protocol you are testing.

Only after those groups are clear should you consider a strict context default. A default reduces omission risk for new helper calls, but each negative test must override it. Put the policy in one request fixture, document the exception pattern, and add review checks for new raw request calls. Do not create two nearly identical request fixtures whose only difference is an unexplained boolean; name them by role if both are necessary.

Measure the rollout with failure quality, not just pass rate. Sample CI failures before and after. A useful result has fewer downstream null records, fewer JSON parse errors caused by HTML error pages, and more failures attached to the first broken API call. Track how many negative tests lost their response-body assertions, because that is a regression even if the suite remains red in the right place.

Strict calls have a maintenance cost. They reduce bespoke expect(status) lines for preconditions, but they couple helper behavior to Playwright's accepted 2xx and 3xx ranges. They also turn recoverable application states into exceptions unless overridden. Explicit assertions cost several lines per call and may allow execution to continue if a developer forgets them. Choose the failure boundary based on what the caller can responsibly do next.

Leave strict status handling off when the response is the evidence

Negative contract tests are the clearest case. If the purpose is to verify a 422 validation document, a 401 challenge, a 403 authorization rule, or a 404 resource representation, you need the APIResponse. Returning it is not leniency. It is access to the subject under test.

Redirect tests also need response inspection. Since 3xx is accepted by the option anyway, a status assertion is the only precise expression of the contract. Pair maxRedirects: 0 with checks for the exact status and safe parts of the location header. A test that merely avoids an exception proves almost nothing.

State-machine and eventual-consistency checks should handle documented intermediate statuses as values. Throwing and catching on every poll adds stack construction, noisy logs, and exception paths for normal states. The cost of leaving strict handling off is that every terminal error needs an explicit branch. Pay that cost in one well-tested polling helper, not through scattered loops.

Bulk diagnostic probes are another exception. A test that calls twenty regional health endpoints may need to collect all statuses before failing once with a table. Strict requests would stop on the first region and hide the scope of an outage. Keep each response, extract only bounded evidence, dispose it, and assert the aggregated result after all calls finish. This consumes more time and network capacity than failing fast, so reserve it for diagnostics that genuinely need the full set.

Do not use the option as a substitute for a contract assertion. A strict call accepts any 2xx or 3xx status. It cannot tell 200 from an erroneous 204, validate a schema, detect a login page reached through redirects, or prove that a deletion returned the promised 202. For direct API tests, explicit status and body checks remain the product evidence. For setup plumbing, failing at the request line is usually the better bargain.

// 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.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

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.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Does failOnStatusCode treat a 404 as a failed Playwright request?

Set `failOnStatusCode: true` on that call and Playwright rejects the request promise for a 404. Leave it false when the 404 is the response your test needs to inspect, then assert its status and body explicitly.

Is failOnStatusCode the same as expect(response).toBeOK()?

`expect(response).toBeOK()` is an assertion made after an `APIResponse` has been returned, and it accepts only 2xx responses. The request option changes control flow earlier and accepts both 2xx and 3xx statuses.

Can I override failOnStatusCode for one API request?

A method-level value applies to that individual `get`, `post`, `fetch`, or other API call. This lets a suite use a strict context default while opting a negative-contract check back into response inspection with `false`.

Why did failOnStatusCode not catch a redirect?

Redirects are not failures under this option because Playwright defines success here as any 2xx or 3xx status. Requests also follow redirects by default, so use `maxRedirects: 0` and assert the 3xx status when the redirect itself is the contract.

Should every API request use failOnStatusCode true?

Keep it disabled for tests whose subject is an error response, a polling transition, or a redirect. Enable it for setup, cleanup, and helper calls where no caller can make a useful decision from a non-success response.