JSON API Error Handling: RFC 7807, TypeScript & Retry Logic

Last updated:

JSON API error handling requires a consistent error response format — RFC 7807 Problem Details defines a standard JSON error body with type, title, status, detail, and instance fields. HTTP status code 400 signals invalid client request syntax; 422 Unprocessable Entity signals valid syntax but semantic validation failure (wrong field type, value out of range). Mixing these semantics in an API makes client error handling impossible to automate.

This guide covers RFC 7807 Problem Details format, HTTP status code semantics, discriminated union error types in TypeScript, retry logic for transient errors (503, 429), Axios interceptors for global error handling, and error boundary patterns in React. Every pattern includes client and server TypeScript implementations.

RFC 7807 Problem Details: The Standard JSON Error Format

RFC 7807 (updated by RFC 9457) defines a standard JSON error response body that every HTTP API can adopt without inventing a custom format. The five standard fields are type (a URI identifying the error class), title (a stable human-readable summary), status (the HTTP status code mirrored in the body), detail (an explanation specific to this occurrence), and instance (a URI for this specific error, typically a request trace ID). The Content-Type for RFC 7807 responses is application/problem+json.

// ── RFC 7807 Problem Details response body ───────────────────────
// Content-Type: application/problem+json
{
  "type":     "https://api.example.com/errors/validation-failed",
  "title":    "Validation Failed",
  "status":   422,
  "detail":   "The 'email' field is not a valid email address.",
  "instance": "/requests/b7f3a1c2-9e12-4d5f-8a0b-3e6f1c2d4a5b"
}

// ── Server implementation (Express / Node.js) ─────────────────────
import express, { Request, Response, NextFunction } from "express";

interface ProblemDetails {
  type: string;
  title: string;
  status: number;
  detail: string;
  instance?: string;
  [key: string]: unknown; // allow extension fields
}

class ApiError extends Error {
  constructor(
    public readonly problem: ProblemDetails,
  ) {
    super(problem.detail);
    this.name = "ApiError";
  }
}

// Middleware: convert ApiError → RFC 7807 response
function problemDetailsMiddleware(
  err: unknown,
  req: Request,
  res: Response,
  _next: NextFunction,
): void {
  if (err instanceof ApiError) {
    res
      .status(err.problem.status)
      .setHeader("Content-Type", "application/problem+json")
      .json(err.problem);
    return;
  }

  // Fallback for unexpected errors — never leak internals
  res
    .status(500)
    .setHeader("Content-Type", "application/problem+json")
    .json({
      type:     "https://api.example.com/errors/internal-error",
      title:    "Internal Server Error",
      status:   500,
      detail:   "An unexpected error occurred. Please try again later.",
      instance: req.path,
    });
}

// Usage in route handler
app.post("/users", async (req: Request, res: Response, next: NextFunction) => {
  const result = validateUser(req.body);
  if (!result.success) {
    return next(new ApiError({
      type:     "https://api.example.com/errors/validation-failed",
      title:    "Validation Failed",
      status:   422,
      detail:   `${result.errors.length} field(s) failed validation.`,
      instance: req.path,
      errors:   result.errors,   // extension field — field-level details
    }));
  }
  // ... create user
});

app.use(problemDetailsMiddleware);

// ── Client-side: check Content-Type before parsing ────────────────
async function handleApiResponse(res: Response): Promise<never> {
  const contentType = res.headers.get("content-type") ?? "";
  if (
    contentType.includes("application/problem+json") ||
    contentType.includes("application/json")
  ) {
    const problem: ProblemDetails = await res.json();
    throw new ApiError(problem);
  }
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}

The type URI must be stable and dereferenceable — host it at that URL with documentation describing the error class, its causes, and remediation steps. Clients use type to programmatically identify and branch on error classes, so changing it is a breaking API change. The instance field should contain a correlation or trace ID that links back to your observability platform — this makes debugging production errors from client-reported errors dramatically faster. Extension fields (like errors for field-level details) are explicitly allowed by RFC 7807 — add them as needed without breaking standard clients. See our guide on JSON API design for broader API design patterns.

HTTP Status Code Semantics for JSON APIs

Correct HTTP status code selection is the foundation of automatable error handling. Clients use status codes to decide whether to retry, re-authenticate, fix the request, or surface the error to the user — they cannot do this reliably if your API returns 400 for every error category. The key distinctions: 4xx codes are client errors (the client must fix something), 5xx codes are server errors (the server must be fixed or the client should retry).

// ── HTTP status code reference for JSON APIs ─────────────────────

