Steven's Knowledge

Error Handling

Categorizing API errors, retry strategies, circuit breaking, user-facing messaging

Error Handling

The default try/catch on a fetch lumps networks issues, server bugs, validation failures, and cancellations into the same Error. That's not enough information to do the right thing for the user.

Error Taxonomy

Error
├── NetworkError           DNS, TCP, TLS, timeout
├── HttpError              non-2xx response
│   ├── ClientError        4xx — don't retry, map to UX
│   │   ├── UnauthorizedError      401
│   │   ├── ForbiddenError         403
│   │   ├── NotFoundError          404
│   │   ├── ConflictError          409
│   │   ├── ValidationError        422 — has field-level info
│   │   └── RateLimitedError       429 — has Retry-After
│   └── ServerError        5xx — retry with backoff
├── ParseError             body didn't match schema
├── DomainError            backend-defined business errors
└── AbortError             cancellation, not really an error

A Real Implementation

export class AppError extends Error {
  constructor(message: string, public cause?: unknown) {
    super(message);
    this.name = new.target.name;
  }
}

export class NetworkError extends AppError {}
export class TimeoutError extends NetworkError {}

export class HttpError extends AppError {
  constructor(public status: number, public body: unknown, message?: string) {
    super(message ?? `HTTP ${status}`);
  }
}

export class UnauthorizedError extends HttpError {}
export class ForbiddenError extends HttpError {}
export class ValidationError extends HttpError {
  constructor(public fieldErrors: Record<string, string>, body: unknown) {
    super(422, body, 'Validation failed');
  }
}
export class RateLimitedError extends HttpError {
  constructor(public retryAfterSeconds: number | null, body: unknown) {
    super(429, body, 'Rate limited');
  }
}

export class DomainError extends AppError {
  constructor(public code: string, message: string) { super(message); }
}
async function buildError(res: Response): Promise<AppError> {
  let body: unknown;
  try { body = await res.json(); } catch { body = undefined; }

  switch (res.status) {
    case 401: return new UnauthorizedError(401, body);
    case 403: return new ForbiddenError(403, body);
    case 422: return new ValidationError(parseFieldErrors(body), body);
    case 429: return new RateLimitedError(
      Number(res.headers.get('Retry-After')) || null, body);
    default:
      return new HttpError(res.status, body);
  }
}

Retry Strategy

ErrorRetry?Backoff
NetworkError / TimeoutErrorYes (3×)Exponential + jitter
ServerError 5xxYes (3×)Exponential + jitter
RateLimitedErrorYesHonor Retry-After
ClientError 4xx (except 408/429)No
AbortErrorNo
ParseErrorNo
DomainErrorNo

Exponential Backoff With Jitter

function backoffDelay(attempt: number) {
  // 1s, 2s, 4s, 8s ... with ±50% jitter
  const base = Math.min(1000 * 2 ** attempt, 30_000);
  const jitter = base * 0.5 * Math.random();
  return base + jitter;
}

TanStack Query Retry Policy

new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error instanceof HttpError && error.status < 500 && error.status !== 408 && error.status !== 429) {
          return false;
        }
        return failureCount < 3;
      },
      retryDelay: (attempt, error) => {
        if (error instanceof RateLimitedError && error.retryAfterSeconds) {
          return error.retryAfterSeconds * 1000;
        }
        return Math.min(1000 * 2 ** attempt, 30_000);
      },
    },
    mutations: {
      retry: 0,   // never retry mutations by default
    },
  },
});

Mutations default to no retry. A failed mutation might already have side-effects on the server. Only retry with an idempotency key.

Timeouts

Two layers:

const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(new TimeoutError('Request timeout')), 15_000);

try {
  const res = await fetch(url, { signal: ac.signal });
  // ...
} finally {
  clearTimeout(timeout);
}

Why 15s on mobile? Cellular networks can stall briefly. 15s tolerates that without making the user wait too long. The number is configurable per endpoint — uploads can be longer, autocompletes much shorter.

Circuit Breaker (For Mobile)

On flaky networks, hammering a failing endpoint wastes battery and quota. After N consecutive failures, fail fast for M seconds.

class CircuitBreaker {
  private failures = 0;
  private openedAt: number | null = null;

  constructor(private threshold = 5, private cooldownMs = 30_000) {}

  async run<T>(fn: () => Promise<T>): Promise<T> {
    if (this.openedAt && Date.now() - this.openedAt < this.cooldownMs) {
      throw new NetworkError('Circuit open');
    }
    try {
      const result = await fn();
      this.failures = 0;
      this.openedAt = null;
      return result;
    } catch (e) {
      if (e instanceof NetworkError || (e instanceof HttpError && e.status >= 500)) {
        this.failures++;
        if (this.failures >= this.threshold) this.openedAt = Date.now();
      }
      throw e;
    }
  }
}

Useful per-host. Less useful for a server with planned downtime — there, prefer a maintenance flag from the backend.

User-Facing Messaging

ErrorWhat to show
NetworkError"You're offline — we'll try again when you're back." + Retry button
TimeoutError"Taking longer than expected." + Retry
UnauthorizedErrorSilent — refresh token; only show if refresh fails ("Please sign in again")
ForbiddenError"You don't have access to this."
NotFoundErrorEmpty state with context-specific text
ValidationErrorField-level errors next to the relevant input
RateLimitedError"Too many requests — try again in N seconds."
ServerError 5xx"Something went wrong on our end. We're looking into it." + retry
DomainErrorMap by code to localized message

Don't Leak Internals

// Bad
<Text>{error.message}</Text>   // "FetchError: getaddrinfo ENOTFOUND api.example.com"

// Good
<Text>{userFacingMessageFor(error)}</Text>

Send the raw error to your error tracker (Sentry), show the friendly version to the user.

Distinguishing User Cancellation From Failures

try { await fn(); } catch (e) {
  if (e instanceof DOMException && e.name === 'AbortError') return;  // user navigated away
  if (e instanceof TimeoutError) throw e;  // we aborted because of timeout
  throw e;
}

A common bug: showing "request failed" toast when the user just left the screen.

Logging And Alerting

Send structured errors to your tracker:

Sentry.captureException(error, {
  tags: {
    'error.type': error.name,
    'error.status': error instanceof HttpError ? String(error.status) : 'n/a',
    'route': currentRoute,
  },
  extra: {
    requestId: response?.headers.get('x-request-id'),
    endpoint,
  },
});

Include the request ID the backend returned — operators can correlate to server logs.

Backend Should Also Help

Frontend can only do so much. Ask the backend for:

  • Stable, machine-readable error codes (code: "INSUFFICIENT_FUNDS"), not freeform messages.
  • Retry-After on 429 and 503.
  • Field-level errors on 422 ({ field: "email", code: "invalid_format" }).
  • A requestId echoed in headers and body.

A backend that returns { message: "oops" } makes good frontend UX impossible.

Common Pitfalls

SymptomCauseFix
Toast "request failed" after navigating awayTreating AbortError as failureFilter AbortError before showing
Infinite refresh loop401 handler that calls refresh, refresh also returns 401, handler triggers againSingle-flight mutex on refresh; sign out on second 401
App stuck on splash because one request hungNo timeoutAlways set a timeout
Toast spam after backend outageNo backoff cap; retry on every focusCap retry attempts; circuit breaker
Field errors don't showBackend returns 400 with mixed-shape bodyNormalize at the API layer
Inline error message in wrong languageShowed backend error.message directlyMap error codes to localized strings

On this page