# Error Handling

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

## 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:

```ts title="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`:

```ts title="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:

```ts title="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:

```ts title="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:

```ts title="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 read that a newer call cancelled — its endpoint listed in the API's
// cancelPrevious — 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](/docs/core-concepts/instance) — how requests are executed under the hood
- [Type Safety](/docs/core-concepts/type-safety) — how types flow from definitions to error handling
- [Middleware](/docs/middleware/overview) — built-in retry, auth, logging, rate limiting, and caching middleware