// ── 4xx Client Errors (do NOT retry) ─────────────────────────────
// 400 Bad Request       — malformed JSON, wrong Content-Type, unparseable body
// 401 Unauthorized      — missing or invalid auth credentials
// 403 Forbidden         — valid credentials but insufficient permissions
// 404 Not Found         — resource does not exist
// 405 Method Not Allowed — wrong HTTP verb for this endpoint
// 409 Conflict          — resource state conflict (duplicate, version mismatch)
// 410 Gone              — resource permanently deleted
// 415 Unsupported Media Type — Content-Type not accepted (e.g., sent XML to JSON API)
// 422 Unprocessable Entity — valid JSON but fails business/schema validation
// 429 Too Many Requests — rate limit exceeded (check Retry-After header)

// ── 5xx Server Errors (may retry with backoff) ────────────────────
// 500 Internal Server Error — unexpected crash (do NOT retry by default)
// 502 Bad Gateway       — upstream service unreachable (transient, retry)
// 503 Service Unavailable — server overloaded or in maintenance (retry)
// 504 Gateway Timeout   — upstream timed out (retry)

// ── Decision tree in TypeScript ──────────────────────────────────
type ErrorAction = "fix-request" | "re-auth" | "retry" | "fatal";

function classifyStatusCode(status: number): ErrorAction {
  if (status === 401)               return "re-auth";
  if (status === 429)               return "retry";   // obey Retry-After
  if (status >= 400 && status < 500) return "fix-request";
  if ([502, 503, 504].includes(status)) return "retry";
  return "fatal"; // 500 and unknown 5xx — do not retry blindly
}

// ── 400 vs 422 in practice ────────────────────────────────────────
import { Request, Response } from "express";

function handleUserCreate(req: Request, res: Response) {
  // 400: JSON parse failed — body is not valid JSON
  let body: unknown;
  try {
    body = JSON.parse(req.body);
  } catch {
    return res.status(400).json({
      type:   "https://api.example.com/errors/malformed-json",
      title:  "Malformed JSON",
      status: 400,
      detail: "Request body could not be parsed as JSON.",
    });
  }

  // 422: JSON parsed but fails schema validation
  const result = UserSchema.safeParse(body);
  if (!result.success) {
    return res.status(422).json({
      type:   "https://api.example.com/errors/validation-failed",
      title:  "Validation Failed",
      status: 422,
      detail: "One or more fields failed validation.",
      errors: result.error.issues.map(issue => ({
        pointer: "/" + issue.path.join("/"),
        code:    issue.code,
        message: issue.message,
      })),
    });
  }

  // 409: JSON valid, user already exists
  const existing = db.findUser(result.data.email);
  if (existing) {
    return res.status(409).json({
      type:   "https://api.example.com/errors/duplicate-email",
      title:  "Email Already Registered",
      status: 409,
      detail: `An account with email ${result.data.email} already exists.`,
    });
  }
}

A common mistake is returning 500 for validation errors that happen to throw an exception internally — always catch validation errors before the generic error handler and return the appropriate 4xx code. Another mistake is returning 200 with an {"{ "success": false }"} body — this prevents HTTP clients from using status codes and breaks caching, monitoring, and retry logic. Reserve 200 strictly for successful responses. For JSON security, never include stack traces or database error messages in error responses — they leak implementation details useful to attackers.

Validation Error Details: Field-Level Error Arrays

A 422 response body is most useful when it identifies exactly which fields failed and why. The RFC 7807 extension pattern adds an errors array to the Problem Details body, where each entry contains a JSON Pointer (/user/email), a machine-readable error code, and a human-readable message. This allows client-side form libraries to map server validation errors directly to input fields without fragile string parsing.

// ── Field-level error response body (RFC 7807 extension) ─────────
{
  "type":   "https://api.example.com/errors/validation-failed",
  "title":  "Validation Failed",
  "status": 422,
  "detail": "3 fields failed validation.",
  "instance": "/users",
  "errors": [
    {
      "pointer": "/email",
      "code":    "invalid_format",
      "message": "Must be a valid email address."
    },
    {
      "pointer": "/age",
      "code":    "out_of_range",
      "message": "Must be between 0 and 150."
    },
    {
      "pointer": "/username",
      "code":    "too_short",
      "message": "Must be at least 3 characters.",
      "params":  { "min": 3, "actual": 1 }
    }
  ]
}

// ── Server: zod schema → field-level errors ───────────────────────
import { z } from "zod";
import { Request, Response } from "express";

const UserSchema = z.object({
  email:    z.string().email(),
  age:      z.number().int().min(0).max(150),
  username: z.string().min(3).max(50),
});

