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:
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:
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:
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:
"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:
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:
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:
// 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