An HTTP/1.1 client, written from scratch for Node.js
—
Composable interceptor system for request/response processing with built-in interceptors for common needs like retries, caching, and compression.
The interceptor system allows composing middleware-style request/response processing through dispatcher composition.
/**
* Built-in interceptors for common HTTP client needs
*/
const interceptors: {
redirect(options?: RedirectInterceptorOpts): DispatcherComposeInterceptor;
retry(options?: RetryInterceptorOpts): DispatcherComposeInterceptor;
cache(options?: CacheInterceptorOpts): DispatcherComposeInterceptor;
decompress(options?: DecompressInterceptorOpts): DispatcherComposeInterceptor;
dump(options?: DumpInterceptorOpts): DispatcherComposeInterceptor;
dns(options?: DnsInterceptorOpts): DispatcherComposeInterceptor;
responseError(options?: ResponseErrorInterceptorOpts): DispatcherComposeInterceptor;
};
type DispatcherComposeInterceptor = (dispatch: Dispatcher['dispatch']) => Dispatcher['dispatch'];
interface Dispatcher {
compose(interceptors: DispatcherComposeInterceptor[]): ComposedDispatcher;
compose(...interceptors: DispatcherComposeInterceptor[]): ComposedDispatcher;
}Usage Examples:
import { Agent, interceptors } from 'undici';
// Create dispatcher with multiple interceptors
const agent = new Agent()
.compose(
interceptors.retry({ maxRetries: 3 }),
interceptors.redirect({ maxRedirections: 5 }),
interceptors.decompress()
);
// Use composed dispatcher
const response = await agent.request({
origin: 'https://api.example.com',
path: '/data'
});Automatic HTTP redirect handling with configurable limits and policies.
/**
* HTTP redirect interceptor
* @param options - Redirect configuration
* @returns Interceptor function
*/
function redirect(options?: RedirectInterceptorOpts): DispatcherComposeInterceptor;
interface RedirectInterceptorOpts {
maxRedirections?: number;
throwOnMaxRedirections?: boolean;
}Usage Examples:
import { Pool, interceptors } from 'undici';
// Configure redirect behavior
const pool = new Pool('https://api.example.com')
.compose(interceptors.redirect({
maxRedirections: 10,
throwOnMaxRedirections: true
}));
// Requests automatically follow redirects
const response = await pool.request({
path: '/redirect-chain',
method: 'GET'
});
console.log(response.context.history); // Array of redirected URLsAutomatic request retry with exponential backoff and configurable retry conditions.
/**
* Request retry interceptor
* @param options - Retry configuration
* @returns Interceptor function
*/
function retry(options?: RetryInterceptorOpts): DispatcherComposeInterceptor;
interface RetryInterceptorOpts {
retry?: (err: Error, context: RetryContext) => number | null;
maxRetries?: number;
maxTimeout?: number;
minTimeout?: number;
timeoutFactor?: number;
retryAfter?: boolean;
methods?: string[];
statusCodes?: number[];
errorCodes?: string[];
}
interface RetryContext {
state: RetryState;
opts: RetryInterceptorOpts;
}
interface RetryState {
counter: number;
currentTimeout: number;
}Usage Examples:
import { Agent, interceptors } from 'undici';
// Basic retry configuration
const agent = new Agent()
.compose(interceptors.retry({
maxRetries: 3,
methods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],
statusCodes: [408, 413, 429, 500, 502, 503, 504],
errorCodes: ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN']
}));
// Custom retry logic with exponential backoff
const customRetryAgent = new Agent()
.compose(interceptors.retry({
retry: (err, { state, opts }) => {
const { counter, currentTimeout } = state;
if (counter >= opts.maxRetries) {
return null; // Stop retrying
}
// Exponential backoff with jitter
const delay = Math.min(
currentTimeout * Math.pow(2, counter),
opts.maxTimeout || 30000
);
return delay + Math.random() * 1000;
},
maxRetries: 5,
maxTimeout: 30000,
minTimeout: 1000,
retryAfter: true // Respect Retry-After header
}));
// Make request with retry
const response = await customRetryAgent.request({
origin: 'https://unreliable-api.example.com',
path: '/data'
});HTTP caching interceptor with configurable cache stores and policies.
/**
* HTTP caching interceptor
* @param options - Cache configuration
* @returns Interceptor function
*/
function cache(options?: CacheInterceptorOpts): DispatcherComposeInterceptor;
interface CacheInterceptorOpts {
store?: CacheStore;
methods?: string[];
vary?: string[];
cacheByDefault?: number;
type?: 'shared' | 'private';
}
interface CacheStore {
get(key: string): Promise<CacheValue | undefined>;
set(key: string, value: CacheValue, ttl?: number): Promise<void>;
delete(key: string): Promise<boolean>;
}
interface CacheValue {
statusCode: number;
statusMessage: string;
headers: Record<string, string>;
body: Buffer;
cacheControlDirectives: Record<string, string | boolean>;
vary: Record<string, string>;
}Usage Examples:
import { Agent, interceptors, cacheStores } from 'undici';
// Use memory cache store
const agent = new Agent()
.compose(interceptors.cache({
store: new cacheStores.MemoryCacheStore(),
methods: ['GET', 'HEAD'],
cacheByDefault: 300, // 5 minutes default TTL
type: 'private'
}));
// Use SQLite cache store for persistence
const persistentAgent = new Agent()
.compose(interceptors.cache({
store: new cacheStores.SqliteCacheStore('./http-cache.db'),
methods: ['GET'],
vary: ['user-agent', 'accept-encoding']
}));
// Make cacheable requests
const response1 = await agent.request({
origin: 'https://api.example.com',
path: '/users'
});
// Second request hits cache
const response2 = await agent.request({
origin: 'https://api.example.com',
path: '/users'
});Automatic response decompression for gzip, deflate, and brotli encodings.
/**
* Response decompression interceptor
* @param options - Decompression configuration
* @returns Interceptor function
*/
function decompress(options?: DecompressInterceptorOpts): DispatcherComposeInterceptor;
interface DecompressInterceptorOpts {
encodings?: string[];
maxResponseSize?: number;
}Usage Examples:
import { Pool, interceptors } from 'undici';
// Enable automatic decompression
const pool = new Pool('https://api.example.com')
.compose(interceptors.decompress({
encodings: ['gzip', 'deflate', 'br'], // brotli, gzip, deflate
maxResponseSize: 10 * 1024 * 1024 // 10MB limit
}));
// Requests automatically include Accept-Encoding header
// and responses are decompressed transparently
const response = await pool.request({
path: '/compressed-data',
method: 'GET'
});
const data = await response.body.json();Request/response dumping for debugging and logging purposes.
/**
* Request/response dumping interceptor
* @param options - Dump configuration
* @returns Interceptor function
*/
function dump(options?: DumpInterceptorOpts): DispatcherComposeInterceptor;
interface DumpInterceptorOpts {
request?: boolean;
response?: boolean;
requestHeaders?: boolean;
responseHeaders?: boolean;
requestBody?: boolean;
responseBody?: boolean;
maxBodySize?: number;
logger?: (message: string) => void;
}Usage Examples:
import { Client, interceptors } from 'undici';
// Dump all request/response data
const client = new Client('https://api.example.com')
.compose(interceptors.dump({
request: true,
response: true,
requestHeaders: true,
responseHeaders: true,
requestBody: true,
responseBody: true,
maxBodySize: 1024, // Only dump first 1KB of body
logger: console.log
}));
// All requests will be logged
const response = await client.request({
path: '/debug',
method: 'POST',
body: JSON.stringify({ test: 'data' })
});
// Custom logger
const debugClient = new Client('https://api.example.com')
.compose(interceptors.dump({
response: true,
responseHeaders: true,
logger: (message) => {
// Custom logging logic
console.log(`[HTTP DEBUG] ${new Date().toISOString()} ${message}`);
}
}));DNS caching and resolution interceptor for improved performance.
/**
* DNS caching and resolution interceptor
* @param options - DNS configuration
* @returns Interceptor function
*/
function dns(options?: DnsInterceptorOpts): DispatcherComposeInterceptor;
interface DnsInterceptorOpts {
maxItems?: number;
maxTtl?: number;
lookup?: (hostname: string, options: any, callback: (err: Error | null, address: string, family: number) => void) => void;
}Usage Examples:
import { Agent, interceptors } from 'undici';
import { lookup } from 'dns';
// DNS caching for improved performance
const agent = new Agent()
.compose(interceptors.dns({
maxItems: 100, // Cache up to 100 DNS entries
maxTtl: 300000, // 5 minutes TTL
lookup: lookup // Use Node.js built-in DNS lookup
}));
// Subsequent requests to same hostname use cached DNS
const responses = await Promise.all([
agent.request({ origin: 'https://api.example.com', path: '/endpoint1' }),
agent.request({ origin: 'https://api.example.com', path: '/endpoint2' }),
agent.request({ origin: 'https://api.example.com', path: '/endpoint3' })
]);Enhanced error handling for HTTP response errors with detailed error information.
/**
* Response error handling interceptor
* @param options - Error handling configuration
* @returns Interceptor function
*/
function responseError(options?: ResponseErrorInterceptorOpts): DispatcherComposeInterceptor;
interface ResponseErrorInterceptorOpts {
throwOnError?: boolean;
statusCodes?: number[];
includeResponseBody?: boolean;
maxResponseBodySize?: number;
}Usage Examples:
import { Pool, interceptors } from 'undici';
// Throw errors for 4xx and 5xx responses
const pool = new Pool('https://api.example.com')
.compose(interceptors.responseError({
throwOnError: true,
statusCodes: [400, 401, 403, 404, 500, 502, 503, 504],
includeResponseBody: true,
maxResponseBodySize: 1024
}));
try {
const response = await pool.request({
path: '/nonexistent',
method: 'GET'
});
} catch (error) {
console.log(error.statusCode); // 404
console.log(error.statusMessage); // Not Found
console.log(error.responseBody); // Error response body
}Create custom interceptors for application-specific needs.
/**
* Custom interceptor example
*/
function customInterceptor(options = {}) {
return (dispatch) => {
return (opts, handler) => {
// Pre-request processing
const modifiedOpts = {
...opts,
headers: {
...opts.headers,
'x-custom-header': 'custom-value'
}
};
// Wrap handler for post-response processing
const wrappedHandler = {
...handler,
onComplete(trailers) {
// Post-response processing
console.log('Request completed');
handler.onComplete(trailers);
},
onError(error) {
// Error processing
console.log('Request failed:', error.message);
handler.onError(error);
}
};
return dispatch(modifiedOpts, wrappedHandler);
};
};
}Usage Examples:
import { Agent } from 'undici';
// Authentication interceptor
function authInterceptor(token) {
return (dispatch) => {
return (opts, handler) => {
return dispatch({
...opts,
headers: {
...opts.headers,
'authorization': `Bearer ${token}`
}
}, handler);
};
};
}
// Rate limiting interceptor
function rateLimitInterceptor(requestsPerSecond = 10) {
let lastRequestTime = 0;
const minInterval = 1000 / requestsPerSecond;
return (dispatch) => {
return async (opts, handler) => {
const now = Date.now();
const timeSinceLastRequest = now - lastRequestTime;
if (timeSinceLastRequest < minInterval) {
await new Promise(resolve =>
setTimeout(resolve, minInterval - timeSinceLastRequest)
);
}
lastRequestTime = Date.now();
return dispatch(opts, handler);
};
};
}
// Use custom interceptors
const agent = new Agent()
.compose(
authInterceptor('your-auth-token'),
rateLimitInterceptor(5), // 5 requests per second
customInterceptor({ option: 'value' })
);import { Agent, interceptors, cacheStores } from 'undici';
// Create comprehensive HTTP client with all features
const httpClient = new Agent({
factory: (origin, opts) => {
return new Pool(origin, {
...opts,
connections: 10,
pipelining: 1
});
}
})
.compose(
// Request/response logging
interceptors.dump({
request: true,
response: true,
requestHeaders: true,
responseHeaders: true,
logger: (msg) => console.log(`[HTTP] ${msg}`)
}),
// DNS caching
interceptors.dns({
maxItems: 100,
maxTtl: 300000
}),
// HTTP caching
interceptors.cache({
store: new cacheStores.MemoryCacheStore(),
methods: ['GET', 'HEAD'],
cacheByDefault: 300
}),
// Automatic decompression
interceptors.decompress(),
// Automatic redirects
interceptors.redirect({
maxRedirections: 5
}),
// Retry with exponential backoff
interceptors.retry({
maxRetries: 3,
methods: ['GET', 'HEAD', 'OPTIONS'],
statusCodes: [408, 413, 429, 500, 502, 503, 504]
}),
// Enhanced error handling
interceptors.responseError({
throwOnError: true,
statusCodes: [400, 401, 403, 404, 500, 502, 503],
includeResponseBody: true
})
);
// All features work together automatically
const response = await httpClient.request({
origin: 'https://api.example.com',
path: '/data',
method: 'GET'
});Install with Tessl CLI
npx tessl i tessl/npm-undici