interface FieldError {
  pointer: string;
  code:    string;
  message: string;
  params?: Record<string, unknown>;
}

function zodErrorsToFieldErrors(zodError: z.ZodError): FieldError[] {
  return zodError.issues.map(issue => ({
    pointer: "/" + issue.path.join("/"),
    code:    issue.code,
    message: issue.message,
    // Include validation params for client-side messaging
    ...(issue.code === "too_small" && { params: { min: issue.minimum } }),
    ...(issue.code === "too_big"   && { params: { max: issue.maximum } }),
  }));
}

function createUser(req: Request, res: Response) {
  const result = UserSchema.safeParse(req.body);
  if (!result.success) {
    const fieldErrors = zodErrorsToFieldErrors(result.error);
    return res.status(422).setHeader("Content-Type", "application/problem+json").json({
      type:     "https://api.example.com/errors/validation-failed",
      title:    "Validation Failed",
      status:   422,
      detail:   `${fieldErrors.length} field(s) failed validation.`,
      instance: req.path,
      errors:   fieldErrors,
    });
  }
  // ... proceed with result.data
}

// ── Client: map field errors to React Hook Form ───────────────────
import { useForm } from "react-hook-form";

async function onSubmit(data: UserFormData) {
  const res = await fetch("/api/users", {
    method:  "POST",
    headers: { "Content-Type": "application/json" },
    body:    JSON.stringify(data),
  });

  if (!res.ok) {
    const problem = await res.json();
    if (problem.errors) {
      // Map RFC 6901 JSON Pointer → form field name
      for (const err of problem.errors as FieldError[]) {
        const fieldName = err.pointer.replace(/^\//, "") as keyof UserFormData;
        setError(fieldName, { type: err.code, message: err.message });
      }
    }
    return;
  }
  // handle success
}

JSON Pointer (RFC 6901) uses a leading slash and slash-separated path segments — /user/address/city maps to body.user.address.city. React Hook Form, Formik, and most form libraries accept a field name string, so strip the leading slash and replace internal slashes with dots or the library-specific separator. The params extension field carries validation metadata that clients can use to generate localised error messages without hardcoding English strings — for example, a client receiving {"{ "min": 3 }"} can render "Must be at least 3 characters" in any language. See our guide on JSON Schema validation for schema definition patterns.

TypeScript Discriminated Unions for JSON Error Types

TypeScript discriminated unions model the full set of possible API error types so that every handler is forced to deal with every case. The discriminant field (type) must be a literal string type — TypeScript uses it to narrow the union in switch and if statements. An exhaustive switch with a never-typed default catches missing cases at compile time rather than at runtime.

// ── Discriminated union error types ──────────────────────────────
interface FieldError {
  pointer: string;
  code:    string;
  message: string;
}

// Each variant has a unique literal "type" — the discriminant
type NetworkError = {
  type:      "network";
  message:   string;
  retryable: true;
};

type ValidationError = {
  type:      "validation";
  fields:    FieldError[];
  detail:    string;
  retryable: false;
};

type AuthError = {
  type:      "auth";
  message:   string;
  retryable: false; // re-authenticate, not retry
};

type RateLimitError = {
  type:       "rate-limit";
  retryAfter: number; // seconds
  retryable:  true;
};

type ServerError = {
  type:      "server";
  status:    number;
  retryable: boolean;
};

type ApiError =
  | NetworkError
  | ValidationError
  | AuthError
  | RateLimitError
  | ServerError;

// ── Parse a fetch response into a typed ApiError ──────────────────
async function parseApiError(res: Response): Promise<ApiError> {
  const status = res.status;

  if (status === 0 || !res.ok && status === undefined) {
    return { type: "network", message: "Network request failed", retryable: true };
  }

  const body = await res.json().catch(() => ({}));

  if (status === 401 || status === 403) {
    return { type: "auth", message: body.detail ?? "Authentication failed", retryable: false };
  }

  if (status === 422) {
    return { type: "validation", fields: body.errors ?? [], detail: body.detail ?? "", retryable: false };
  }

  if (status === 429) {
    const retryAfter = Number(res.headers.get("retry-after") ?? "60");
    return { type: "rate-limit", retryAfter, retryable: true };
  }

  if ([502, 503, 504].includes(status)) {
    return { type: "server", status, retryable: true };
  }

  return { type: "server", status, retryable: false };
}

// ── Exhaustive switch — TypeScript enforces all cases ─────────────
function assertNever(value: never): never {
  throw new Error(`Unhandled error type: ${JSON.stringify(value)}`);
}

function handleApiError(error: ApiError): string {
  switch (error.type) {
    case "network":
      return `Network error — ${error.message}`;

    case "validation":
      return `Validation failed: ${error.fields.map(f => f.message).join(", ")}`;

    case "auth":
      return `Authentication error — ${error.message}`;

    case "rate-limit":
      return `Rate limited — retry in ${error.retryAfter}s`;

    case "server":
      return `Server error ${error.status}`;

    default:
      return assertNever(error); // TypeScript error if a case is missing
  }
}

// ── Type guard for retryable errors ──────────────────────────────
function isRetryable(error: ApiError): error is NetworkError | RateLimitError | (ServerError & { retryable: true }) {
  return error.retryable === true;
}

// ── React: typed error state ──────────────────────────────────────
const [error, setError] = React.useState<ApiError | null>(null);

// In JSX — TypeScript narrows per case
{error?.type === "validation" && (
  <ul>
    {error.fields.map(f => (     // error.fields is typed: FieldError[]
      <li key={f.pointer}>{f.message}</li>
    ))}
  </ul>
)}

The retryable boolean on each variant makes retry logic trivially correct — call isRetryable(error) before entering the retry loop instead of checking status codes at multiple call sites. TypeScript ensures that adding a new error variant (e.g., TimeoutError) causes a compile error in every switch that does not handle it — missing cases are caught at build time, not production. See our guide on TypeScript JSON types for broader typing patterns.

Retry Logic for Transient JSON API Errors

Transient errors — 503, 502, 504, 429, and network failures — are expected in distributed systems and should be retried automatically. Client errors (4xx except 429) and unexpected server errors (500) should never be retried — they will not succeed on the next attempt. Use exponential backoff with jitter: each retry waits longer than the last, with random jitter to prevent thundering herd when many clients retry simultaneously after a server recovers.

// ── Retry configuration ───────────────────────────────────────────
interface RetryConfig {
  maxAttempts: number;   // total attempts including first try
  baseDelayMs: number;   // initial delay before first retry
  maxDelayMs:  number;   // cap on exponential growth
  jitterMs:    number;   // max random jitter added to each delay
}

const DEFAULT_RETRY_CONFIG: RetryConfig = {
  maxAttempts: 3,
  baseDelayMs: 500,
  maxDelayMs:  30_000,
  jitterMs:    1_000,
};

// ── Exponential backoff with jitter ───────────────────────────────
function backoffDelay(attempt: number, config: RetryConfig): number {
  const exponential = config.baseDelayMs * Math.pow(2, attempt);
  const capped = Math.min(exponential, config.maxDelayMs);
  const jitter = Math.random() * config.jitterMs;
  return capped + jitter;
}

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// ── Retryable status codes ────────────────────────────────────────
const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);

