Skip to main content

Interceptors

Interceptors allow you to modify requests before they're sent and responses after they're received. They're the ideal solution for cross-cutting concerns like authentication, logging, error handling, and request transformation.

Overview​

Interceptors act as middleware in your API request pipeline:

Request → [Request Interceptors] → Network → [Response Interceptors] → Response
TypeWhen it runsCommon uses
RequestBefore each requestAdd auth headers, modify URLs, log requests
ResponseAfter each responseLog responses, transform data, handle errors

Request Interceptors​

Request interceptors run before every request is sent. They receive the URL and request options, and must return the (potentially modified) options.

Basic Request Interceptor​

// Add a custom header to every request
api.addRequestInterceptor(async (url, options) => {
return {
...options,
headers: {
...options.headers,
'X-Custom-Header': 'custom-value'
}
}
})

Authentication Interceptor​

The most common use case is adding authentication headers:

api.addRequestInterceptor(async (url, options) => {
const token = await getAuthToken()

return {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
}
})

Dynamic Token Retrieval​

// Token that refreshes automatically
api.addRequestInterceptor(async (url, options) => {
let token = await storage.get('access_token')

// Check if token is expired
if (isTokenExpired(token)) {
token = await refreshToken()
await storage.set('access_token', token)
}

return {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
}
})

URL Modification​

Add API versioning or prefixes:

// Add API version prefix
api.addRequestInterceptor(async (url, options) => {
return {
...options,
url: `/v2${url}`
}
})

// Add tenant-specific prefix
api.addRequestInterceptor(async (url, options) => {
const tenantId = getCurrentTenantId()
return {
...options,
url: `/tenants/${tenantId}${url}`
}
})

Request ID Tracking​

Add unique identifiers for request tracing:

api.addRequestInterceptor(async (url, options) => {
const requestId = crypto.randomUUID()

// Store for later reference if needed
console.log(`Sending request ${requestId} to ${url}`)

return {
...options,
headers: {
...options.headers,
'X-Request-ID': requestId,
'X-Correlation-ID': getCorrelationId()
}
}
})

Request Logging​

Log all outgoing requests:

api.addRequestInterceptor(async (url, options) => {
console.log(`→ ${options.method || 'GET'} ${url}`)

if (options.body) {
console.log(' Body:', JSON.stringify(options.body, null, 2))
}

return options
})

Conditional Headers​

Add headers based on the request:

api.addRequestInterceptor(async (url, options) => {
const headers = { ...options.headers }

// Add admin header for admin endpoints
if (url.includes('/admin')) {
headers['X-Admin-Access'] = 'true'
}

// Add file-specific headers
if (url.includes('/upload')) {
headers['X-Upload-Mode'] = 'chunked'
}

// Add idempotency key for mutations
if (['POST', 'PUT', 'PATCH'].includes(options.method || '')) {
headers['Idempotency-Key'] = crypto.randomUUID()
}

return { ...options, headers }
})

Response Interceptors​

Response interceptors run after every response is received. They receive the response object and must return it (potentially modified).

Basic Response Interceptor​

api.addResponseInterceptor(async (response) => {
console.log(`← ${response.status} ${response.statusText} (${response.duration}ms)`)
return response
})

Response Logging​

Log response details:

api.addResponseInterceptor(async (response) => {
const icon = response.ok ? '✓' : '✗'
console.log(`${icon} ${response.status} ${response.url} (${response.duration}ms)`)

// Log errors in detail
if (!response.ok) {
console.error('Response body:', response.data)
}

return response
})

Data Transformation​

Unwrap nested response data:

// Many APIs wrap data in a container object
// { "data": { "user": { ... } }, "meta": { ... } }

api.addResponseInterceptor(async (response) => {
// Unwrap the data property
if (response.data?.data && typeof response.data.data === 'object') {
return {
...response,
data: response.data.data,
meta: response.data.meta // Preserve meta if needed
}
}
return response
})

