Returning HTTP 500 Errors on Demand

The error banner in your application has never been seen by anyone on the team, because the mock always returns 200. This page shows how to flip any endpoint into a 500, 429 or 503 — from a test, a request header, or the browser console — without editing handler code each time, and how to write the error body so the client’s real branching logic runs.

Context: why a bare status is not enough

The instinct is to return HttpResponse.json({}, { status: 500 }) and call it done. That does exercise the status check, but it skips almost everything else the client does with an error. Real applications read an error code to decide which message to show, a retryable flag to decide whether to retry, and Retry-After to decide how long to wait. A body-less 500 leaves all of that untested, and the first real outage becomes the first execution of that code.

There is a second trap, specific to fetch. A 500 response is a successful fetch: the promise resolves, response.ok is false, and nothing throws. Code written as try { await fetch(...) } catch { showError() } will never show the error. A test that asserts “the promise rejects” therefore passes on broken code and fails on correct code — the exact inversion you do not want. Assert on the rendered error surface instead.

Why a catch block never sees a 500 A response with status 500 enters the fetch call. The promise resolves rather than rejecting, so the catch branch is skipped entirely and the code falls through to the success path where response.ok is false. Only an explicit ok check routes it to the error surface. A parallel path shows a transport failure, which does reject and does reach the catch branch. 500 response with an error body transport failure connection reset await fetch() one call, two fates promise RESOLVES catch is skipped entirely promise REJECTS catch runs — TypeError if (!response.ok) the only route across error surface what the test asserts on Delete the dashed check and the 500 path renders an empty success view — which is why the assertion belongs on the surface, not on the promise.

Solution

1. Model the error envelope once

Put the shape in one place so every injected error is indistinguishable from a real one:

// src/mocks/errors.ts
import { HttpResponse } from 'msw';

export interface ApiError {
  error: string;
  message: string;
  retryable: boolean;
  requestId: string;
}

const CATALOGUE: Record<number, Omit<ApiError, 'requestId'>> = {
  400: { error: 'invalid_request',      message: 'The request body failed validation.', retryable: false },
  401: { error: 'unauthenticated',      message: 'The access token is missing or expired.', retryable: false },
  403: { error: 'forbidden',            message: 'This account cannot access that resource.', retryable: false },
  404: { error: 'not_found',            message: 'No resource exists at that identifier.', retryable: false },
  409: { error: 'conflict',             message: 'The resource was modified by another writer.', retryable: false },
  429: { error: 'rate_limited',         message: 'Too many requests. Slow down.', retryable: true },
  500: { error: 'internal_error',       message: 'An unexpected error occurred upstream.', retryable: true },
  503: { error: 'service_unavailable',  message: 'The upstream service is temporarily unavailable.', retryable: true },
};

export function errorResponse(status: number, counter = 0): Response {
  const body = CATALOGUE[status] ?? CATALOGUE[500];
  return HttpResponse.json(
    { ...body, requestId: `mock_${status}_${counter.toString(36)}` },
    {
      status,
      headers: status === 429 ? { 'Retry-After': '2' } : undefined,
    }
  );
}

The requestId is not decoration. Real APIs return one, real support tickets quote it, and real UIs display it — so if your error component is supposed to show it, the mock has to supply it or the component’s fallback path is what you end up testing.

2. Select the status per request

A header check inside the resolver gives per-request control, which is what makes parallel test files safe:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { errorResponse } from './errors';

export const handlers = [
  http.get('https://api.example.com/orders/:id', ({ request, params }) => {
    const forced = Number(request.headers.get('x-mock-status') ?? '0');
    if (forced >= 400) return errorResponse(forced);

    return HttpResponse.json({ id: params.id, status: 'paid', total: 4250 });
  }),
];

Any caller can now demand a failure without touching the mock:

curl -i http://localhost:5173/api/orders/ord_1 -H 'x-mock-status: 503'

And from the browser console, to see the error banner without restarting anything:

await fetch('/api/orders/ord_1', { headers: { 'x-mock-status': 500 } });

3. Override the handler inside a single test

For specs, a one-shot override is cleaner than a header because it also documents the intent at the point of use:

// src/features/orders/OrderDetail.error.test.tsx
import { expect, it } from 'vitest';
import { http } from 'msw';
import { render, screen } from '@testing-library/react';
import { server } from '../../mocks/server';
import { errorResponse } from '../../mocks/errors';
import { OrderDetail } from './OrderDetail';