function isRetryableStatus(status: number): boolean {
  return RETRYABLE_STATUS_CODES.has(status);
}

// ── Retry-After header: obey server's exact wait time ────────────
function getRetryAfterMs(headers: Headers): number | null {
  const value = headers.get("retry-after");
  if (!value) return null;

  // Retry-After: 120  (seconds)
  const seconds = Number(value);
  if (!isNaN(seconds)) return seconds * 1000;

  // Retry-After: Wed, 21 Oct 2025 07:28:00 GMT  (HTTP date)
  const date = new Date(value).getTime();
  if (!isNaN(date)) return Math.max(0, date - Date.now());

  return null;
}

// ── Full retry wrapper ────────────────────────────────────────────
async function fetchWithRetry(
  url: string,
  options: RequestInit = {},
  config: RetryConfig = DEFAULT_RETRY_CONFIG,
): Promise<Response> {
  let lastError: unknown;

  for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
    try {
      const res = await fetch(url, options);

      if (res.ok) return res;

      // Non-retryable client error — throw immediately
      if (!isRetryableStatus(res.status)) {
        throw res; // caller handles 4xx
      }

      // Last attempt — give up
      if (attempt === config.maxAttempts - 1) throw res;

      // Obey Retry-After if present (429 rate limit)
      const retryAfterMs = getRetryAfterMs(res.headers);
      const delay = retryAfterMs ?? backoffDelay(attempt, config);

      console.warn(`HTTP ${res.status} — retrying in ${Math.round(delay)}ms (attempt ${attempt + 1})`);
      await sleep(delay);

    } catch (err) {
      // Network error (TypeError: Failed to fetch) — always retryable
      if (err instanceof TypeError) {
        lastError = err;
        if (attempt === config.maxAttempts - 1) throw err;
        await sleep(backoffDelay(attempt, config));
        continue;
      }
      throw err; // non-network error — re-throw immediately
    }
  }

  throw lastError;
}

