Error Handling
Error types
| Error | Package | Thrown when |
|---|---|---|
ValidationError | @routar/core | Request or response validation fails |
HttpError | @routar/core | Server returns a non-2xx status (all executors — fetch, Axios, ky) |
TimeoutError | @routar/core | Request exceeds the createFetchExecutor timeout |
All transports normalize HTTP failures to HttpError. The Axios and ky executors catch their native errors (AxiosError, ky HTTPError) and re-throw an HttpError with status, statusText, and body populated from the response. The original transport error is preserved on err.cause, so you can still reach AxiosError-specific fields when you need them.
Endpoints can declare errors schemas for known non-2xx bodies. Matching HttpError.body values are validated by createApi; invalid declared error bodies throw ValidationError unless response validation is in 'warn' mode.
Handling errors
import { TimeoutError, ValidationError } from '@routar/core'
import { HttpError } from '@routar/core'
try {
await todoApi.create({ body: { title: '' } })
} catch (err) {
if (err instanceof ValidationError) {
// request or response schema failed
console.log(err.message)
console.log(err.cause) // original Zod/Valibot error
}
if (err instanceof HttpError) {
// fetch returned non-2xx
console.log(err.status) // e.g. 422
console.log(err.statusText) // e.g. 'Unprocessable Entity'
console.log(err.body) // parsed JSON payload, or null
console.log(err.url) // full request URL, e.g. 'https://api.example.com/todos'
console.log(err.method) // HTTP method, e.g. 'POST'
console.log(err.cause) // original transport error (AxiosError, ky HTTPError), or undefined
}
if (err instanceof TimeoutError) {
console.log(err.ms) // configured timeout in ms
}
}Transport-agnostic HTTP errors
Because every executor normalizes HTTP failures to HttpError, a single instanceof HttpError check handles fetch, Axios, and ky alike — no need to branch on isAxiosError or ky’s HTTPError:
import { HttpError } from '@routar/core'
try {
await todoApi.getDetail({ path: { id: 1 } })
} catch (err) {
if (err instanceof HttpError) {
// any executor — non-2xx response
console.log(err.status, err.body)
}
}If you need transport-specific fields (e.g. an AxiosError’s config or code), reach them through err.cause:
import { HttpError } from '@routar/core'
import { isAxiosError } from 'axios'
try {
await todoApi.getDetail({ path: { id: 1 } })
} catch (err) {
if (err instanceof HttpError && isAxiosError(err.cause)) {
console.log(err.cause.code) // e.g. 'ERR_BAD_REQUEST'
console.log(err.cause.config) // original request config
}
}onError plugins see the same normalized HttpError, so plugin code is transport-agnostic too. (Network failures and cancellations — errors with no response — are re-thrown unchanged, since there is no HTTP status to normalize.)
Typed error bodies
const todoRouter = defineRouter('/todos', {
getDetail: endpoint({
method: 'GET',
path: '/:id',
request: { path: z.object({ id: z.number() }) },
response: TodoSchema,
errors: {
404: z.object({ code: z.literal('NOT_FOUND') }),
},
}),
})
try {
await todoApi.getDetail({ path: { id: 404 } })
} catch (err) {
if (err instanceof HttpError && err.status === 404) {
console.log(err.body) // validated against the 404 schema
}
}Statuses without a declared schema keep their original HttpError. validate: false skips both success-response and declared error-body validation.
Retry only on server errors
retry is an option on createFetchExecutor:
import { createFetchExecutor, HttpError } from '@routar/core'
createFetchExecutor('https://api.example.com', {
retry: {
count: 3,
shouldRetry: (err) => {
if (err instanceof HttpError) return err.status >= 500
return true // retry on network errors
},
},
})