liaisev5.3.1
/Handling errors

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

kindWhat happenedstatusWhat you usually doReported 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 statusHandle it by status, or show itYes
'network'No response arrived, because you’re offline or DNS or CORS failed. A call liaise refused to send is 'network' too (below).0Show an offline message, or retryYes
'timeout'Your timeout passed.0Say it’s slowYes
'abort'The call was cancelled by your signal, or replaced by a newer dedupe call.0Ignore itNo
'parse'A 2xx body didn’t parse as its responseType, or failed your schema.The response’s statusReport itYes
'middleware'Your middleware threw.0Fix your codeYes
  • Check error first. After if (error) return, data has your response type, so you never write data!.
  • Branch on error.kind, not on status. Four kinds share status: 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, and retryMiddleware still retries it.
  • For an endpoint that sends no body, declare responseType: 'none'. Widening the type to | null doesn’t work, because an empty body is a 'parse' error. See Reading responses.
  • response is for the status and headers. liaise has already read its body to produce data or error.body, so response.json() throws “Body has already been read”. A Response you build yourself for successResult() in tests keeps its body.
  • Every field of error is listed under ApiError.

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 or NaN (Path params)
  • a :name the 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 Date in 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: string can’t be undefined without a cast or an any.
  • Log error.body for 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 retryMiddleware doesn’t retry it. onError does 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 Result either 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.

esc