Interceptors
HTTP Client Toolkit exposes three hooks that let you customise the HTTP lifecycle without coupling domain-specific concerns (auth, logging, URL resolution) to the core client.
Execution Order
Section titled “Execution Order”requestInterceptor(url, init) ← modify request before sending ↓fetchFn(url, modifiedInit) ← make the HTTP call ↓responseInterceptor(response) ← inspect/modify raw Response ↓parse response body ← existing logic ↓responseTransformer(data) ← transform parsed data ↓responseHandler(data) ← validate/inspect ↓cache result ← store in cache ↓return to callerAll three are optional and compose with existing options like responseTransformer and responseHandler.
Request Interceptor
Section titled “Request Interceptor”Runs before every outbound request. Use it to inject headers, add tracing context, or modify the request init.
const client = new HttpClient(stores, { requestInterceptor: async (url, init) => { const token = await getAccessToken(); const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); return { ...init, headers }; },});The interceptor receives the URL and current RequestInit, and must return a (possibly modified) RequestInit. Both sync and async interceptors are supported.
Common Use Cases
Section titled “Common Use Cases”requestInterceptor: async (url, init) => { const token = await refreshToken(); const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); return { ...init, headers };},requestInterceptor: (url, init) => { const headers = new Headers(init.headers); headers.set('X-Correlation-ID', crypto.randomUUID()); return { ...init, headers };},requestInterceptor: (url, init) => { const headers = new Headers(init.headers); headers.set('X-API-Key', process.env.API_KEY!); return { ...init, headers };},Response Interceptor
Section titled “Response Interceptor”Runs after the raw Response is received but before body parsing, transformation, and caching. Use it for logging, modifying headers, or replacing the response entirely.
const client = new HttpClient(stores, { responseInterceptor: (response, url) => { console.log(`${response.status} ${url}`); return response; },});The interceptor receives the raw Response and the request URL, and must return a Response (the same one or a replacement). Both sync and async interceptors are supported.
Common Use Cases
Section titled “Common Use Cases”responseInterceptor: (response, url) => { const duration = performance.now() - requestStart; logger.info({ url, status: response.status, duration }); return response;},responseInterceptor: async (response, url) => { // Follow a redirect URL from a custom header const redirect = response.headers.get('x-redirect-url'); if (redirect) { return fetch(redirect); } return response;},Custom Fetch Function
Section titled “Custom Fetch Function”Replace globalThis.fetch entirely. Use this when you need control over the transport layer — e.g. resolving pre-signed URLs, using an HTTP agent, or instrumenting with OpenTelemetry.
const client = new HttpClient(stores, { fetchFn: async (url, init) => { const response = await fetch(url, init); // Resolve S3 pre-signed URL before the response enters the cache const presignedUrl = response.headers.get('x-presigned-url'); if (presignedUrl) { return fetch(presignedUrl); } return response; },});When fetchFn is not provided, the client uses globalThis.fetch.
Composing All Three
Section titled “Composing All Three”The hooks compose naturally. Here’s a complete example with auth injection, custom fetch, and response logging:
const client = new HttpClient(stores, { // 1. Inject auth before every request requestInterceptor: async (url, init) => { const token = await getAccessToken(); const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); return { ...init, headers }; },
// 2. Resolve pre-signed URLs at the fetch layer fetchFn: async (url, init) => { const response = await fetch(url, init); const presignedUrl = response.headers.get('x-presigned-url'); if (presignedUrl) { return fetch(presignedUrl); } return response; },
// 3. Log every response responseInterceptor: (response, url) => { console.log(`[${response.status}] ${url}`); return response; },
// 4. Transform parsed data (existing option) responseTransformer: (data) => camelcaseKeys(data as Record<string, unknown>, { deep: true }),});Interaction with Caching
Section titled “Interaction with Caching”Interceptors only run when an actual HTTP request is made. Cache hits are returned directly without invoking any interceptor or fetchFn.
Interceptors do run during background revalidation (stale-while-revalidate), ensuring that revalidation requests use the same auth tokens, logging, and fetch customisation as regular requests.
Error Handling
Section titled “Error Handling”Errors thrown by interceptors are wrapped in HttpClientError and propagate normally. Note that errorHandler is not called for interceptor errors — it only handles HTTP errors (non-2xx responses). Interceptor errors are non-HTTP errors and are always wrapped by the toolkit.
const client = new HttpClient(stores, { requestInterceptor: async (url, init) => { const token = await getAccessToken(); if (!token) { throw new Error('No access token available'); } const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); return { ...init, headers }; },});
// Interceptor errors surface as HttpClientErrortry { await client.get(url);} catch (error) { if (error instanceof HttpClientError) { console.log(error.message); // "No access token available" }}