Skip to content

Core ConceptsError Handling

Error Handling

Handle HTTP errors with typed error data, type guards, and middleware-based error hooks.

3 min readboth modes

The HTTPError Class

When a request fails with an HTTP error status (400 or above), or gets no answer at all, Courier throws an HTTPError. This class extends Courier's CourierError, itself an Error, and adds status, headers, and a typed data property containing the parsed response body:

typescript
import { CourierError } from "@noverax/courier"

declare class HTTPError<TData = unknown> extends CourierError<"HTTP_ERROR"> {
  readonly status: number    // HTTP status code (400, 404, 500, ...); 0 when there was no answer
  readonly headers: Headers  // Response headers
  readonly data: TData       // Parsed response body — typed with the TData generic
}

// error.message → "HTTP 404", or "Network request failed" / "Request timed out" with no answer
// error.name → "HTTPError"

Typed error data

The HTTPError<TData> generic lets you type the error response body. If your backend returns { "message": "Not found", "code": "NOT_FOUND" }, you can access it as error.data.message with full type safety.

The isHTTPError Type Guard

Use isHTTPError() to narrow an unknown error to HTTPError. It works as a TypeScript type guard, giving you access to status, headers, and data inside the if block:

lib/api-helpers.ts
import { isHTTPError } from "@noverax/courier"

try {
  const user = await caller.users.getById({ id: "999" })
} catch (error) {
  // Type guard narrows error to HTTPError
  if (isHTTPError(error)) {
    console.log(error.status)  // 404
    console.log(error.data)    // whatever the backend returned
  }

  // With a typed error body
  if (isHTTPError<{ message: string; code: string }>(error)) {
    console.log(error.data.message) // "User not found"
    console.log(error.data.code)    // "USER_NOT_FOUND"
  }
}

Error Handling Patterns

Direct mode — try/catch direct mode

In Server Components and Server Actions, use standard try/catch to handle errors from createCaller:

app/users/[id]/page.tsx
import { isHTTPError } from "@noverax/courier"
import { caller } from "@/lib/caller"

export default async function UserPage({ params }: { params: { id: string } }) {
  try {
    const user = await caller.users.getById({ id: params.id })

    return <UserProfile user={user} />
  } catch (error) {
    if (isHTTPError(error)) {
      if (error.status === 404) {
        return <NotFound message="User not found" />
      }
      return <ErrorPage status={error.status} />
    }
    throw error // Re-throw unexpected errors for error boundary
  }
}

Proxy mode — React Query proxy mode

When using createProxyQueryCaller, React Query catches errors automatically and exposes them via the error property:

components/users.tsx
"use client"

import { isHTTPError } from "@noverax/courier"
import { q } from "@/lib/query"

export function UsersList() {
  const { data, error, isError } = q.users.useQuery("getAll")

  // React Query catches errors automatically
  if (isError && isHTTPError(error)) {
    return <div>Error {error.status}: Failed to load users</div>
  }

  return <ul>{data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}

export function CreateUserForm() {
  const { mutate, error, isError } = q.users.useMutation("create", {
    onError: (error) => {
      // Handle error in the mutation callback
      if (isHTTPError<{ message: string }>(error)) {
        toast.error(error.data.message)
      }
    },
  })

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      mutate({ name: "Jane", email: "jane@test.com" })
    }}>
      {isError && isHTTPError(error) && (
        <p className="text-red-500">Error: {error.status}</p>
      )}
      <button type="submit">Create User</button>
    </form>
  )
}

Middleware-Based Error Handling

For cross-cutting error handling (logging, authentication redirects, toast notifications), define a custom middleware with defineMiddleware. This middleware wraps every request in the source and can intercept errors before they reach your components:

api/sources.ts
import { defineApi } from "@noverax/courier/define"
import { defineMiddleware } from "@noverax/courier/middleware"
import { isHTTPError } from "@noverax/courier"
import { users } from "./users"
import { posts } from "./posts"

