liaisev5.3.1
/Retries, caching and logging

Guide

Retries, caching and logging

This page covers what liaise has built in beyond the core: retries, caching and logging. Read it when a backend fails now and then, when the same read repeats often, or when you want to see every call while you build.

Some failures go away when you try again. Some reads repeat often enough to keep. And while you build, you want to see every call. Retries and caching are middleware, imported from a separate entry point:

import { retryMiddleware, cacheMiddleware } from 'liaise/middleware'

Logging is the client’s log option (Log every call).

Retry failed calls

retryMiddleware(2) retries a failed call up to two more times, three attempts in all. By default it retries only 5xx responses. Any 4xx, including a 429, comes back as it is, and so does a network error:

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

type Item = { id: string }

const getItems = defineRequest<Item[]>()({ method: 'GET', path: '/items' })

const api = createApi({
  baseUrl: 'https://api.example.com',
  requests: { getItems },
  middleware: [retryMiddleware(2)], // 5xx only
})

To retry 429s and network errors too, pass retryOn. This client retries a 503, a 429 and a dropped connection, never a 404, and gives up at its 3 second deadline:

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

type Report = { rows: number }

const retry = retryMiddleware({
  max: 3,
  // Retry server errors, rate limits and dropped connections. Not 4xx.
  retryOn: r => {
    const e = r.error
    return !!e && (e.status >= 500 || e.status === 429 || e.kind === 'network')
  },
})

const api = createApi({
  baseUrl: '/api',
  middleware: [retry],
  requests: {
    // timeout covers every attempt and every wait between them.
    getReport: defineRequest<Report>()({ method: 'GET', path: '/report', timeout: 3000 }),
  },
})

retryOn checks kind for a dropped connection rather than status: 0, because an abort has status 0 too. Retry a flaky backend within one deadline walks through this setup.

Retry only what is safe to send twice

retryMiddleware retries any method. A 5xx after a POST can mean the server did the work and failed afterwards, so a retry can create the same order twice. On a client that also writes, retry only the methods that are safe to repeat:

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

type Order = { id: string }

// Methods that are safe to send twice. A POST is not: a 5xx after it may
// mean the server already created the order.
const IDEMPOTENT = ['GET', 'HEAD', 'PUT', 'DELETE']

const retry = retryMiddleware({
  max: 2,
  retryOn: r => !!r.error && r.error.status >= 500 && IDEMPOTENT.includes(r.error.request.method),
})

const api = createApi({
  baseUrl: 'https://api.example.com',
  middleware: [retry],
  requests: {
    getOrder: defineRequest<Order>()({ method: 'GET', path: '/orders/:id' }),
    createOrder: defineRequest<Order>()({ method: 'POST', path: '/orders' }),
  },
})
  • r.error.request.method is the method the call was sent with.
  • PUT and DELETE are idempotent by HTTP’s definition, but only if your API implements them that way. Check before you add them.
  • If a POST must be retried and your API supports idempotency keys, send one with each order, so the server can recognise the repeat.

Pass an object to tune the waits between attempts:

const retry = retryMiddleware({
  max: 5,               // up to 5 retries after the first attempt
  delay: 'exponential', // 250 ms, 500 ms, 1 s, ... before jitter
  onRetry: ({ attempt, max, delay }) => console.log(`retry ${attempt}/${max} in ${delay}ms`),
})
  • By default the waits grow exponentially with random jitter, and a Retry-After header from the server is honoured. Every option is in RetryOptions.
  • A cancel or a deadline during a wait ends the call with that error. If your timeout, your signal or a newer dedupe call fires between attempts, you get kind: 'timeout' or 'abort'. The 503 that caused the retry is dropped.

Cache repeated reads

cacheMiddleware() keeps successful responses in memory, so a repeated read within the time-to-live skips the network. Each cacheMiddleware() call makes its own store. Give it to the endpoints you want cached:

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

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

const getUserCache = cacheMiddleware({ ttl: 5 * 60_000, maxSize: 100 })

const getUser = defineRequest<User>()({
  method: 'GET',
  path: '/users/:id',
  middleware: [getUserCache],
})

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

// On logout, clear every cached entry:
getUserCache.clear()

// Skip the cache for one call:
const { data } = await api.getUser({ id: '42' }, { skipMiddleware: [getUserCache] })
  • Only successful GET and HEAD calls are cached. An error always goes to the network again. A POST, PUT, PATCH or DELETE changes something, so the cache never answers one and never stores it. To cache a read that your API sends as a POST, such as a search, give that endpoint cacheMiddleware({ methods: ['POST'] }).
  • The key includes the URL, the params, the headers, the fetchOptions and the client’s own fetch. When your auth header is set before the cache runs, one user never sees another user’s entry. Cache key has the details.
  • Put a middleware that adds a unique header to each call after cacheMiddleware. A request ID added before it makes every call look new, and nothing is ever cached. Client middleware always runs before endpoint middleware, so either list the request-ID middleware on the endpoint after the cache, or put the cache on the client before it.
  • Entries last 5 minutes, and the store keeps 50 by default. The oldest entry is dropped first. Set ttl and maxSize to change that, and debug to log hits and misses (Cache options).
  • To merge identical calls made at the same time without keeping anything, use share instead.

Log every call

log: true on the client prints each call’s start and end to the console, with its duration. Logging is off by default.

import { createApi, defineRequest } from 'liaise'

const getItems = defineRequest<{ id: string }[]>()({ method: 'GET', path: '/items' })

const api = createApi({
  baseUrl: '/api',
  requests: { getItems },
  log: import.meta.env.DEV, // on in development only (Vite); in Node: process.env.NODE_ENV !== 'production'
})
[liaise] → GET getItems /api/items
[liaise] ← getItems OK (142ms)

[liaise] → POST createUser /api/users
[liaise] ← createUser ERROR 422 (89ms)
  • Each call logs once, with its final outcome. The logger runs outside all your middleware, so a call that retryMiddleware retries still logs one pair of lines. A call held past its timeout by a stuck middleware logs its end at the deadline, when the timeout backstop ends it. A call whose setup fails, such as one with a path param liaise refuses before sending, logs nothing.

  • log: { data: true } also prints each call’s data, or its error.body on failure (Log options). data is off by default, because responses often hold personal data and tokens, and a long list makes the console slow.

  • A call that joined a shared request ends with , shared, as in [liaise] ← getUser OK (138ms, shared). Its answer came from a request another call sent.

  • To log one endpoint or one call, use logMiddleware in that level’s middleware. It takes the same options (Log options):

    import { defineRequest } from 'liaise'
    import { logMiddleware } from 'liaise/middleware'
    
    const getItems = defineRequest<{ id: string }[]>()({
      method: 'GET',
      path: '/items',
      middleware: [logMiddleware({ data: true })], // or just [logMiddleware]
    })

Logging is meant for development. In production, write a middleware that sends the same facts to your monitoring.

Next: Writing middleware shows how to write your own, for anything the built-ins don’t cover.

esc