Skip to content

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.

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 caller

All three are optional and compose with existing options like responseTransformer and responseHandler.

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.

requestInterceptor: async (url, init) => {
const token = await refreshToken();
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${token}`);
return { ...init, headers };
},

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.

responseInterceptor: (response, url) => {
const duration = performance.now() - requestStart;
logger.info({ url, status: response.status, duration });
return response;
},

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.

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 }),
});

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.

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 HttpClientError
try {
await client.get(url);
} catch (error) {
if (error instanceof HttpClientError) {
console.log(error.message); // "No access token available"
}
}