// ── Usage ─────────────────────────────────────────────────────────
const res = await fetchWithRetry("/api/data", {
  headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();

The Retry-After header takes precedence over computed backoff — always check it on 429 responses. A server imposing a 60-second rate limit window will not respond successfully before that window expires, no matter how many retries are attempted. Cap maxDelayMs at 30 seconds for interactive APIs and 5 minutes for background jobs — unbounded exponential growth can produce delays of hours for high retry counts. For idempotent requests (GET, PUT, DELETE), retry is always safe. For POST requests, verify the server is idempotent or use an idempotency key header (Idempotency-Key: uuid) before retrying.

Axios and Fetch Error Interceptors

Global error interceptors centralise all API error handling in a single location, eliminating try/catch boilerplate in every component and API call. Axios response interceptors fire for every response with a non-2xx status code. The interceptor can normalise errors into typed ApiError instances, handle token refresh transparently, and apply retry logic — all before the error reaches the calling code.

// ── Axios: create a shared instance with interceptors ────────────
import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from "axios";

const api: AxiosInstance = axios.create({
  baseURL: "https://api.example.com",
  timeout: 10_000,
  headers: { "Content-Type": "application/json" },
});

// ── Request interceptor: attach auth token ────────────────────────
api.interceptors.request.use(config => {
  const token = localStorage.getItem("access_token");
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// ── Response interceptor: normalise errors ────────────────────────
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];

api.interceptors.response.use(
  response => response,
  async (error: AxiosError) => {
    const status = error.response?.status;
    const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };

    // ── 401: transparent token refresh ───────────────────────────
    if (status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      if (!isRefreshing) {
        isRefreshing = true;
        try {
          const { data } = await axios.post("/auth/refresh", {
            refreshToken: localStorage.getItem("refresh_token"),
          });
          localStorage.setItem("access_token", data.accessToken);
          refreshSubscribers.forEach(cb => cb(data.accessToken));
          refreshSubscribers = [];
        } finally {
          isRefreshing = false;
        }
      }

      // Queue requests during token refresh
      return new Promise(resolve => {
        refreshSubscribers.push(token => {
          if (originalRequest.headers) {
            originalRequest.headers.Authorization = `Bearer ${token}`;
          }
          resolve(api(originalRequest));
        });
      });
    }

    // ── 429: respect Retry-After ──────────────────────────────────
    if (status === 429) {
      const retryAfter = Number(error.response?.headers["retry-after"] ?? 60);
      await sleep(retryAfter * 1000);
      return api(originalRequest);
    }

    // ── 503 / 502 / 504: retry with backoff ───────────────────────
    if ([502, 503, 504].includes(status ?? 0) && !originalRequest._retry) {
      originalRequest._retry = true;
      await sleep(backoffDelay(0, DEFAULT_RETRY_CONFIG));
      return api(originalRequest);
    }

    // ── Normalise into typed ApiError ─────────────────────────────
    const body = error.response?.data as Record<string, unknown> | undefined;

    return Promise.reject(normalizeAxiosError(status, body, error));
  }
);

function normalizeAxiosError(
  status: number | undefined,
  body: Record<string, unknown> | undefined,
  original: AxiosError,
): ApiError {
  if (!status) {
    return { type: "network", message: original.message, retryable: true };
  }
  if (status === 401 || status === 403) {
    return { type: "auth", message: String(body?.detail ?? "Authentication failed"), retryable: false };
  }
  if (status === 422) {
    return { type: "validation", fields: (body?.errors ?? []) as FieldError[], detail: String(body?.detail ?? ""), retryable: false };
  }
  if (status === 429) {
    const retryAfter = Number(original.response?.headers["retry-after"] ?? 60);
    return { type: "rate-limit", retryAfter, retryable: true };
  }
  return { type: "server", status, retryable: [502, 503, 504].includes(status) };
}

// ── Fetch: global wrapper with same semantics ─────────────────────
async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
  const token = localStorage.getItem("access_token");
  const res = await fetchWithRetry(url, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
  });
  if (!res.ok) throw await parseApiError(res);
  return res.json() as Promise<T>;
}

The token refresh pattern with a subscriber queue is essential for SPAs — without it, multiple concurrent requests that all receive a 401 will each attempt to refresh the token independently, causing race conditions and redundant refresh calls. The first refresh attempt sets isRefreshing = true and collects all pending requests; when the refresh completes, all subscribers are notified with the new token and re-executed. This pattern works identically with fetch using a module-level queue variable. For server-side Next.js routes, use a middleware pattern rather than interceptors, since Axios interceptors only run in the browser.

React Error Boundaries for JSON API Failures

