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.
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.
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
500reaches thecatchblock;fetchresolves. 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
401in the mock can trigger the real logout flow. Many applications wire a global interceptor that clears the session and redirects on401. Injecting one in a component test can therefore blow away the test’s own auth fixture and produce a confusing cascade. Scope401injection to specs that are specifically about re-authentication, and stub the redirect.
Related
- Simulating Network Latency in MSW — the delay half of the same fault vocabulary
- Testing Retry and Backoff Logic Locally — turning these single failures into recoverable sequences
- Best Practices for Dynamic Response Shaping — shaping the successful side of the same contract
← Back to Error & Latency Simulation