Skip to content

Interceptors

Interceptors are a middleware system that lets you modify requests and responses globally or per-request. They’re useful for authentication, logging, error handling, retries, and more.

Interceptors wrap around the HTTP request, allowing you to:

  • Modify requests before they’re sent
  • Transform responses before they’re returned
  • Handle errors globally
  • Add authentication headers
  • Log requests and responses
  • Implement retry logic
  • Short-circuit requests (e.g., return cached responses without hitting the network)

Here’s a simple interceptor that adds a custom header:

import { HttpInterceptorFn } from 'fetchquack';
const customHeaderInterceptor: HttpInterceptorFn = async (context, next) => {
// Modify the request before it's sent
context.headers['X-Custom-Header'] = 'MyValue';
// Call next() to continue to the next interceptor or the actual request
const response = await next(context);
// Optionally inspect or modify the response
return response;
};

fetchquack includes several ready-to-use interceptors.

Automatically adds authentication tokens to requests:

import { authInterceptor } from 'fetchquack/interceptors/auth';
const client = new HttpClient({
globalInterceptors: [
authInterceptor({
getToken: () => localStorage.getItem('authToken'),
headerName: 'Authorization', // default
tokenPrefix: 'Bearer ' // default
})
]
});

Options:

OptionTypeDefaultDescription
getToken() => string | null | Promise<string | null>RequiredFunction to retrieve the auth token. Return null to skip adding the header.
headerNamestring'Authorization'Name of the auth header
tokenPrefixstring'Bearer 'Prefix before the token. Set to '' for API keys.
shouldSkipAuth(context: HttpInterceptorContext) => boolean() => falseReturn true to skip auth for certain requests

Examples:

// API Key authentication (no prefix)
authInterceptor({
getToken: () => process.env.API_KEY,
headerName: 'X-API-Key',
tokenPrefix: ''
})
// Async token with refresh
authInterceptor({
getToken: async () => {
const token = getStoredToken();
if (isExpired(token)) {
return await refreshToken();
}
return token;
}
})
// Skip auth for public endpoints
authInterceptor({
getToken: () => getToken(),
shouldSkipAuth: (ctx) => ctx.url.startsWith('/public/')
})

Logs all requests and responses for debugging:

import { loggingInterceptor } from 'fetchquack/interceptors/logging';
const client = new HttpClient({
globalInterceptors: [
loggingInterceptor({
prefix: '[API]',
secretHeaders: ['Authorization', 'X-API-Key'],
sanitizeBody: true,
shouldSkipLogging: (ctx) => ctx.url.includes('/health')
})
]
});
// Console output:
// [API] [abc123] [REQ] { method: 'GET', url: '/api/users', headers: {...} }
// [API] [abc123] [RES] { status: 200, duration: '45ms' }

Options:

OptionTypeDefaultDescription
prefixstring'[HTTP]'Prefix for log messages
secretHeadersstring[]['Authorization']Headers to mask as '<secret>' in logs (case-insensitive)
sanitizeBodybooleanfalseHide request/response bodies in logs
colorizeRequestIdbooleantrueUse ANSI colors for request IDs
shouldSkipLogging(ctx: HttpInterceptorContext) => boolean() => falseReturn true to skip logging for certain requests

Adds custom headers to requests:

import { headerInterceptor } from 'fetchquack/interceptors/header';
const client = new HttpClient({
globalInterceptors: [
headerInterceptor({
headers: {
'X-API-Version': '2.0',
'X-Client-Type': 'web',
'Accept-Language': 'en-US'
}
})
]
});

Options:

OptionTypeDefaultDescription
headersRecord<string, string>RequiredHeaders to add to every request
shouldAddHeaders(context: HttpInterceptorContext) => boolean() => trueReturn false to skip adding headers for certain requests

Example with conditional headers:

headerInterceptor({
headers: { 'X-Internal': 'true' },
shouldAddHeaders: (ctx) => ctx.url.startsWith('/internal/')
})

Automatically handles CSRF tokens (browser only):

import { csrfInterceptor } from 'fetchquack/interceptors/csrf';
const client = new HttpClient({
globalInterceptors: [
csrfInterceptor({
cookieName: 'XSRF-TOKEN', // default
headerName: 'X-XSRF-TOKEN', // default
protectedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'] // default
})
]
});

Options:

OptionTypeDefaultDescription
cookieNamestring'XSRF-TOKEN'Cookie name containing the CSRF token
headerNamestring'X-XSRF-TOKEN'Header name to send the token in
protectedMethodsstring[]['POST', 'PUT', 'PATCH', 'DELETE']HTTP methods that require CSRF protection

Example for Django:

csrfInterceptor({
cookieName: 'csrftoken',
headerName: 'X-CSRFToken'
})

Measure request duration:

import { HttpInterceptorFn } from 'fetchquack';
const timingInterceptor: HttpInterceptorFn = async (context, next) => {
const start = performance.now();
try {
const response = await next(context);
const duration = performance.now() - start;
console.log(`${context.method} ${context.url} - ${duration.toFixed(2)}ms`);
return response;
} catch (error) {
const duration = performance.now() - start;
console.error(`${context.method} ${context.url} - FAILED after ${duration.toFixed(2)}ms`);
throw error;
}
};