const errorMiddleware = defineMiddleware("errors", "wrap", async (ctx, next) => {
  try {
    return await next()
  } catch (error) {
    // Log to your error tracking service
    if (isHTTPError(error)) {
      console.error(
        `[${ctx.source}] ${ctx.group}:${ctx.method} failed with ${error.status}`,
        { variables: ctx.variables, data: error.data },
      )

      // Handle 401 globally — redirect to login, refresh token, etc.
      if (error.status === 401) {
        window.location.href = "/login"
      }
    }

    // Re-throw so try/catch and React Query still receive the error
    throw error
  }
})

const api = defineApi({
  baseURL: process.env.API_URL!,
  middleware: [errorMiddleware],
  groups: { users, posts },
})

export const sources = { api }

Middleware does not suppress errors

The middleware re-throws the error after handling side effects. Your try/catch or React Query error handling will still receive it. If you want to suppress an error, return a fallback MiddlewareResponse instead of re-throwing.

Combining with Built-in Middleware

You can compose the error middleware with Courier's built-in middleware. Middleware executes in array order — the first middleware is the outermost wrapper:

api/sources.ts
import { defineApi } from "@noverax/courier/define"
import {
  defineMiddleware,
  retryMiddleware,
  logMiddleware,
  authMiddleware,
} from "@noverax/courier/middleware"

const api = defineApi({
  baseURL: process.env.API_URL!,
  middleware: [
    logMiddleware(),                                   // logs endpoint, status, duration
    retryMiddleware({ limit: 2, delay: 300 }),         // retries on 5xx
    authMiddleware({ getToken: () => getAuthToken() }),  // attaches Bearer token
    errorMiddleware,                                   // custom error handling
  ],
  groups: { users, posts },
})

Error Types

Courier can throw different types of errors depending on the failure mode:

typescript
// 1. HTTP errors (status >= 400) — most common
// The backend returned an error response with a body
if (isHTTPError(error)) {
  error.status   // 400, 401, 403, 404, 500, ...
  error.data     // Parsed response body (JSON or text)
  error.headers  // Response headers
}

// 2. Network errors — no response received
// Thrown as HTTPError with status 0 and message "Network request failed";
// error.data.code carries the system's code (ECONNREFUSED, ENOTFOUND, ...)
// and error.cause the error fetch rejected with.

// 3. Timeouts — the request's timeout ran out
// Thrown as HTTPError with status 0 and message "Request timed out";
// error.data.code is "TIMEOUT" and error.cause is the TimeoutError.
// Through the proxy, a timeout on the server's side of the call arrives
// as HTTPError 504 with error.data.code "UPSTREAM_TIMEOUT"; a host the
// server could not reach arrives as 502 with "UPSTREAM_UNREACHABLE".

// 4. Cancellation
// A call cancelled through your own signal rejects with the reason you
// gave, as fetch does: a DOMException named "AbortError" by default.
// This holds while the answer's body is still being read, too.
// A call that deduplicate cancelled rejects with HTTPError status 499.
if (isHTTPError(error) && error.status === 499) {
  // Superseded by a newer read of the same endpoint
}

// 5. Your own code's failures
// On a direct call, what a middleware or a pathOptions function threw,
// or a body that would not serialize, is rethrown as it was.

// 6. JSON parse errors — a success whose body is not valid JSON
// Thrown when responseType is "json" but a 2xx body cannot be parsed:
// an HTTPError carrying the upstream's own status (e.g. 200), with the
// SyntaxError as its cause. Through the proxy it arrives as a 502.
// An error answer whose body is not JSON is not a parse error: its
// text becomes error.data.

Next Steps

  • HTTP Client & Methods — how requests are executed under the hood
  • Type Safety — how types flow from definitions to error handling
  • Middleware — built-in retry, auth, logging, rate limiting, and caching middleware