React error boundaries catch rendering errors thrown by child components — including errors thrown during data fetching when using Suspense and use(). An error boundary wrapping a data-dependent component tree prevents a single API failure from crashing the entire application, and allows displaying a contextual fallback UI with retry capability.

// ── Typed error boundary with retry support ──────────────────────
import React, { Component, ErrorInfo, ReactNode } from "react";

interface ErrorBoundaryState {
  error:   ApiError | null;
  hasError: boolean;
}

interface ErrorBoundaryProps {
  children:  ReactNode;
  fallback?: (error: ApiError, reset: () => void) => ReactNode;
}

class ApiErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  state: ErrorBoundaryState = { error: null, hasError: false };

  static getDerivedStateFromError(thrown: unknown): ErrorBoundaryState {
    // Convert any thrown value into a typed ApiError
    if (isApiError(thrown)) {
      return { hasError: true, error: thrown };
    }
    return {
      hasError: true,
      error: { type: "server", status: 500, retryable: false },
    };
  }

  componentDidCatch(error: unknown, info: ErrorInfo) {
    console.error("API Error Boundary caught:", error, info.componentStack);
    // Send to observability: Sentry.captureException(error)
  }

  reset = () => this.setState({ error: null, hasError: false });

  render() {
    if (this.state.hasError && this.state.error) {
      if (this.props.fallback) {
        return this.props.fallback(this.state.error, this.reset);
      }
      return <DefaultErrorFallback error={this.state.error} onRetry={this.reset} />;
    }
    return this.props.children;
  }
}

// ── Default fallback UI — handles all ApiError variants ───────────
function DefaultErrorFallback({
  error,
  onRetry,
}: {
  error:   ApiError;
  onRetry: () => void;
}) {
  switch (error.type) {
    case "network":
      return (
        <div role="alert" className="error-banner">
          <p>Network error. Check your connection.</p>
          <button onClick={onRetry}>Try again</button>
        </div>
      );

    case "validation":
      return (
        <div role="alert" className="error-banner">
          <p>Validation error: {error.detail}</p>
          <ul>
            {error.fields.map(f => <li key={f.pointer}>{f.message}</li>)}
          </ul>
        </div>
      );

    case "auth":
      return (
        <div role="alert" className="error-banner">
          <p>Please sign in to continue.</p>
          <a href="/login">Sign in</a>
        </div>
      );

    case "rate-limit":
      return (
        <div role="alert" className="error-banner">
          <p>Too many requests. Retry in {error.retryAfter} seconds.</p>
          <button onClick={onRetry}>Retry</button>
        </div>
      );

    case "server":
      return (
        <div role="alert" className="error-banner">
          <p>Server error ({error.status}). Please try again later.</p>
          {error.retryable && <button onClick={onRetry}>Try again</button>}
        </div>
      );
  }
}

// ── Type guard ────────────────────────────────────────────────────
function isApiError(value: unknown): value is ApiError {
  return (
    typeof value === "object" &&
    value !== null &&
    "type" in value &&
    ["network", "validation", "auth", "rate-limit", "server"].includes(
      (value as { type: string }).type
    )
  );
}

// ── Usage with React Query ────────────────────────────────────────
function UserProfile({ userId }: { userId: string }) {
  return (
    <ApiErrorBoundary>
      <UserProfileContent userId={userId} />
    </ApiErrorBoundary>
  );
}

// React Query: throw on error so boundary catches it
const { data } = useQuery({
  queryKey: ["user", userId],
  queryFn:  () => apiFetch(`/api/users/${userId}`),
  throwOnError: true,   // throw ApiError so ErrorBoundary catches it
});

The throwOnError option in React Query (formerly useErrorBoundary) makes the query throw its error during rendering, which the nearest ApiErrorBoundary catches. Without this, React Query swallows the error into the error state variable and the boundary is never triggered. The reset function passed to the fallback calls setState to clear the boundary — pair it with React Query's queryClient.invalidateQueries() to force a re-fetch when the user clicks "Try again". For Next.js App Router, use the error.tsx segment file alongside React error boundaries — error.tsx handles server component errors while class error boundaries handle client component errors.

Key Terms

