liaisev5.3.1
/Cancelling, deadlines and stale requests

Guide

Cancelling, deadlines and stale requests

This page covers three ways to end a call early: your own signal, a deadline, and dedupe. Read it when a user can leave a page mid-request, when a server can hang, or when fast typing starts a call on every keystroke.

Each one ends the call with an error you can tell apart. A signal or dedupe gives kind: 'abort', and a deadline gives kind: 'timeout'.

Cancel with a signal

Pass an AbortSignal in the call options:

import { createApi, defineRequest } from 'liaise'

type User = { id: string; name: string }

const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })

const controller = new AbortController()
const pending = api.getUser({ id: '42' }, { signal: controller.signal })

controller.abort()

const { error } = await pending
// error.kind === 'abort', error.status === 0, error.body is a DOMException named 'AbortError'

A cancellation you asked for isn’t reported to onError. How liaise tells your cancellation apart from other failures is under Abort classification.

Set a deadline with timeout

timeout is in milliseconds, and there is no default: without one, a call waits as long as the server takes. You set it on the client, on the endpoint or on one call. Here the client gives 10 seconds to every endpoint that sets none, and getReport sets its own 3:

import { createApi, defineRequest } from 'liaise'
import { retryMiddleware } from 'liaise/middleware'

type Report = { total: number }

const getReport = defineRequest<Report>()({ method: 'GET', path: '/report', timeout: 3000 })

const api = createApi({
  baseUrl: 'https://api.example.com',
  requests: { getReport },
  middleware: [retryMiddleware(3)],
  timeout: 10_000, // for every endpoint that sets none
})

const { error } = await api.getReport()
// If no answer arrives within 3 seconds, retries included: error.kind === 'timeout', error.status === 0

The endpoint’s 3 seconds replace the client’s 10, so a server that never answers ends the call at 3 seconds with kind: 'timeout'.

  • timeout is one deadline for the whole call. It covers every middleware, every retry and every wait between retries. timeout: 3000 with three retries still answers within three seconds.
  • The most specific timeout wins. A call’s replaces the endpoint’s, and the endpoint’s replaces the client’s. Zero or a negative number at the winning level means no deadline, so timeout: 0 on an endpoint opts it out of the client’s, and on a call turns both off.
  • result.retry() starts a fresh deadline. The retried call isn’t charged for time the first one used.
  • If you want a separate limit for each attempt instead, see Give each attempt its own timeout.
  • Under share, the endpoint’s or the client’s timeout also bounds the one shared request (details).
  • A fractional timeout is rounded down to whole milliseconds, with a minimum of 1 ms (CallOptions).

Drop stale calls with dedupe

dedupe: true makes each new call to an endpoint cancel the one still running. Use it for search-as-you-type and fast-changing filters, where only the latest answer matters:

type User = { id: string; name: string }

const searchUsers = defineRequest<User[], { q: string }>()({
  method: 'GET',
  path: '/users/search',
  dedupe: true,
})

const api = createApi({ baseUrl: 'https://api.example.com', requests: { searchUsers } })

// Typing fast: each call cancels the one before it.
api.searchUsers({ q: 'h' })    // ends with kind 'abort'
api.searchUsers({ q: 'he' })   // ends with kind 'abort'
api.searchUsers({ q: 'hel' })  // this one completes
  • It works per endpoint. A call to one endpoint never cancels a call to another.
  • Copies from withHeaders() that add the same headers dedupe together, and copies with different headers never cancel each other’s calls. { dedupe: false } turns it off for a copy.
  • A replaced call ends with kind: 'abort', so your code can ignore it.
  • It works together with your own signal and a timeout. Whichever fires first ends the call.
  • It can’t be combined with share, which does the opposite.

Search as you type builds a search box on dedupe, and shows that an older search can’t land after a newer one.

When a middleware is stuck

All three still end the call when a middleware is stuck on work of its own that ignores the signal, such as a token refresh that never settles. Timeout backstop explains how.

Next: Sharing identical requests does the opposite of dedupe: it joins the call already running instead of cancelling it.

esc