@reggieofarrell/http-client
v2.3.1
Published
A lightweight HTTP client for both the server and browser built on `xior` with retry functionality, written in TypeScript
Maintainers
Readme
Http Client
A lightweight HTTP client for both the server and browser built on xior with retry functionality, written in TypeScript.
Installation
npm install @reggieofarrell/http-clientOpenAPI SDK Code Generator
⚠️ Beta Feature: The code generator is currently in beta. While it's fully functional, the API may change in future versions based on user feedback.
Generate strongly-typed SDK clients from OpenAPI 3.0+ specifications! The code generator creates TypeScript clients that extend HttpClient with organized route groups and TypeScript types generated by openapi-typescript.
# Install peer dependencies for code generation
npm install openapi-typescriptimport { generateClient } from '@reggieofarrell/http-client/codegen';
// Generate a complete SDK from your OpenAPI spec
await generateClient({
openApiSpec: './openapi.json',
outputDir: './src/api-client',
clientName: 'MyApiClient',
});📖 Read the full Code Generator documentation →
What is Xior?
Xior is a lightweight (~6KB) fetch-based HTTP client with an axios-like API. It supports plugins, interceptors, and provides similar functionality to axios while being built on the modern fetch API.
Built on
This package is built on top of @reggieofarrell/axios-retry-client v2 and provides a similar API, but uses xior instead of axios for smaller bundle size and modern fetch-based architecture.
Usage
Configuration Options
The HttpClient accepts the following configuration options:
xiorConfig: Configuration for the underlying xior instance. This includes timeout settings, headers, and other xior-specific options.baseURL: Base URL for the API.debug: Whether to log request and response details.debugLevel: Debug level. 'normal' will log request and response data. 'verbose' will log all xior properties for the request and response.name: Name of the client. Used for logging.retryConfig: Configuration for error retry functionality. The default config if you don't override it is{ retries: 0, retryDelay: exponentialDelay, delayFactor: 500, backoff: 'exponential', backoffJitter: 'none' }. You can override individual properties in theretryConfigand they will be merged with the default. We adddelayFactor,backoff, andbackoffJitterto make configuring the retry delay easier. Otherwise you'd have to create your ownretryDelayfunction (which you can still do if you like).idempotencyConfig: Configuration for idempotency key generation. The default config is{ enabled: false, methods: ['POST', 'PATCH'], headerName: 'Idempotency-Key' }. This helps prevent duplicate operations when requests are retried due to network issues or timeouts. Available methods include GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS, though HEAD and OPTIONS are typically not used for idempotency.errorMessagePath: Path or function to extract error messages from HTTP error responses. Defaults to"data.message". Supports dot notation for nested paths (e.g.,"data.error.detail") or custom functions for complex extraction logic.
For more details, refer to the source code.
Basic Setup
import { HttpClient } from '@reggieofarrell/http-client';
const client = new HttpClient({
baseURL: 'https://api.example.com',
name: 'ExampleClient',
xiorConfig: {
timeout: 30000 // 30 second timeout
},
retryConfig: {
retries: 2
}
});Making Requests
GET Request
const { data } = await client.get('/endpoint');
console.log(data);POST Request
const { data } = await client.post('/endpoint', { key: 'value' });
console.log(data);PUT Request
const { data } = await client.put('/endpoint', { key: 'value' });
console.log(data);PATCH Request
const { data } = await client.patch('/endpoint', { key: 'value' });
console.log(data);DELETE Request
const { data } = await client.delete('/endpoint');
console.log(data);HEAD Request
const { data } = await client.head('/endpoint');
console.log(data);OPTIONS Request
const { data } = await client.options('/endpoint');
console.log(data);Direct Request Method
For maximum flexibility, you can use the request method directly with any HTTP method:
import { HttpClient, RequestType } from '@reggieofarrell/http-client';
// Using RequestType enum
const { data } = await client.request(RequestType.GET, '/endpoint');
const { data } = await client.request(RequestType.POST, '/endpoint', { key: 'value' });
const { data } = await client.request(RequestType.HEAD, '/endpoint');
const { data } = await client.request(RequestType.OPTIONS, '/endpoint');Request Configuration
You can pass additional configuration options to any request:
const { data } = await client.get('/endpoint', {
headers: {
'X-Some-Header': 'value'
},
timeout: 5000
})In addition to the XiorRequestConfig options, you can also override retry options per request:
const { data } = await client.get('/endpoint', {
retryConfig: {
retries: 5,
delayFactor: 1000,
backoff: 'linear',
enableRetry: (config, error) => {
// Custom retry logic - only retry on specific errors
// Note: error is a XiorError during retry evaluation
return error.response?.status === 503;
}
}
})Note: Per-request retry configuration leverages xior's built-in error-retry plugin options that are applied at the request level.
Path Parameters
You can use path parameters in URLs by defining them with the :paramName format and providing values via the pathParams config option. Path parameter values are automatically URL-encoded for safety.
Basic Usage
// Single path parameter
const { data } = await client.get('/users/:userId', {
pathParams: { userId: '123' }
});
// Results in: /users/123Multiple Path Parameters
// Multiple path parameters
const { data } = await client.get('/users/:userId/posts/:postId', {
pathParams: { userId: '123', postId: '456' }
});
// Results in: /users/123/posts/456Path Parameters with All HTTP Methods
Path parameters work with all HTTP methods:
// GET request
const { data } = await client.get('/users/:userId', {
pathParams: { userId: '123' }
});
// POST request
const { data } = await client.post('/users/:userId/posts', { title: 'New Post' }, {
pathParams: { userId: '123' }
});
// PUT request
const { data } = await client.put('/users/:userId/posts/:postId', { title: 'Updated' }, {
pathParams: { userId: '123', postId: '456' }
});
// PATCH request
const { data } = await client.patch('/users/:userId', { name: 'John' }, {
pathParams: { userId: '123' }
});
// DELETE request
const { data } = await client.delete('/users/:userId/posts/:postId', {
pathParams: { userId: '123', postId: '456' }
});URL Encoding
Path parameter values are automatically URL-encoded, so special characters are handled safely:
// Special characters are automatically encoded
const { data } = await client.get('/users/:userId', {
pathParams: { userId: '[email protected]' }
});
// Results in: /users/user%40example.comNumber Values
You can pass numbers as path parameter values - they'll be automatically converted to strings:
const { data } = await client.get('/posts/:postId', {
pathParams: { postId: 12345 }
});
// Results in: /posts/12345Error Handling
If you provide a URL with path parameters but don't provide the corresponding values, an error will be thrown:
// This will throw an error
try {
await client.get('/users/:userId', {});
} catch (error) {
// Error: Missing required path parameters: userId. Provide values via pathParams config.
}Combining with Other Config Options
Path parameters can be combined with other configuration options:
const { data } = await client.get('/users/:userId/posts/:postId', {
pathParams: { userId: '123', postId: '456' },
headers: {
'X-Custom-Header': 'value'
},
timeout: 5000,
retryConfig: {
retries: 3
}
});Query Parameters
You can pass query parameters to requests using either the query or params property. Both are aliases for the same functionality - query is provided as a more intuitive name, while params matches the XiorRequestConfig API. If both are provided, query takes precedence.
Basic Usage
// Using the `query` alias
const { data } = await client.get('/users', {
query: { limit: 10, offset: 0 }
});
// Results in: /users?limit=10&offset=0
// Using the `params` property (XiorRequestConfig API)
const { data } = await client.get('/users', {
params: { limit: 10, offset: 0 }
});
// Results in: /users?limit=10&offset=0Query Parameters with All HTTP Methods
Query parameters work with all HTTP methods:
// GET request
const { data } = await client.get('/users', {
query: { status: 'active', limit: 20 }
});
// POST request
const { data } = await client.post('/search', { query: 'test' }, {
query: { page: 1, perPage: 10 }
});
// DELETE request
const { data } = await client.delete('/users', {
query: { userId: '123' }
});Combining Query Parameters with Path Parameters
You can use both query parameters and path parameters together:
const { data } = await client.get('/users/:userId/posts', {
pathParams: { userId: '123' },
query: { limit: 10, sort: 'date' }
});
// Results in: /users/123/posts?limit=10&sort=dateQuery Parameter Precedence
If both query and params are provided, query takes precedence:
// query will be used, params will be ignored
const { data } = await client.get('/users', {
query: { limit: 10 },
params: { limit: 20 } // This will be ignored
});
// Results in: /users?limit=10Timeout Configuration
The HttpClient supports timeout configuration through Xior's built-in timeout functionality. You can set timeouts globally for all requests or per-request.
Global Timeout Configuration
Set a default timeout for all requests when creating the client:
const client = new HttpClient({
baseURL: 'https://api.example.com',
xiorConfig: {
timeout: 30000 // 30 seconds
}
});Per-Request Timeout Configuration
Override the timeout for specific requests:
// Short timeout for quick requests
const { data } = await client.get('/fast-endpoint', {
timeout: 5000 // 5 seconds
});
// Longer timeout for slow operations
const { data } = await client.post('/slow-operation', payload, {
timeout: 120000 // 2 minutes
});Timeout Error Handling
When a request times out, Xior throws an AbortError. Handle timeout errors appropriately:
try {
const { data } = await client.get('/endpoint', {
timeout: 10000 // 10 seconds
});
console.log(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request timed out');
// Handle timeout - maybe retry with longer timeout
} else {
console.log('Other error:', error.message);
}
}Timeout with Retry Configuration
Combine timeout configuration with retry logic for robust error handling:
const client = new HttpClient({
baseURL: 'https://api.example.com',
xiorConfig: {
timeout: 15000 // 15 second default timeout
},
retryConfig: {
retries: 3,
delayFactor: 1000,
enableRetry: (config, error) => {
// Retry on timeout errors and server errors
// Note: error is a XiorError during retry evaluation
return error.name === 'AbortError' ||
(error.response && error.response.status >= 500);
}
}
});
// This request will timeout after 15 seconds, then retry up to 3 times
const { data } = await client.get('/unreliable-endpoint');The timeout value is passed directly to the underlying fetch API's AbortController, providing native browser and Node.js timeout support.
Aborting In-Flight Requests
You can abort in-flight requests using the AbortController API. This is useful for canceling requests when users navigate away, components unmount, or when you need to cancel long-running operations.
Basic Request Abortion
const controller = new AbortController();
// Start a request
const requestPromise = client.get('/long-running-endpoint', {
signal: controller.signal
});
// Abort the request after 5 seconds
setTimeout(() => {
controller.abort();
}, 5000);
try {
const { data } = await requestPromise;
console.log(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was aborted');
} else {
console.log('Other error:', error.message);
}
}Aborting Multiple Requests
const controller = new AbortController();
// Start multiple requests with the same abort signal
const requests = [
client.get('/endpoint1', { signal: controller.signal }),
client.get('/endpoint2', { signal: controller.signal }),
client.get('/endpoint3', { signal: controller.signal })
];
// Abort all requests
controller.abort();
// All requests will be cancelled
try {
await Promise.all(requests);
} catch (error) {
console.log('All requests were aborted');
}Retry Configuration with Jitter
The retry system supports configurable backoff strategies with optional jitter to prevent the "thundering herd" problem when multiple clients retry simultaneously.
Backoff Strategies
exponential(default):delayFactor * 2^(retryCount - 1)- Doubles delay with each retrylinear:delayFactor * retryCount- Increases delay linearlynone: ConstantdelayFactordelay for all retries
Jitter Strategies
Jitter adds randomness to prevent multiple clients from retrying at the exact same time:
none(default): No jitter, deterministic delaysfull: Random delay between 0 and the calculated backoff delayequal: Half deterministic, half random -delay/2 + random(0, delay/2)decorrelated: Random delay with adaptive upper bound -random(delayFactor, delay * 3)
Example Configurations
Exponential backoff with full jitter (recommended for distributed systems):
const client = new HttpClient({
baseURL: 'https://api.example.com',
retryConfig: {
retries: 3,
delayFactor: 1000,
backoff: 'exponential',
backoffJitter: 'full'
}
});
// Retry delays (with delayFactor=1000ms):
// - Retry 1: random(0, 1000ms)
// - Retry 2: random(0, 2000ms)
// - Retry 3: random(0, 4000ms)Linear backoff with equal jitter:
const client = new HttpClient({
baseURL: 'https://api.example.com',
retryConfig: {
retries: 3,
delayFactor: 500,
backoff: 'linear',
backoffJitter: 'equal'
}
});
// Retry delays (with delayFactor=500ms):
// - Retry 1: 250ms + random(0, 250ms) = 250-500ms
// - Retry 2: 500ms + random(0, 500ms) = 500-1000ms
// - Retry 3: 750ms + random(0, 750ms) = 750-1500msPer-request jitter override:
// Instance defaults to no jitter
const client = new HttpClient({
baseURL: 'https://api.example.com',
retryConfig: {
retries: 2,
delayFactor: 1000,
backoff: 'exponential',
backoffJitter: 'none'
}
});
// Override with full jitter for specific request
const { data } = await client.get('/critical-endpoint', {
retryConfig: {
retries: 5,
backoffJitter: 'full'
}
});Retry-After Header Support
The client automatically respects Retry-After headers from server responses. When present, the server-specified delay takes precedence over calculated backoff delays, and jitter is not applied to server-specified delays.
// If the server returns "Retry-After: 10" (10 seconds)
// The client will wait exactly 10 seconds regardless of jitter settingsThe Retry-After header can be:
- A number (seconds to wait)
- An HTTP date string (absolute time to retry)
Idempotency Controls
Idempotency controls help prevent duplicate operations when requests are retried due to network issues, timeouts, or client-side errors. This is especially important for operations like payments, order creation, or data mutations that shouldn't be repeated.
What is Idempotency?
An idempotent operation is one that can be performed multiple times with the same result. For example, if you create a payment and the request times out, you can safely retry the same request without creating a duplicate payment.
Basic Idempotency Setup
const client = new HttpClient({
baseURL: 'https://api.example.com',
idempotencyConfig: {
enabled: true,
methods: ['POST', 'PATCH'], // Only for mutation operations
headerName: 'Idempotency-Key'
}
});
// POST requests will automatically include an idempotency key
const { data } = await client.post('/payments', {
amount: 1000,
currency: 'USD'
});Idempotency Configuration Options
interface IdempotencyConfig {
/**
* Enable idempotency key generation
* @default false
*/
enabled?: boolean;
/**
* HTTP methods that should include idempotency keys
* @default ['POST', 'PATCH']
*/
methods?: RequestType[];
/**
* Header name for idempotency key
* @default 'Idempotency-Key'
*/
headerName?: string;
/**
* Custom function to generate idempotency keys
* @default counter-based key generation
*/
keyGenerator?: () => string;
}Per-Request Idempotency
You can override idempotency settings for individual requests:
// Disable idempotency for a specific request
const { data } = await client.post('/endpoint', payload, {
idempotencyConfig: {
enabled: false
}
});
// Use a custom idempotency key
const { data } = await client.post('/endpoint', payload, {
idempotencyKey: 'my-custom-key-123'
});
// Override methods for this request
const { data } = await client.put('/endpoint', payload, {
idempotencyConfig: {
enabled: true,
methods: ['PUT']
}
});Manual Idempotency Key
You can provide your own idempotency key for specific requests:
const { data } = await client.post('/payments', paymentData, {
idempotencyKey: 'payment-123-abc'
});Custom Key Generation
Use a custom function to generate idempotency keys:
const client = new HttpClient({
baseURL: 'https://api.example.com',
idempotencyConfig: {
enabled: true,
keyGenerator: () => `custom-${Date.now()}-${Math.random().toString(36)}`
}
});Retry Scenarios
The client automatically handles retry scenarios by reusing the same idempotency key:
const client = new HttpClient({
baseURL: 'https://api.example.com',
idempotencyConfig: {
enabled: true,
methods: ['POST']
},
retryConfig: {
retries: 3,
delayFactor: 1000
}
});
// If this request fails and retries, the same idempotency key will be used
const { data } = await client.post('/critical-operation', data);Custom Header Names
Use custom header names for idempotency keys:
const client = new HttpClient({
baseURL: 'https://api.example.com',
idempotencyConfig: {
enabled: true,
headerName: 'X-Request-ID'
}
});Method-Specific Configuration
Configure different methods to use idempotency:
const client = new HttpClient({
baseURL: 'https://api.example.com',
idempotencyConfig: {
enabled: true,
methods: ['POST', 'PUT', 'PATCH'] // Include PUT operations
}
});Best Practices
- Enable for mutation operations: Only enable idempotency for POST, PUT, and PATCH requests
- Use descriptive keys: When providing manual keys, use descriptive names
- Server-side handling: Ensure your API server properly handles idempotency keys
- Key cleanup: Keys are automatically cleaned up after successful requests
- Retry scenarios: The same key is reused during retries, preventing duplicate operations
Disable TLS checks (server only - Node.js)
If necessary you can disable the TLS checks in case the server you are hitting is using a self-signed certificate.
import { HttpClient } from '@reggieofarrell/http-client';
import https from 'https';
const client = new HttpClient({
baseURL: 'https://api.example.com',
xiorConfig: {
// @ts-ignore
httpsAgent: new https.Agent({
rejectUnauthorized: false
})
}
});Different Request Data Types
The HttpClient supports various data types for requests:
FormData (File Uploads)
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('description', 'My file upload');
const { data } = await client.post('/upload', formData);URL-Encoded Form Data
const params = new URLSearchParams();
params.append('username', 'johndoe');
params.append('password', 'secret123');
const { data } = await client.post('/login', params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});Plain Text
const textData = 'Hello World';
const { data } = await client.post('/text', textData, {
headers: {
'Content-Type': 'text/plain'
}
});XML Data
const xmlData = '<?xml version="1.0"?><root><item>value</item></root>';
const { data } = await client.post('/xml', xmlData, {
headers: {
'Content-Type': 'application/xml'
}
});Binary Data
const binaryData = new ArrayBuffer(8);
const view = new Uint8Array(binaryData);
view[0] = 0x48; // 'H'
view[1] = 0x65; // 'e'
const { data } = await client.post('/binary', binaryData, {
headers: {
'Content-Type': 'application/octet-stream'
}
});Adding Xior Plugins
Since HttpClient is built on xior, you can add any xior plugin to enhance functionality:
Instance-Level Plugins
Add plugins to all requests:
import { HttpClient } from '@reggieofarrell/http-client';
import cachePlugin from 'xior/plugins/cache';
import throttlePlugin from 'xior/plugins/throttle';
const client = new HttpClient({
baseURL: 'https://api.example.com'
});
// Add caching to all requests
client.client.plugins.use(cachePlugin({
cacheTime: 5 * 60 * 1000, // 5 minutes
cacheItems: 100
}));
// Add throttling to all requests
client.client.plugins.use(throttlePlugin({
threshold: 1000, // 1 second between requests
enableThrottle: (config) => config.method === 'GET'
}));
// Now all requests are cached and throttled
const { data } = await client.get('/users');Per-Request Plugins
For requests that need specific plugins, create a temporary client:
import { HttpClient } from '@reggieofarrell/http-client';
import cachePlugin from 'xior/plugins/cache';
import progressPlugin from 'xior/plugins/progress';
import xior from 'xior';
const client = new HttpClient({
baseURL: 'https://api.example.com'
});
// For a specific request that needs caching
const tempClient = xior.create({
baseURL: 'https://api.example.com'
});
tempClient.plugins.use(cachePlugin({
cacheTime: 5 * 60 * 1000
}));
const { data } = await tempClient.get('/expensive-endpoint');Enhanced Client Pattern
Create a custom client with specific plugins:
import { HttpClient } from '@reggieofarrell/http-client';
import cachePlugin from 'xior/plugins/cache';
import progressPlugin from 'xior/plugins/progress';
class EnhancedHttpClient extends HttpClient {
constructor(config) {
super(config);
// Add plugins to all requests
this.client.plugins.use(cachePlugin({
cacheTime: 10 * 60 * 1000,
cacheItems: 200
}));
}
// Method for requests that need progress tracking
async uploadWithProgress(url: string, data: any, config = {}) {
const tempClient = xior.create({
...this.client.defaults,
baseURL: this.baseURL
});
tempClient.plugins.use(progressPlugin({
progressDuration: 5000
}));
const response = await tempClient.post(url, data, config);
return { request: response, data: response.data };
}
}
// Usage
const client = new EnhancedHttpClient({
baseURL: 'https://api.example.com'
});
// Regular requests (cached)
const { data } = await client.get('/users');
// Upload with progress
const { data } = await client.uploadWithProgress('/upload', formData, {
onUploadProgress: (progress) => {
console.log(`Upload: ${progress.progress}%`);
}
});Available Xior Plugins
- Cache:
xior/plugins/cache- Response caching - Throttle:
xior/plugins/throttle- Request throttling - Dedupe:
xior/plugins/dedupe- Request deduplication - Progress:
xior/plugins/progress- Upload/download progress - Mock:
xior/plugins/mock- Request mocking for tests - Error Cache:
xior/plugins/error-cache- Error response caching
For more details, see the xior plugins documentation.
Accessing the underlying client
Requests return request and data with request being the underlying xior response in case you need to dig into this.
const { request, data } = await client.get('/endpoint');
console.log(request.status); // HTTP status code
console.log(request.headers); // Response headers
console.log(data); // Response dataDirect access to the underlying xior instance
You can also access the underlying xior instance directly:
// Access the underlying xior instance
const xiorInstance = client.client;
// Use xior methods directly if needed
const response = await xiorInstance.get('/custom-endpoint');Type responses
// pass a generic if you're using typescript to get a typed response
const { data } = await client.get<SomeResponseType>('/endpoint')Middleware Hooks
The HttpClient provides middleware-style hooks that allow you to modify requests and responses. These hooks are designed for direct mutation of parameters, making them more efficient and easier to use.
beforeRequest Hook
The beforeRequest hook is called before each request is sent. You can modify the request data and configuration directly:
import { HttpClient } from '@reggieofarrell/http-client';
class CustomClient extends HttpClient {
protected async beforeRequest(
requestType: RequestType,
url: string,
data: any,
config: XiorRequestConfig
): Promise<void> {
// Add authentication token
if (this.authToken) {
config.headers = {
...config.headers,
'Authorization': `Bearer ${this.authToken}`
};
}
// Add request timestamp
if (data && typeof data === 'object') {
data.requestTime = Date.now();
}
// Log request details
console.log(`Making ${requestType} request to ${url}`);
}
}
const client = new CustomClient({
baseURL: 'https://api.example.com'
});afterResponse Hook
The afterResponse hook is called after receiving a successful response (2xx status codes). You can modify the response data directly:
import { HttpClient } from '@reggieofarrell/http-client';
class CustomClient extends HttpClient {
protected async afterResponse(
requestType: RequestType,
url: string,
response: XiorResponse,
data: any
): Promise<void> {
// Add processing timestamp
data.processedAt = Date.now();
// Transform response data
if (data.items && Array.isArray(data.items)) {
data.itemCount = data.items.length;
}
// Log response details
console.log(`Received ${requestType} response from ${url}: ${response.status}`);
}
}Combined Middleware Workflow
You can use both hooks together to create a complete request/response processing pipeline:
import { HttpClient } from '@reggieofarrell/http-client';
class ApiClient extends HttpClient {
private requestId = 0;
protected async beforeRequest(
requestType: RequestType,
url: string,
data: any,
config: XiorRequestConfig
): Promise<void> {
// Generate unique request ID
const id = ++this.requestId;
// Add request ID to headers
config.headers = {
...config.headers,
'X-Request-ID': id.toString()
};
// Add request ID to data if it's an object
if (data && typeof data === 'object') {
data.requestId = id;
}
console.log(`[${id}] Starting ${requestType} ${url}`);
}
protected async afterResponse(
requestType: RequestType,
url: string,
response: XiorResponse,
data: any
): Promise<void> {
// Add response metadata
data.responseTime = Date.now();
data.requestId = response.headers['x-request-id'];
console.log(`[${data.requestId}] Completed ${requestType} ${url} - ${response.status}`);
}
}
// Usage
const client = new ApiClient({
baseURL: 'https://api.example.com'
});
// All requests will have request IDs and logging
const { data } = await client.post('/users', { name: 'John' });
// Console output:
// [1] Starting POST /users
// [1] Completed POST /users - 201Error Handling
The afterResponse hook is only called for successful responses (2xx status codes). Error responses are handled by the errorHandler method, which has been refactored to provide better flexibility for child classes.
Basic Error Handling Override
class CustomClient extends HttpClient {
protected async afterResponse(
requestType: RequestType,
url: string,
response: XiorResponse,
data: any
): Promise<void> {
// This is only called for successful responses
console.log('Request succeeded:', response.status);
}
protected errorHandler(error: any, reqType: RequestType, url: string) {
// This is called for error responses
console.log('Request failed:', error.message);
super.errorHandler(error, reqType, url);
}
}Advanced Error Handling with processError()
For more control over error handling, you can use the processError() method to get the processed error object before throwing it:
class CustomClient extends HttpClient {
protected errorHandler(error: any, reqType: RequestType, url: string) {
// Get the processed error object
const processedError = this.processError(error, reqType, url);
// Add custom logic (logging, metrics, etc.)
this.logErrorMetrics(processedError);
// Option 1: Throw the processed error as-is
throw processedError;
// Option 2: Modify the error before throwing
// processedError.message = `[Custom] ${processedError.message}`;
// throw processedError;
// Option 3: Add custom properties
// (processedError as any).customProperty = 'some value';
// throw processedError;
}
}Error Processing Method
The processError() method handles all the core error processing logic and returns a fully constructed error object. This method:
- Builds request metadata for all error types
- Handles HTTP response errors (status codes outside 2xx)
- Handles network, timeout, and serialization errors
- Applies retry configuration logic
- Returns the appropriate error type (
HttpError,NetworkError,TimeoutError, orSerializationError)
This separation allows child classes to:
- Use the default error handling:
throw this.processError(error, reqType, url); - Customize errors before throwing: Modify the processed error object
- Add side effects: Logging, metrics, custom error tracking
- Completely override: Build their own error handling logic
Extending the HttpClient
You can extend the HttpClient class to add custom functionality:
import { HttpClient } from '@reggieofarrell/http-client';
class MyApiClient extends HttpClient {
constructor() {
super({
baseURL: 'https://api.example.com',
retryConfig: {
retries: 3,
delayFactor: 1000,
backoff: 'exponential'
}
});
}
async getUsers() {
const { data } = await this.get('/users');
return data;
}
async createUser(userData: any) {
const { data } = await this.post('/users', userData);
return data;
}
}
// Usage
const apiClient = new MyApiClient();
const users = await apiClient.getUsers();Advanced Error Handling Examples
Here are comprehensive examples of different error handling patterns:
1. Custom Error Logging and Metrics
class AnalyticsClient extends HttpClient {
private errorMetrics: any[] = [];
protected errorHandler(error: any, reqType: RequestType, url: string) {
const processedError = this.processError(error, reqType, url);
// Log to analytics service
this.errorMetrics.push({
timestamp: new Date().toISOString(),
method: reqType,
url,
errorType: processedError.constructor.name,
message: processedError.message,
isRetriable: processedError.isRetriable
});
// Send to external monitoring
this.sendToMonitoring(processedError);
throw processedError;
}
private sendToMonitoring(error: any) {
// Send to your monitoring service (DataDog, New Relic, etc.)
console.log('Sending error to monitoring:', error.message);
}
}2. Custom Error Messages and Context
class UserFriendlyClient extends HttpClient {
protected errorHandler(error: any, reqType: RequestType, url: string) {
const processedError = this.processError(error, reqType, url);
// Add user-friendly context
if (processedError instanceof HttpError) {
switch (processedError.status) {
case 401:
processedError.message = 'Please log in to continue';
break;
case 403:
processedError.message = 'You do not have permission to access this resource';
break;
case 404:
processedError.message = 'The requested resource was not found';
break;
case 429:
processedError.message = 'Too many requests. Please try again later';
break;
default:
processedError.message = `Request failed: ${processedError.message}`;
}
}
throw processedError;
}
}3. Error Recovery and Fallback
class ResilientClient extends HttpClient {
protected errorHandler(error: any, reqType: RequestType, url: string) {
const processedError = this.processError(error, reqType, url);
// Attempt recovery for specific errors
if (processedError instanceof NetworkError) {
// Try fallback endpoint
if (url.startsWith('/api/')) {
const fallbackUrl = url.replace('/api/', '/api-fallback/');
console.log(`Attempting fallback for ${url} -> ${fallbackUrl}`);
// You could implement retry logic here
}
}
throw processedError;
}
}4. Error Classification and Routing
class SmartClient extends HttpClient {
protected errorHandler(error: any, reqType: RequestType, url: string) {
const processedError = this.processError(error, reqType, url);
// Route different error types to different handlers
if (processedError instanceof HttpError) {
this.handleHttpError(processedError, reqType, url);
} else if (processedError instanceof NetworkError) {
this.handleNetworkError(processedError, reqType, url);
} else if (processedError instanceof TimeoutError) {
this.handleTimeoutError(processedError, reqType, url);
}
throw processedError;
}
private handleHttpError(error: HttpError, reqType: RequestType, url: string) {
// Specific HTTP error handling
if (error.status === 429) {
// Implement exponential backoff
console.log('Rate limited, implementing backoff strategy');
}
}
private handleNetworkError(error: NetworkError, reqType: RequestType, url: string) {
// Network error specific handling
console.log('Network issue detected, checking connectivity');
}
private handleTimeoutError(error: TimeoutError, reqType: RequestType, url: string) {
// Timeout specific handling
console.log('Request timed out, consider increasing timeout');
}
}5. Complete Custom Error Handling
class CustomErrorClient extends HttpClient {
protected errorHandler(error: any, reqType: RequestType, url: string) {
// Completely custom error handling without using processError
if (error.response) {
// Custom HTTP error handling
const customError = new Error(`Custom HTTP Error: ${error.response.status}`);
(customError as any).status = error.response.status;
(customError as any).data = error.response.data;
throw customError;
} else {
// Custom network error handling
const customError = new Error(`Custom Network Error: ${error.message}`);
(customError as any).originalError = error;
throw customError;
}
}
}Error Handling
The HttpClient provides comprehensive error handling with stable error types:
import { HttpClient, NetworkError, TimeoutError, HttpError, SerializationError, HttpErrorCategory } from '@reggieofarrell/http-client';
try {
const { data } = await client.get('/endpoint');
console.log(data);
} catch (error) {
if (error instanceof HttpError) {
console.log('HTTP Error:', error.status, error.category, error.response);
console.log('Retriable:', error.isRetriable);
// Handle specific error categories
switch (error.category) {
case HttpErrorCategory.AUTHENTICATION:
console.log('Authentication failed');
break;
case HttpErrorCategory.RATE_LIMIT:
console.log('Rate limited, retry after delay');
break;
case HttpErrorCategory.SERVER_ERROR:
console.log('Server error, may be retriable');
break;
}
} else if (error instanceof NetworkError) {
console.log('Network Error:', error.metadata.error.type, error.metadata.error.message);
console.log('Retriable:', error.isRetriable);
} else if (error instanceof TimeoutError) {
console.log('Timeout Error:', error.metadata.error.message);
console.log('Retriable:', error.isRetriable);
} else if (error instanceof SerializationError) {
console.log('Serialization Error:', error.message);
console.log('Retriable:', error.isRetriable);
}
}Error Types
The HTTP client provides four stable error types:
HttpError- HTTP 4xx/5xx responses- Properties:
status,category,statusText,response,isRetriable - Categories:
AUTHENTICATION,NOT_FOUND,RATE_LIMIT,VALIDATION,CLIENT_ERROR,SERVER_ERROR
- Properties:
NetworkError- Network connectivity issues- Properties:
code,isRetriable,metadata(includes error details) - Always retriable by default
- Properties:
TimeoutError- Request timeout- Properties:
code,isRetriable,metadata(includes timeout details) - Always retriable by default
- Properties:
SerializationError- Request/response serialization failures- Properties:
code,isRetriable,metadata - Not retriable by default
- Properties:
Error Metadata
All errors include comprehensive diagnostic metadata:
interface ErrorMetadata {
request: {
method: string;
url: string;
baseURL: string;
headers: Record<string, any>;
timeout?: number;
timestamp: string; // ISO format
};
retryCount?: number;
clientName: string;
}Retry Logic
The retry system automatically uses the isRetriable property from error instances:
const client = new HttpClient({
baseURL: 'https://api.example.com',
retryConfig: {
retries: 3,
// Custom retry logic can override isRetriable
enableRetry: (config, error) => {
// The error parameter is a XiorError during retry evaluation
// but will be converted to HttpClientError types when thrown
// Check if it's one of our new error types
if ((error as any).isRetriable !== undefined) {
return (error as any).isRetriable;
}
// Fallback to standard HTTP retry logic
if (!error.response) return true; // Network errors
return error.response.status >= 500; // 5xx errors
}
}
});Retry Logic and Error Types
Important: The enableRetry function receives a XiorError during retry evaluation, but the final thrown errors are converted to our stable error types (HttpError, NetworkError, etc.).
import { HttpClient, classifyErrorForRetry } from '@reggieofarrell/http-client';
const client = new HttpClient({
baseURL: 'https://api.example.com',
retryConfig: {
retries: 3,
enableRetry: (config, error) => {
// Use our error classification helper for consistent logic
const classification = classifyErrorForRetry(error);
return classification.isRetriable;
}
}
});
// When an error is thrown, it will be one of our stable error types
try {
const { data } = await client.get('/endpoint');
} catch (error) {
if (error instanceof HttpError) {
// This is now an HttpError with isRetriable property
console.log('Retriable:', error.isRetriable);
}
}Advanced Retry Logic with Error Classification
For more sophisticated retry logic, you can use the classifyErrorForRetry helper function to access our error type logic during retry evaluation:
import { HttpClient, classifyErrorForRetry, HttpErrorCategory } from '@reggieofarrell/http-client';
const client = new HttpClient({
baseURL: 'https://api.example.com',
retryConfig: {
retries: 3,
enableRetry: (config, error) => {
// Get structured error information
const classification = classifyErrorForRetry(error);
// Work with our error types' logic
if (classification.type === 'http') {
// Handle HTTP errors with full context
if (classification.category === HttpErrorCategory.RATE_LIMIT) {
return true; // Always retry rate limits
}
if (classification.category === HttpErrorCategory.AUTHENTICATION) {
return false; // Never retry auth errors
}
if (classification.status === 429) {
return true; // Custom logic for specific status codes
}
// Use the pre-calculated retriability
return classification.isRetriable;
}
if (classification.type === 'timeout') {
return true; // Always retry timeouts
}
if (classification.type === 'network') {
return true; // Always retry network errors
}
if (classification.type === 'serialization') {
return false; // Never retry serialization errors
}
// Fallback to the classification's retriability
return classification.isRetriable;
}
}
});Error Classification
The classifyErrorForRetry function returns an ErrorClassification object:
interface ErrorClassification {
type: 'network' | 'timeout' | 'http' | 'serialization' | 'unknown';
isRetriable: boolean;
status?: number; // For HTTP errors
category?: HttpErrorCategory; // For HTTP errors
}This gives you access to:
- Error type detection - Know if it's a network, timeout, HTTP, or serialization error
- Pre-calculated retriability - Use our smart defaults with
classification.isRetriable - HTTP context - Access status codes and error categories for HTTP errors
- Type safety - Work with familiar
HttpErrorCategoryenum values
Per-Request Error Classification
You can also use error classification for per-request retry logic:
await client.get('/endpoint', {
retryConfig: {
enableRetry: (config, error) => {
const classification = classifyErrorForRetry(error);
// Custom per-request logic
if (classification.type === 'http' && classification.status === 404) {
return false; // Don't retry 404s for this specific endpoint
}
return classification.isRetriable;
}
}
});Custom Error Message Extraction
Different APIs structure their error responses differently. The HttpClient allows you to customize how error messages are extracted from HTTP error responses.
Instance-Level Configuration
// String path with dot notation for nested properties
const client = new HttpClient({
baseURL: 'https://api.example.com',
errorMessagePath: 'data.error.detail', // Extract from data.error.detail
});
// Function-based extraction for complex logic
const client = new HttpClient({
baseURL: 'https://api.example.com',
errorMessagePath: (response) => {
// Handle multiple possible error formats
if (response.data?.error?.message) {
return response.data.error.message;
}
if (response.data?.errors?.length > 0) {
return response.data.errors.map((e: any) => e.message).join('; ');
}
if (response.data?.message) {
return response.data.message;
}
return undefined; // Will fall back to statusText
},
});Per-Request Override
// Override error message path for specific requests
const response = await client.get('/endpoint', {
errorMessagePath: 'data.errors.0.message' // Extract first error message
});
// Use function for per-request custom logic
const response = await client.post('/endpoint', data, {
errorMessagePath: (response) => {
return response.data?.validation_errors?.[0]?.message;
}
});Common API Patterns
// GitHub API style
const githubClient = new HttpClient({
baseURL: 'https://api.github.com',
errorMessagePath: 'data.message'
});
// Stripe API style
const stripeClient = new HttpClient({
baseURL: 'https://api.stripe.com',
errorMessagePath: 'data.error.message'
});
// Custom API with nested errors
const customClient = new HttpClient({
baseURL: 'https://api.custom.com',
errorMessagePath: 'data.errors.0.detail'
});
// Complex API with multiple error formats
const complexClient = new HttpClient({
baseURL: 'https://api.complex.com',
errorMessagePath: (response) => {
// Try different paths based on response structure
if (response.data?.error?.message) {
return response.data.error.message;
}
if (response.data?.errors?.length > 0) {
return response.data.errors[0].message;
}
if (response.data?.message) {
return response.data.message;
}
return undefined; // Falls back to statusText
}
});Fallback Behavior
When the configured path doesn't contain a message or the function returns undefined, the client falls back to the HTTP status text:
// If errorMessagePath doesn't find a message, falls back to statusText
try {
await client.get('/endpoint');
} catch (error) {
if (error instanceof HttpError) {
console.log(error.message); // Either extracted message or statusText
}
}Debugging
Enable debug logging to see request and response details:
const client = new HttpClient({
baseURL: 'https://api.example.com',
debug: true,
debugLevel: 'verbose' // or 'normal'
});Breaking Changes
v2.0.0 - Stable Error Types
This version introduces stable error types and removes the legacy ApiResponseError:
Removed:
ApiResponseErrorclass
Added:
HttpClientErrorbase classNetworkErrorfor network connectivity issuesTimeoutErrorfor request timeoutsHttpErrorfor HTTP 4xx/5xx responsesSerializationErrorfor data serialization failuresHttpErrorCategoryenum for error categorization
Migration Guide:
// Before (v1.x)
try {
const { data } = await client.get('/endpoint');
} catch (error) {
if (error instanceof ApiResponseError) {
console.log('Status:', error.status);
console.log('Response:', error.response);
}
}
// After (v2.x)
import { HttpError, NetworkError, TimeoutError, SerializationError } from '@reggieofarrell/http-client';
try {
const { data } = await client.get('/endpoint');
} catch (error) {
if (error instanceof HttpError) {
console.log('Status:', error.status);
console.log('Category:', error.category);
console.log('Response:', error.response);
console.log('Retriable:', error.isRetriable);
} else if (error instanceof NetworkError) {
console.log('Network issue:', error.metadata.error.type);
}
}License
0BSD
