liaisev5.3.1
/Give each attempt its own timeout

Recipes

Give each attempt its own timeout

Use this when an attempt sometimes hangs while a fresh one would answer quickly, so you want to give up on each attempt after a few seconds and try again. liaise’s timeout can’t do this on its own: it is one deadline for the whole call, retries included.

Put a middleware that gives each attempt its own signal inside retryMiddleware. getUser is the endpoint from Quick start.

import { createApi } from 'liaise'
import type { Middleware } from 'liaise'
import { retryMiddleware } from 'liaise/middleware'

// A fresh time limit for each attempt, on top of the caller's own signal.
const perAttempt = (ms: number): Middleware => async (ctx, next) => {
  const caller = ctx.request.signal // the caller's signal and deadline, if any
  const limit = AbortSignal.timeout(ms)
  const attempt = new AbortController()
  const stop = () => attempt.abort(caller?.aborted ? caller.reason : limit.reason)
  if (caller?.aborted) stop()
  caller?.addEventListener('abort', stop)
  limit.addEventListener('abort', stop)
  ctx.request.signal = attempt.signal
  try {
    return await next()
  } finally {
    caller?.removeEventListener('abort', stop)
    limit.removeEventListener('abort', stop)
    ctx.request.signal = caller // the next attempt starts from the caller's signal again
  }
}

const api = createApi({
  baseUrl: '/api',
  requests: { getUser },
  // Order matters: retry wraps perAttempt, so every attempt gets its own 5 s.
  middleware: [
    retryMiddleware({ retryOn: r => r.error?.kind === 'timeout' || (r.error?.status ?? 0) >= 500 }),
    perAttempt(5_000),
  ],
})

An attempt that hangs ends after 5 seconds with kind: 'timeout'. retryMiddleware then sends a new attempt with a fresh 5 seconds, and the caller gets the user from that one.

Why it works

  • liaise reads ctx.request.signal when it calls fetch, so each attempt is sent with the signal perAttempt set. When that signal’s time runs out, the attempt ends with kind: 'timeout' (Signals in middleware).
  • The caller’s signal still stops the request. perAttempt aborts the attempt when either the caller’s signal or the attempt’s limit fires, with that signal’s reason. A cancel ends the call as 'abort' and cancels the request in flight; a passed limit ends the attempt as 'timeout'.
  • Each attempt puts the caller’s signal back when it ends. retryMiddleware sends every attempt with the same ctx, so without that line the next attempt would start from this attempt’s aborted signal and time out at once.
  • The two signals are joined by hand because AbortSignal.any needs Node 20.3 or Safari 17.4. If every runtime you support has it, AbortSignal.any([caller, limit]) does the same in one line; leave out undefined when there is no caller signal.
  • Order matters. Each middleware in the list wraps the ones after it. retryMiddleware comes first, so it wraps perAttempt, and every retry runs perAttempt again with a fresh signal. Listed the other way round, perAttempt would run once, and every attempt would share its one signal.
  • Timeouts aren’t retried by default. The default retryOn matches only 5xx responses, and a timeout has status 0. The retryOn above opts in to timeouts and keeps 5xx.

Signal-replacing middleware says how a replaced signal works with share and dedupe.

Next: Report errors to Sentry sends every unexpected failure to your error tracker from one place.

esc