Automatically retry failed requests:

import { HttpInterceptorFn, HttpError } from 'fetchquack';
const retryInterceptor: HttpInterceptorFn = async (context, next) => {
const maxRetries = 3;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await next(context);
} catch (error) {
lastError = error;
// Don't retry on 4xx errors (client errors)
if (error instanceof HttpError && error.statusCode >= 400 && error.statusCode < 500) {
throw error;
}
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retrying request (attempt ${attempt + 2}/${maxRetries + 1})...`);
}
}
}
throw lastError;
};

Transform errors into a consistent format:

const errorHandlerInterceptor: HttpInterceptorFn = async (context, next) => {
try {
return await next(context);
} catch (error) {
if (error instanceof HttpError) {
throw new AppError({
code: `HTTP_${error.statusCode}`,
message: error.message,
statusCode: error.statusCode,
url: context.url
});
}
throw error;
}
};

Implement simple response caching:

const cache = new Map<string, { response: HttpInterceptorResponse; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const cacheInterceptor: HttpInterceptorFn = async (context, next) => {
// Only cache GET requests
if (context.method !== 'GET') {
return next(context);
}
const cacheKey = context.url;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
console.log('Cache hit for', cacheKey);
return cached.response;
}
const response = await next(context);
cache.set(cacheKey, { response, timestamp: Date.now() });
return response;
};

Interceptors receive a context object with request information:

interface HttpInterceptorContext {
method: string; // HTTP method (uppercased)
url: string; // Request URL
body?: string | object | null; // Request body
headers: Record<string, string>; // HTTP headers (mutable)
metadata?: Record<string, any>; // Custom metadata for passing data between interceptors
}

The metadata field is used internally to flag streaming and SSE requests:

  • metadata.streaming === true for fetchStream() and sse() calls
  • metadata.sse === true for sse() calls

You can also use metadata to pass data between your own interceptors:

const addRequestIdInterceptor: HttpInterceptorFn = async (context, next) => {
context.metadata = context.metadata || {};
context.metadata.requestId = crypto.randomUUID();
context.headers['X-Request-ID'] = context.metadata.requestId;
return next(context);
};

Applied to all requests made with a client:

const client = new HttpClient({
globalInterceptors: [
authInterceptor({ getToken: () => getToken() }),
loggingInterceptor()
]
});

Applied to specific requests only:

await client.fetch({
method: 'GET',
url: '/api/special',
interceptors: [
specialAuthInterceptor,
cacheInterceptor
]
});

Request-level interceptors run after global interceptors:

const client = new HttpClient({
globalInterceptors: [loggingInterceptor()] // Runs first
});
await client.fetch({
method: 'GET',
url: '/api/data',
interceptors: [cacheInterceptor] // Runs second
});
// Execution order:
// 1. loggingInterceptor (pre-request)
// 2. cacheInterceptor (pre-request) → actual HTTP request
// 3. cacheInterceptor (post-response)
// 4. loggingInterceptor (post-response)

Interceptors execute in order, wrapping each other like layers of an onion:

const client = new HttpClient({
globalInterceptors: [
loggingInterceptor(), // 1st: logs request → last to process response
authInterceptor(...), // 2nd: adds auth header
headerInterceptor(...) // 3rd: adds headers → actual HTTP request
]
});
  1. Request passes through each interceptor in order
  2. The actual HTTP request is made
  3. Response comes back through each interceptor in reverse order

In Angular, interceptors can use dependency injection thanks to provideNgxHttpClient():

import { inject } from '@angular/core';
import { HttpInterceptorFn } from 'fetchquack';
const angularAuthInterceptor: HttpInterceptorFn = async (context, next) => {
// inject() works here because provideNgxHttpClient wraps interceptors
// in Angular's injection context
const authService = inject(AuthService);
const token = await authService.getToken();
if (token) {
context.headers['Authorization'] = `Bearer ${token}`;
}
return next(context);
};
// In app.config.ts
import { provideNgxHttpClient } from 'fetchquack/ngx';
export const appConfig: ApplicationConfig = {
providers: [
provideNgxHttpClient({
globalInterceptors: [angularAuthInterceptor]
})
]
};

Each interceptor should have a single responsibility:

// Good - focused interceptors
const client = new HttpClient({
globalInterceptors: [
authInterceptor({ ... }),
loggingInterceptor(),
retryInterceptor
]
});
// Avoid - one interceptor doing too much
const megaInterceptor = async (context, next) => {
// adds auth, logs, retries, caches, etc.
};

Place interceptors in logical order:

const client = new HttpClient({
globalInterceptors: [
loggingInterceptor(), // Log everything first
authInterceptor(...), // Add auth before other modifications
headerInterceptor(...), // Add other headers
retryInterceptor // Retry should wrap the actual request
]
});

Always consider error cases in your interceptors:

const safeInterceptor: HttpInterceptorFn = async (context, next) => {
try {
context.headers['X-Custom'] = 'value';
return await next(context);
} catch (error) {
console.error('Request failed:', error);
throw error; // Always re-throw unless you're intentionally handling the error
}
};