Guide
Handling errors
This page shows what a call gives back when it fails, and how to handle each kind of failure. Read it when you write the code that shows an error to the user or reports it.
Plain fetch reports failures three different ways. A 500 resolves like a success, being offline throws, and a hung server never answers. In liaise every call returns { data, error }, and error.kind names what went wrong. Nothing is thrown, so there is no try to write:
import { createApi, defineRequest } from 'liaise'
type User = { id: string; name: string }
const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id', timeout: 5000 })
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
async function loadUser(id: string) {
const { data, error } = await api.getUser({ id })
if (error) {
switch (error.kind) {
case 'http': // the server answered with a non-2xx status
if (error.status === 401) redirectToLogin()
else show(`The server said no (${error.status})`)
break
case 'network': // no response arrived
show("You're offline. Try again.")
break
case 'timeout': // the 5 second deadline passed
show('This is taking too long.')
break
case 'abort': // you cancelled the call, so there is nothing to show
break
case 'parse': // a 2xx body that didn't parse or failed the schema
case 'middleware': // your own middleware threw
report(error)
break
default: // every kind is handled, so a new one is a compile error
error.kind satisfies never
}
return
}
show(`Hello, ${data.name}`) // data is a User here
}
show, redirectToLogin and report stand for your own code. Each failure reaches its own branch: a 401 goes to the login page, a 500 shows its status, being offline and a hung server each get their own message, and a body that isn’t JSON is reported. The default branch makes the switch exhaustive: if a future version adds a seventh kind, error.kind satisfies never stops compiling until you handle it.
The six kinds
kind | What happened | status | What you usually do | Reported to onError? |
|---|---|---|---|---|
'http' | The server answered with a non-2xx status. A GraphQL response with errors is 'http' too, even on a 2xx (GraphQL errors). | The response’s status | Handle it by status, or show it | Yes |
'network' | No response arrived, because you’re offline or DNS or CORS failed. A call liaise refused to send is 'network' too (below). | 0 | Show an offline message, or retry | Yes |
'timeout' | Your timeout passed. | 0 | Say it’s slow | Yes |
'abort' | The call was cancelled by your signal, or replaced by a newer dedupe call. | 0 | Ignore it | No |
'parse' | A 2xx body didn’t parse as its responseType, or failed your schema. | The response’s status | Report it | Yes |
'middleware' | Your middleware threw. | 0 | Fix your code | Yes |
- Check
errorfirst. Afterif (error) return,datahas your response type, so you never writedata!. - Branch on
error.kind, not onstatus. Four kinds sharestatus: 0, and each needs different handling. - A non-2xx response is always
'http', even when its body doesn’t parse. liaise checks the status before it reads the body, so a 500 with broken JSON is still a 500, andretryMiddlewarestill retries it. - For an endpoint that sends no body, declare
responseType: 'none'. Widening the type to| nulldoesn’t work, because an empty body is a'parse'error. See Reading responses. responseis for the status and headers. liaise has already read its body to producedataorerror.body, soresponse.json()throws “Body has already been read”. AResponseyou build yourself forsuccessResult()in tests keeps its body.- Every field of
erroris listed underApiError.
When ‘network’ is a bug in your code
liaise refuses to send a call it can’t build correctly, and reports it as 'network' with status: 0, the same as being offline. These are bugs in the calling code, not outages:
- a path param that is
undefined,null,'', an object orNaN(Path params) - a
:namethe path can’t fill: one in the path’s query string, or a param named after a colon in the middle of a segment (Colons in a path) - a nested object or a
Datein the query string (Sending data) - params of a type liaise can’t send, such as a
Set
The example above would tell the user “You’re offline” for any of them. Only the message tells them apart: error.body holds a TypeError, and for a refused call its message says what liaise refused and what to change. fetch failing offline gives a TypeError too, such as “Failed to fetch” in Chrome or “fetch failed” in Node.
- TypeScript catches most of these first. A param typed
id: stringcan’t beundefinedwithout a cast or anany. - Log
error.bodyfor every'network'error, at least in development, so a bug doesn’t hide behind the offline message. - A refused call never reaches your middleware, so
retryMiddlewaredoesn’t retry it.onErrordoes see it.
Trying again with retry()
Every Result carries retry(), which runs the same call again. It goes through all your middleware, so an auth header is set again and logging runs again:
const { error, retry } = await api.getUser({ id: '42' })
if (error?.kind === 'http' && error.status === 401) {
await refreshToken()
const second = await retry() // a fresh call through every middleware
if (!second.error) show(second.data.name)
}
refreshToken stands for your own token refresh. To retry automatically, use retryMiddleware.
Reporting errors with onError
onError on createApi is one place to send every error to your tracker.
const api = createApi({
baseUrl: 'https://api.example.com',
requests: { getUser },
onError: (error) => logToTracker(error),
})
logToTracker stands for your error tracker, such as Sentry.
- It runs once per call, after all your middleware has finished. A call that a retry middleware rescues from a 500 never reaches it. When calls share one failed request, it runs once for all of them.
- It isn’t called for
'abort', because a cancellation isn’t a failure. A'timeout'is reported, because it’s a deadline you missed. - It only watches. The caller gets the same
Resulteither way.
Report errors to Sentry uses onError to send unexpected failures to Sentry and skip the expected 4xx.
Next: Sending data shows where each param goes: the path, the query string or the body.