Interceptors
Transform requests and responses globally with interceptors. Perfect for authentication, logging, error normalization, and request modification.
Interceptors allow you to run custom logic before a request is sent or after a response is received. They are the primary mechanism for cross-cutting concerns like authentication, logging, error normalization, and request enrichment.
Request Interceptors
Request interceptors receive the current request configuration and return the modified configuration. They execute in order before every request.
const api = IgniterCaller.create()
.withBaseUrl('https://api.example.com')
// Add auth token
.withRequestInterceptor(async (config) => ({
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${getAuthToken()}`,
},
}))
// Add request tracing
.withRequestInterceptor(async (config) => ({
...config,
headers: {
...config.headers,
'X-Request-ID': crypto.randomUUID(),
'X-Timestamp': new Date().toISOString(),
},
}))
.build();Signature
type RequestInterceptor = (
config: IgniterCallerRequestOptions,
) => IgniterCallerRequestOptions | Promise<IgniterCallerRequestOptions>;Response Interceptors
Response interceptors receive the response envelope and return a (possibly transformed) envelope. They execute in order after every response.
const api = IgniterCaller.create()
.withResponseInterceptor(async (response) => {
// Log all responses
console.log(`[${response.status}] ${response.headers?.get('x-request-id')}`);
return response;
})
.withResponseInterceptor(async (response) => {
// Normalize empty data
if (response.data === '' || response.data === undefined) {
return { ...response, data: null as any };
}
return response;
})
.build();Signature
type ResponseInterceptor = <T>(
response: IgniterCallerApiResponse<T>,
) => IgniterCallerApiResponse<T> | Promise<IgniterCallerApiResponse<T>>;Common Patterns
Authentication Token Refresh
A complete auth interceptor that handles token refresh on 401 responses:
let accessToken: string | null = null;
let refreshPromise: Promise<string> | null = null;
const api = IgniterCaller.create()
.withBaseUrl('https://api.example.com')
// Inject token
.withRequestInterceptor(async (config) => {
if (!accessToken) {
accessToken = await refreshAccessToken();
}
return {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${accessToken}`,
},
};
})
// Handle 401 — clear token so next request refreshes
.withResponseInterceptor(async (response) => {
if (response.status === 401) {
accessToken = null;
// Optionally redirect to login
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
return response;
})
.build();
async function refreshAccessToken(): Promise<string> {
if (!refreshPromise) {
refreshPromise = fetch('/api/auth/refresh', { credentials: 'include' })
.then(r => r.json())
.then(data => data.accessToken)
.finally(() => { refreshPromise = null; });
}
return refreshPromise;
}Request Logging Middleware
const api = IgniterCaller.create()
.withRequestInterceptor(async (config) => {
const startTime = Date.now();
// Store start time on the config for use in response interceptor
(config as any)._startTime = startTime;
console.log(`→ ${config.method} ${config.url}`);
return config;
})
.withResponseInterceptor(async (response) => {
const startTime = (response as any)._startTime as number;
const duration = startTime ? Date.now() - startTime : undefined;
const emoji = (response.status ?? 0) < 400 ? '✅' : '❌';
const timing = duration ? ` (${duration}ms)` : '';
console.log(`${emoji} ${response.status}${timing}`);
return response;
})
.build();Error Normalization
const api = IgniterCaller.create()
.withResponseInterceptor(async (response) => {
// Some APIs wrap errors differently — normalize them
if (response.error && (response.error as any).details?.errors) {
return {
...response,
error: {
...response.error,
message: formatValidationErrors((response.error as any).details.errors),
},
} as any;
}
// Convert 5xx errors to user-friendly messages in production
if ((response.status ?? 0) >= 500 && process.env.NODE_ENV === 'production') {
return {
...response,
error: {
...response.error,
message: 'An unexpected error occurred. Please try again.',
},
} as any;
}
return response;
})
.build();Conditional Headers
Add headers based on request characteristics:
const api = IgniterCaller.create()
.withRequestInterceptor(async (config) => {
const headers: Record<string, string> = {};
// Add idempotency key for mutating requests
if (['POST', 'PUT', 'PATCH'].includes(config.method)) {
headers['X-Idempotency-Key'] = crypto.randomUUID();
}
// Add version header for specific endpoints
if (config.url?.startsWith('/v2/')) {
headers['Accept-Version'] = '2.0';
}
return {
...config,
headers: { ...config.headers, ...headers },
};
})
.build();Request Deduplication
Prevent duplicate in-flight requests for the same URL:
const inflightRequests = new Map<string, Promise<any>>();
const api = IgniterCaller.create()
.withRequestInterceptor(async (config) => {
const key = `${config.method}:${config.url}`;
if (config.method === 'GET') {
const existing = inflightRequests.get(key);
if (existing) {
// Return a marker — this doesn't actually deduplicate at the execute level
// For true deduplication, use .stale() caching instead
}
}
return config;
})
.build();For request deduplication
Interceptors alone can't fully deduplicate requests. Use .stale() caching for automatic deduplication of identical GET requests within the stale window.
Interceptor Execution Order
Interceptors execute in the order they're registered:
Request Interceptors (first → last)
↓
Fetch
↓
Response Interceptors (first → last)const api = IgniterCaller.create()
// Runs first: adds auth
.withRequestInterceptor(authInterceptor)
// Runs second: adds tracing
.withRequestInterceptor(tracingInterceptor)
.build();
// On response:
// tracingInterceptor response runs first
// authInterceptor response runs secondTesting Interceptors
import { describe, it, expect, vi } from 'vitest';
describe('Auth Interceptor', () => {
it('adds authorization header to every request', async () => {
const interceptor = vi.fn(async (config) => ({
...config,
headers: { ...config.headers, 'Authorization': 'Bearer test-token' },
}));
const api = IgniterCaller.create()
.withRequestInterceptor(interceptor)
.build();
// Interceptor runs on execute
await api.get('/test').execute();
expect(interceptor).toHaveBeenCalledTimes(1);
});
it('normalizes empty responses', async () => {
const interceptor = async (response: any) => {
if (response.data === '') {
return { ...response, data: null };
}
return response;
};
const result = await interceptor({ data: '', status: 200, headers: new Headers() });
expect(result.data).toBeNull();
});
});Best Practices
Interceptor Guidelines
- Keep interceptors focused — each interceptor should do one thing well
- Register in order — auth first, then tracing, then domain-specific logic
- Always return the config/response — don't accidentally drop the object
- Handle errors gracefully — a failing interceptor shouldn't break all requests
- Test interceptors in isolation — they're pure functions, easy to unit test
Common Pitfalls
- Don't forget to
awaitasync operations — hanging promises break the request chain - Don't mutate the config object — always spread and return a new object
- Don't use interceptors for data fetching — use the request builder's
.execute()for that - Don't register 20 interceptors — group related logic into fewer, well-named interceptors