Test 401, 422, 429 and 500 handlers
Force any status code with ?force_status= to make sure your error UI never shows blank screens or eats stack traces.
Every team writes a happy path. Most teams forget about the error paths until something breaks in production. This recipe shows how to systematically exercise every error your client should handle by forcing status codes from the server, so you can verify the user sees something sensible no matter what comes back.
The trick
Append?force_status=N to any Go REST endpoint, and the API returns that status code instead of doing the real work. The response includesX-Simulated-Status: N, so you know the response was forced. The status code respects whatever your client expects: 422 returns a real validation-error body, 429 returns the rate-limit headers, and 500 returns the server-error body.
curl -i "https://gorest.co.in/public/v2/users?force_status=401"
curl -i "https://gorest.co.in/public/v2/users?force_status=422"
curl -i "https://gorest.co.in/public/v2/users?force_status=429"
curl -i "https://gorest.co.in/public/v2/users?force_status=500"
A handler that branches on every case
The pattern: branch on status before the body. 401 is its own thing (sign in again). 422 is its own thing (show field errors). 429 needs a retry. 4xx without a special case is "something looks wrong with the request". 5xx is "the server screwed up". Network errors are their own bucket too.
async function fetchUsers() {
try {
const res = await fetch("/public/v2/users?force_status=429", {
headers: { Authorization: `Bearer ${TOKEN}` }
});
if (res.status === 401) {
return showSignIn();
}
if (res.status === 422) {
const errors = await res.json();
return showFieldErrors(errors);
}
if (res.status === 429) {
const wait = +res.headers.get("x-ratelimit-reset") || 1;
return scheduleRetry(wait);
}
if (!res.ok) {
throw new Error(`Got ${res.status}`);
}
return await res.json();
} catch (err) {
showServerError(err.message);
}
}
Test it
Three integration tests, one per error type. Use theforce_status parameter so the test never depends on real failure conditions:
// tests/error-handler.test.js
import { test, expect } from "vitest";
test("401 redirects to sign-in", async () => {
const result = await fetchUsers({ forceStatus: 401 });
expect(window.location.pathname).toBe("/sign-in");
});
test("422 shows field errors", async () => {
const result = await fetchUsers({ forceStatus: 422 });
expect(screen.getByRole("alert")).toHaveTextContent(/can't be blank/i);
});
test("429 schedules retry", async () => {
const setTimeout = vi.spyOn(globalThis, "setTimeout");
await fetchUsers({ forceStatus: 429 });
expect(setTimeout).toHaveBeenCalled();
});
These tests are fast (no real failures, no waiting), deterministic (forced status codes do not vary), and cheap (do not burn rate-limit budget on real 429s).
Map status codes to user-facing messages
Showing"Got 422" in the UI is a signal that you have not thought about errors. Map every code to a sentence the user can understand and act on:
const ERROR_MESSAGES = {
400: "Something looks wrong with the request. Please refresh.",
401: "Your session expired. Please sign in again.",
403: "You do not have permission to do that.",
404: "We could not find what you were looking for.",
422: "Some fields need attention.",
429: "Slow down a moment. We are throttling requests.",
500: "Something broke on our side. Please try again.",
503: "We are briefly unavailable. Try in a moment."
};
function userFacingError(status) {
return ERROR_MESSAGES[status] || "Something went wrong.";
}
The 401 message implies the user should sign in. The 422 message implies they should look at the form. The 429 message implies they should wait. Each is actionable.
Build a "kitchen sink" page for QA
A small page in your app that lets QA force every error in turn is worth its weight. Buttons that hit the API with eachforce_status value, plus a third for "delay 5s", plus a fourth for "no network" (wrap fetch in a Promise that rejects). One review, every error path covered.
Common mistakes
- Catching too broadly. A blanket
try/catcharound the fetch turns 401 into "something went wrong", and the user gets a useless message. Branch on status first. - Eating the body. On 422, the body is the field errors; read it before showing a generic message.
- Retrying on 4xx. 4xx errors are caused by the request, so retrying gives you the same response. Only retry on 429, 503, and 5xx.
- Showing stack traces. Errors in production should not leak internal details. Use the message map; log the technical error somewhere only developers see.
Combine with simulated latency
Thedelay andforce_status parameters compose. Test "slow then 500":
fetch("/public/v2/users?delay=3000&force_status=500")This is the worst case for many UIs: a long wait, then an error. Use it to verify your skeleton + error transition does not flash, race, or leave the page in an inconsistent state.
Keep building
Test loading skeletons with simulated latency
Use ?delay=N to make Go REST take seconds to respond, so you can verify your skeleton screens render without flicker.
Build a paginated user list in React
A working component that fetches a page, renders rows, and uses X-Pagination-Pages to render Previous/Next correctly.
Build infinite scroll with IntersectionObserver
Append pages as the sentinel element scrolls into view. Works with any list endpoint that returns pagination headers.