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 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 GoRest endpoint, and the API returns that status code instead of doing the real work. The response includes X-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 case (sign in again). 422 is its own case (show field errors). 429 needs a retry. 4xx without a special case is "something looks wrong with the request". 5xx is "the server failed". Network errors are a separate case 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 the force_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 (they do not use up rate-limit budget on real 429s).
Map status codes to user-facing messages
Showing "Got 422" in the UI is a sign 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 tells the user to sign in. The 422 message tells them to look at the form. The 429 message tells them to wait. Each one says what to do.
Build a "kitchen sink" page for QA
A small page in your app that lets QA force every error in turn is very useful. Buttons that hit the API with each force_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
The delay and force_status parameters can be combined. 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 to 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 GoRest 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.