Error Enrichment​

Add additional context to errors:

api.addResponseInterceptor(async (response) => {
if (!response.ok) {
// Enrich error with additional context
const enrichedData = {
...response.data,
_url: response.url,
_status: response.status,
_timestamp: new Date().toISOString()
}

return {
...response,
data: enrichedData
}
}
return response
})

Performance Tracking​

Collect performance metrics:

const metrics: number[] = []

api.addResponseInterceptor(async (response) => {
metrics.push(response.duration)

// Calculate running average
const avg = metrics.reduce((a, b) => a + b, 0) / metrics.length

if (response.duration > avg * 2) {
console.warn(`Slow request: ${response.url} took ${response.duration}ms (avg: ${avg.toFixed(0)}ms)`)
}

return response
})

Automatic Error Handling​

Handle common error scenarios:

api.addResponseInterceptor(async (response) => {
// Log server errors
if (response.status >= 500) {
console.error(`Server error on ${response.url}:`, response.data)
}

// Handle rate limiting
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After')
console.warn(`Rate limited. Retry after: ${retryAfter}s`)
}

// Handle maintenance mode
if (response.status === 503) {
console.warn('Service temporarily unavailable')
}

return response
})

Interceptor Order​

Interceptors run in the order they were added:

// Request interceptors: First added runs first
api.addRequestInterceptor(async (url, options) => {
console.log('1. First interceptor')
return options
})

api.addRequestInterceptor(async (url, options) => {
console.log('2. Second interceptor')
return options
})

// Output:
// 1. First interceptor
// 2. Second interceptor

For response interceptors, the order is the same:

api.addResponseInterceptor(async (response) => {
console.log('1. First response interceptor')
return response
})

api.addResponseInterceptor(async (response) => {
console.log('2. Second response interceptor')
return response
})

Interceptor Pipeline​

Request:
→ Interceptor 1 (auth) → Interceptor 2 (logging) → Network

Response:
Network → Interceptor 1 (logging) → Interceptor 2 (transform) →

Managing Interceptors​

Clear All Interceptors​

Remove all registered interceptors:

// Clear all interceptors (both request and response)
api.clearInterceptors()

Clear Specific Types​

Remove only request or response interceptors:

// Clear only request interceptors
api.clearRequestInterceptors()

// Clear only response interceptors
api.clearResponseInterceptors()

Interceptor Lifecycle​

Set up interceptors in test hooks:

describe('API Tests', () => {
// Add interceptors before tests
before(async () => {
api.addRequestInterceptor(authInterceptor)
api.addResponseInterceptor(loggingInterceptor)
})

// Clean up after tests
after(async () => {
api.clearInterceptors()
})

it('should work with interceptors', async () => {
// Auth and logging applied automatically
const response = await api.get('/users')
expect(response.status).toBe(200)
})
})

Common Use Cases​

Token Refresh on 401​

Automatically refresh tokens when they expire:

let isRefreshing = false
let refreshPromise: Promise<string> | null = null

api.addResponseInterceptor(async (response) => {
if (response.status === 401 && !isRefreshing) {
isRefreshing = true

try {
// Only one refresh at a time
refreshPromise = refreshPromise || refreshAuthToken()
const newToken = await refreshPromise

// Update the stored token
api.setHeader('Authorization', `Bearer ${newToken}`)

console.log('Token refreshed successfully')
} catch (error) {
console.error('Token refresh failed:', error)
// Redirect to login or handle appropriately
} finally {
isRefreshing = false
refreshPromise = null
}
}

return response
})

Request/Response Timing​

Track request durations for performance monitoring:

const requestTimings = new Map<string, number>()

api.addRequestInterceptor(async (url, options) => {
const requestId = crypto.randomUUID()
requestTimings.set(requestId, Date.now())

return {
...options,
headers: {
...options.headers,
'X-Request-ID': requestId
}
}
})

