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.methodis the method the call was sent with.PUTandDELETEare idempotent by HTTP’s definition, but only if your API implements them that way. Check before you add them.- If a
POSTmust 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-Afterheader 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 newerdedupecall fires between attempts, you getkind: '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
GETandHEADcalls are cached. An error always goes to the network again. APOST,PUT,PATCHorDELETEchanges something, so the cache never answers one and never stores it. To cache a read that your API sends as aPOST, such as a search, give that endpointcacheMiddleware({ methods: ['POST'] }). - The key includes the URL, the params, the headers, the
fetchOptionsand the client’s ownfetch. 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
ttlandmaxSizeto change that, anddebugto log hits and misses (Cache options). - To merge identical calls made at the same time without keeping anything, use
shareinstead.
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
retryMiddlewareretries still logs one pair of lines. A call held past itstimeoutby 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 itserror.bodyon failure (Log options).datais 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
logMiddlewarein that level’smiddleware. 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.