RFC 7807
An IETF standard (updated by RFC 9457) that defines a JSON format for HTTP API error responses called "Problem Details." The standard fields are type (a URI identifying the error class), title (a human-readable summary), status (the HTTP status code), detail (explanation for this occurrence), and instance (a URI for this specific error). The media type is application/problem+json. Extension fields are explicitly allowed — the errors array for field-level validation errors is a common extension. The standard is supported by Spring Boot, ASP.NET Core, and many other frameworks natively.
Problem Details
The JSON error response format defined by RFC 7807 / RFC 9457. A Problem Details document has a type URI that identifies the error class, a title that describes the class, and a detail that explains this specific occurrence. The type URI must be stable — clients use it to programmatically identify error classes and route to appropriate handlers. Problem Details documents are returned with Content-Type: application/problem+json, which allows clients to detect the format before parsing. The format is extensible — any additional fields in the JSON body are valid extension members.
discriminated union
A TypeScript type that is a union of interfaces where each interface has a unique literal value for a common "discriminant" field (typically type or kind). TypeScript uses the discriminant to narrow the union in switch and if statements — inside a case "validation" branch, the type is narrowed to ValidationError and its specific fields (fields: FieldError[]) are available without casting. A default branch typed as never causes a compile error if any variant is unhandled — this exhaustiveness check catches missing cases at build time. Discriminated unions are the recommended pattern for modelling JSON API error responses in TypeScript.
HTTP status code
A three-digit integer returned in every HTTP response that indicates the outcome of the request. For JSON APIs: 2xx (success — 200 OK, 201 Created, 204 No Content), 4xx (client error — 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity, 429 Too Many Requests), 5xx (server error — 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout). The key distinction for error handling: 4xx errors should not be retried (the client must fix something); 502, 503, and 504 errors are transient and should be retried with backoff; 429 should be retried after the Retry-After delay.
Retry-After
An HTTP response header sent with 429 Too Many Requests (and sometimes 503 Service Unavailable) that tells the client exactly how long to wait before retrying. The value is either an integer number of seconds (Retry-After: 120) or an HTTP date (Retry-After: Wed, 21 Oct 2025 07:28:00 GMT). Clients must obey this header — retrying before the specified time will result in another 429. If both Retry-After and exponential backoff apply (e.g., multiple retry attempts), always use the larger of the two values. The header is defined in RFC 7231 and is the primary mechanism for rate limit communication in HTTP APIs.
Axios interceptor
A middleware function added to an Axios instance that runs on every request or response before the caller receives it. Request interceptors (axios.interceptors.request.use(fn)) modify outgoing requests — commonly used to attach auth tokens. Response interceptors (axios.interceptors.response.use(successFn, errorFn)) process responses — the error function receives all non-2xx responses and can return a resolved promise (to retry transparently) or a rejected promise (to propagate the error). Interceptors are the recommended pattern for centralising auth refresh, retry logic, and error normalisation in Axios-based applications — they eliminate the need for try/catch in every API call.
error boundary
A React class component that implements getDerivedStateFromError() or componentDidCatch() to catch JavaScript errors thrown by any child component during rendering, in lifecycle methods, or in constructors. Error boundaries do not catch errors in event handlers (use try/catch there) or asynchronous code (unless the error is re-thrown during rendering via use(promise) or React Query's throwOnError). When an error boundary catches an error, it renders a fallback UI instead of the crashed subtree. The reset() pattern clears the boundary's error state so the component tree can re-render — typically triggered by a "Try again" button that also re-fetches the data.

FAQ

What is RFC 7807 Problem Details and how do I implement it?

RFC 7807 Problem Details (updated by RFC 9457) defines a standard JSON error response format with five fields: type (a URI identifying the error class — must be stable and documented), title (human-readable summary of the error class), status (HTTP status code mirrored in the body), detail (explanation specific to this occurrence), and instance (URI for this specific error, e.g., a trace ID). Set Content-Type: application/problem+json on error responses. Server implementation: create an ApiError class that wraps a Problem Details object, throw it from route handlers, and convert it to an HTTP response in a centralized error middleware. The type URI must be stable — clients branch their error-handling logic on it, so changing it is a breaking change. Extend the body with an errors array for field-level validation details. Spring Boot and ASP.NET Core implement RFC 7807 natively; in Express, write a (err, req, res, next) error middleware.

What is the difference between HTTP 400 and 422 for JSON validation errors?

HTTP 400 Bad Request means the server cannot parse the request at all — the JSON is syntactically malformed (trailing comma, missing quote), the Content-Type header is wrong, or the body is empty when a body is required. HTTP 422 Unprocessable Entity means the JSON was parsed successfully but fails semantic validation — a required field is missing, a number is outside the allowed range, an email field contains an invalid address, or a foreign key references a non-existent record. The rule of thumb: if JSON.parse() throws, return 400; if zod, ajv, or your schema validator rejects the parsed value, return 422. Using 400 for everything makes it impossible for clients to distinguish "fix my JSON syntax" from "fix my field values," breaking automated error recovery. RFC 7807 type URIs should also reflect this — use separate type values for malformed-json (400) and validation-failed (422).

How do I return field-level validation errors in JSON?

Extend the RFC 7807 body with an errors array where each entry has a pointer (RFC 6901 JSON Pointer — e.g., /user/email), a code (machine-readable error type — e.g., invalid_format, too_short), and a message (human-readable explanation). Return HTTP 422 with this body. With zod: result.error.issues.map(issue => ({ pointer: "/" + issue.path.join("/"), code: issue.code, message: issue.message }). With ajv: map error.instancePath to pointer and error.keyword to code. Client-side in React Hook Form: iterate the errors array, strip the leading slash from pointer, and call setError(fieldName, { message }) for each entry. The pointer field follows RFC 6901 syntax — leading slash, path segments separated by slashes, array indices as numbers (/items/0/name).

How do I type JSON API errors in TypeScript?

Use a discriminated union with a literal type field as the discriminant: type ApiError = NetworkError | ValidationError | AuthError | RateLimitError | ServerError. Each variant has a unique type literal: type NetworkError = { type: "network"; message: string; retryable: true }. TypeScript narrows the union automatically in switch (error.type) — inside case "validation", error.fields is available without casting. Add a default branch that calls assertNever(error) (typed as (value: never) => never) — TypeScript raises a compile error if any variant is unhandled. Parse server responses into this union in a central parseApiError(res) function that inspects the HTTP status code and response body. The retryable boolean on each variant makes retry decisions trivial: if (error.retryable) { await retry(); }. See our TypeScript JSON types guide for more patterns.

How do I implement retry logic for failed JSON API requests?

Retry only on transient errors: 429, 502, 503, 504, and network failures (TypeError: Failed to fetch). Never retry on 4xx client errors (except 429) — they indicate a problem with the request, not the server. Algorithm: loop up to maxAttempts times; on a retryable error, compute delay = min(baseDelay × 2^attempt + random(jitter), maxDelay); for 429, use Retry-After header value instead of computed backoff; sleep then retry. Cap at 3 attempts for interactive APIs, 5–10 for background jobs. Cap maxDelay at 30 seconds. Add random jitter (0–1000ms) to prevent thundering herd — without jitter, all clients retry simultaneously and overload a recovering server. For POST requests, only retry if the server is idempotent or you send an Idempotency-Key header — retrying a non-idempotent POST can create duplicate records.

How do I handle JSON API errors globally with Axios?

Create a shared Axios instance and add a response interceptor: api.interceptors.response.use(res => res, async (error) => { ... }. In the error handler: check error.response?.status; for 401, attempt token refresh and retry the original request; for 429, sleep Retry-After seconds then retry; for 502/503/504, retry with backoff; for all other errors, call normalizeAxiosError() to convert to a typed ApiError discriminated union and return Promise.reject(normalizedError). Import this shared api instance everywhere instead of raw axios — all calls benefit from auth refresh, retry, and error normalization automatically. For the 401 token refresh pattern, use a subscriber queue to prevent concurrent requests from each triggering an independent refresh, which causes race conditions. Place interceptor setup in a dedicated api.ts module initialized once at app startup.

What should a JSON error response include?

A well-formed JSON API error response must include: (1) the correct HTTP status code on the response (not just in the body); (2) Content-Type: application/problem+json header; (3) type — a stable, documented URI identifying the error class; (4) title — a short, stable human-readable summary; (5) status — the HTTP status code repeated in the body; (6) detail — a specific explanation safe to display to end users. Optionally include: instance with a trace or correlation ID for observability; errors array for field-level validation details; retryAfter on rate limit errors; documentationUrl for developer-facing errors. Never include: stack traces, internal exception messages, database error strings, file paths, or server hostnames — these leak implementation details that attackers use to fingerprint and exploit your system. See our JSON security guide for more on safe error responses.

How do I handle JSON parse errors from a fetch response?

Never call response.json() without first checking response.ok — error responses often return HTML (an Nginx error page, a Rails exception page) instead of JSON, causing response.json() to throw a SyntaxError: Unexpected token <. Safe pattern: check res.ok first; for non-ok responses, check Content-Type before calling res.json()contentType.includes("application/problem+json") || contentType.includes("application/json"); if the content type is not JSON, throw an HttpError with the status code. Wrap res.json() itself in a try/catch in case the body is truncated or malformed: const body = await res.json().catch(() => null). Also handle network errors separately — fetch() throws a TypeError (not an HTTP error) when the network is unreachable, the DNS fails, or the connection is reset. A complete apiFetch wrapper handles all three failure modes: network error, HTTP error, and parse error.

Further reading and primary sources