api.addResponseInterceptor(async (response) => {
const requestId = response.headers.get('X-Request-ID')

if (requestId && requestTimings.has(requestId)) {
const startTime = requestTimings.get(requestId)!
const duration = Date.now() - startTime

console.log(`${response.url}: ${duration}ms`)

requestTimings.delete(requestId)
}

return response
})

Request Caching​

Cache GET requests for repeated calls:

const cache = new Map<string, { data: unknown; timestamp: number }>()
const CACHE_TTL = 60000 // 1 minute

api.addRequestInterceptor(async (url, options) => {
const method = options.method || 'GET'

// Only cache GET requests
if (method !== 'GET') {
return options
}

const cached = cache.get(url)
const now = Date.now()

if (cached && now - cached.timestamp < CACHE_TTL) {
// Return cached response (you'd need custom handling for this)
console.log(`Cache hit: ${url}`)
}

return options
})

api.addResponseInterceptor(async (response) => {
// Cache successful GET responses
if (response.ok && response.request.method === 'GET') {
cache.set(response.url, {
data: response.data,
timestamp: Date.now()
})
}

return response
})

Request Deduplication​

Prevent duplicate concurrent requests:

const pendingRequests = new Map<string, Promise<unknown>>()

function getRequestKey(url: string, options: RequestInit): string {
return `${options.method || 'GET'}:${url}:${JSON.stringify(options.body)}`
}

// Note: This is a simplified example; actual implementation would be more complex

Environment-Specific Headers​

Add headers based on environment:

api.addRequestInterceptor(async (url, options) => {
const env = process.env.NODE_ENV || 'development'

const headers = {
...options.headers,
'X-Environment': env,
'X-Client-Version': process.env.npm_package_version
}

// Add debug headers in non-production
if (env !== 'production') {
headers['X-Debug-Mode'] = 'true'
headers['X-Test-Run-ID'] = process.env.TEST_RUN_ID || 'local'
}

return { ...options, headers }
})

Retry with Exponential Backoff​

Implement custom retry logic:

api.addResponseInterceptor(async (response) => {
// Only retry on specific errors
const retryableStatuses = [408, 429, 500, 502, 503, 504]

if (retryableStatuses.includes(response.status)) {
const retryCount = parseInt(response.headers.get('X-Retry-Count') || '0')
const maxRetries = 3

if (retryCount < maxRetries) {
const delay = Math.pow(2, retryCount) * 1000 // Exponential backoff
console.log(`Retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`)

await new Promise(resolve => setTimeout(resolve, delay))

// Note: Actual retry implementation would need to re-execute the request
}
}

return response
})

Best Practices​

1. Keep Interceptors Focused​

Each interceptor should do one thing well:

// Good - single responsibility
api.addRequestInterceptor(addAuthHeader)
api.addRequestInterceptor(addRequestId)
api.addRequestInterceptor(logRequest)

// Avoid - too many responsibilities
api.addRequestInterceptor(async (url, options) => {
// Auth + logging + modification + validation...
})

2. Handle Errors Gracefully​

Don't let interceptor errors break requests:

api.addRequestInterceptor(async (url, options) => {
try {
const token = await getToken()
return {
...options,
headers: { ...options.headers, 'Authorization': `Bearer ${token}` }
}
} catch (error) {
console.error('Failed to get token:', error)
// Return options unchanged rather than failing
return options
}
})

3. Document Your Interceptors​

Make it clear what each interceptor does:

/**
* Adds authentication header to all requests.
* Fetches token from secure storage and attaches as Bearer token.
*/
const authInterceptor = async (url: string, options: RequestInit) => {
// Implementation
}

/**
* Logs all requests and responses for debugging.
* Only active in development environment.
*/
const loggingInterceptor = async (response: ApiResponse) => {
// Implementation
}

4. Use Interceptors for Cross-Cutting Concerns​

Interceptors are ideal for:

  • Authentication
  • Logging
  • Error handling
  • Request/response transformation
  • Metrics collection

Next Steps​