Skip to content

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

More recipes

Keep building