it('shows the retryable error surface when the API returns 503', async () => {
  server.use(
    http.get('https://api.example.com/orders/:id', () => errorResponse(503), {
      once: true,
    })
  );

  render(<OrderDetail id="ord_1" />);

  expect(await screen.findByRole('alert')).toHaveTextContent(
    /temporarily unavailable/i
  );
  expect(screen.getByRole('button', { name: /try again/i })).toBeEnabled();
});

The { once: true } option is what keeps the override honest: the handler applies to the first matching request and then falls back to the default. A retry inside the component therefore succeeds, which lets one spec assert both that the error appeared and that recovery works. Pair it with the shared afterEach(() => server.resetHandlers()) from your setup file and no override can leak into a neighbouring spec — the discipline covered in mock lifecycle management.

4. Fail the first N attempts, then recover

A counter in the closure turns a constant failure into a sequence, which is the shape retry logic actually has to survive:

// src/mocks/handlers.ts
let attempts = 0;
export const resetAttempts = () => { attempts = 0; };

http.get('https://api.example.com/orders/:id', ({ params }) => {
  attempts += 1;
  if (attempts <= 2) return errorResponse(503, attempts);
  return HttpResponse.json({ id: params.id, status: 'paid', total: 4250 });
});

Call resetAttempts() in beforeEach and the sequence is deterministic per spec. This is the foundation the retry and backoff testing page builds on.

The attempt counter as a two-state sequence Three request columns share one counter. Attempt one increments the counter to 1 and returns 503. Attempt two increments it to 2 and returns 503 again. Attempt three increments it to 3, crosses the threshold, and returns the successful order payload. A reset call in beforeEach returns the counter to zero so the next spec sees the same sequence. attempts = 0 (set by beforeEach) Attempt 1 attempts → 1 503 service_unavailable retryable: true Attempt 2 attempts → 2 503 service_unavailable still under the threshold Attempt 3 attempts → 3 200 order payload threshold crossed backoff backoff resetAttempts() in beforeEach — without it the second spec starts at 3 and never sees a failure at all. Assert on both halves: the error surface appeared, and it was replaced by real content. Status codes, grouped by the client behaviour they demand Three grouped columns. Non-retryable client errors (400, 401, 403, 404, 409) must surface immediately with a specific message. Retryable server conditions (500, 502, 503) should be retried with exponential backoff. The rate-limit case (429) is retryable but must honour the Retry-After header rather than using its own backoff schedule. Never retry Retry with backoff Retry when told to 400 · 401 · 403 · 404 · 409 The request itself is wrong. Retrying it changes nothing except the load on the API. Surface a specific message and an action the user can take. 500 · 502 · 503 The request was fine; the server was not. The same call may well succeed shortly. Exponential backoff with jitter, bounded attempt count. 429 Retryable, but the server has already stated the interval in the Retry-After header. Honour the header; ignoring it is what turns a limit into a ban. Your mock catalogue should carry one endpoint per column, because each column is a different branch in the client. A test suite that only ever injects 500 leaves the left and right columns unproven.

Verification

# The header override reaches the resolver and the body is well formed
curl -s http://localhost:5173/api/orders/ord_1 -H 'x-mock-status: 429' -D - -o - \
  | grep -E 'HTTP/|Retry-After|rate_limited'

# The error specs pass and no override leaks into the next file
npx vitest run src/features/orders --reporter=verbose

The curl output should show the 429 status line, a Retry-After: 2 header, and rate_limited in the body. If the status is 200, the header name is being lost — check that no proxy in front of the dev server strips unknown x- headers.

Gotchas and edge cases

  • server.use() without { once: true } persists for the whole file. Every subsequent request in that file gets the error, including ones written months later by someone else. Use the one-shot form by default and reach for the persistent form only when the whole file is about the failure mode.

  • Axios and fetch disagree about what an error is. Axios rejects on any non-2xx by default, so a 500 reaches the catch block; fetch resolves. If your codebase mixes both, an error test written against one client silently proves nothing about the other. Assert on the rendered surface, which is client-agnostic.

  • A 401 in the mock can trigger the real logout flow. Many applications wire a global interceptor that clears the session and redirects on 401. Injecting one in a component test can therefore blow away the test’s own auth fixture and produce a confusing cascade. Scope 401 injection to specs that are specifically about re-authentication, and stub the redirect.


← Back to Error & Latency Simulation