@orchard9ai/error-handling
Version:
Federated error handling package with go-core-http-toolkit format support and logging integration
464 lines (406 loc) • 12.6 kB
text/typescript
import { ErrorHandler } from '../core/ErrorHandler.js';
import { parseHttpError, parseUnknownError } from '../utils/errorParsers.js';
import type { ApiError, ErrorContext, DisplayError, ErrorHandlerConfig } from '../types/index.js';
/**
* HTTP client configuration options
*/
export interface HttpClientConfig {
/** Base URL for all requests */
baseUrl?: string;
/** Default headers to include with requests */
defaultHeaders?: Record<string, string>;
/** Timeout in milliseconds */
timeout?: number;
/** Whether to enable retry on failure (disabled by default) */
enableRetry?: boolean;
/** Number of retry attempts if enabled */
retryAttempts?: number;
/** Retry delay in milliseconds */
retryDelay?: number;
/** Custom error handler callback */
onError?: (error: HttpClientError, displayError: DisplayError) => void;
/** Whether to show toast notifications for errors */
showToasts?: boolean;
/** Error handler configuration */
errorHandlerConfig?: Partial<ErrorHandlerConfig>;
}
/**
* HTTP response interface matching your API format
*/
export interface HttpErrorResponse {
error: string;
message: string;
code: string;
correlation_id: string;
details?: {
field?: string;
resource?: string;
value?: string;
[key: string]: any;
};
timestamp: string;
}
/**
* Enhanced request configuration
*/
export interface HttpRequestConfig extends RequestInit {
/** Request timeout override */
timeout?: number;
/** Skip retry for this request */
skipRetry?: boolean;
/** Custom error context */
errorContext?: Partial<ErrorContext>;
}
/**
* HTTP client response wrapper
*/
export interface HttpResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Headers;
url: string;
}
/**
* HTTP client error class
*/
export class HttpClientError extends Error {
public readonly status: number;
public readonly statusText: string;
public readonly response: Response | undefined;
public readonly displayError: DisplayError;
public readonly apiError: ApiError;
constructor(
message: string,
status: number,
statusText: string,
displayError: DisplayError,
apiError: ApiError,
response?: Response
) {
super(message);
this.name = 'HttpClientError';
this.status = status;
this.statusText = statusText;
this.response = response;
this.displayError = displayError;
this.apiError = apiError;
}
}
/**
* Comprehensive HTTP client with standardized error handling
*/
export class HttpClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
private timeout: number;
private enableRetry: boolean;
private retryAttempts: number;
private retryDelay: number;
private errorHandler: ErrorHandler;
private onError: ((error: HttpClientError, displayError: DisplayError) => void) | undefined;
private showToasts: boolean;
constructor(config: HttpClientConfig = {}) {
this.baseUrl = config.baseUrl || '';
this.defaultHeaders = {
'Content-Type': 'application/json',
...config.defaultHeaders
};
this.timeout = config.timeout || 30000;
this.enableRetry = config.enableRetry || false; // Disabled by default
this.retryAttempts = config.retryAttempts || 3;
this.retryDelay = config.retryDelay || 1000;
this.onError = config.onError;
this.showToasts = config.showToasts || true;
this.errorHandler = new ErrorHandler({
loggerName: 'http-client',
...config.errorHandlerConfig
});
}
/**
* Make HTTP request
*/
async request<T = any>(
endpoint: string,
config: HttpRequestConfig = {}
): Promise<HttpResponse<T>> {
const { timeout = this.timeout, skipRetry = false, errorContext, ...fetchConfig } = config;
const url = this.buildUrl(endpoint);
const requestConfig: RequestInit = {
...fetchConfig,
headers: {
...this.defaultHeaders,
...fetchConfig.headers
}
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await this.executeRequest(url, {
...requestConfig,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const displayError = await this.handleHttpError(response, {
component: 'http-client',
action: 'request',
...errorContext
});
throw new HttpClientError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
displayError,
displayError as any, // Will be properly typed
response
);
}
const data = await this.parseResponse<T>(response);
return {
data,
status: response.status,
statusText: response.statusText,
headers: response.headers,
url: response.url
};
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof HttpClientError) {
this.handleError(error);
throw error;
}
// Handle network errors
const apiError = parseUnknownError(error);
const displayError = this.errorHandler.handleApiError(apiError, {
component: 'http-client',
action: 'network-request',
...errorContext
});
const httpError = new HttpClientError(
(error as Error).message,
0,
'Network Error',
displayError,
displayError as any
);
this.handleError(httpError);
throw httpError;
}
}
/**
* GET request
*/
async get<T = any>(endpoint: string, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
return this.request<T>(endpoint, { ...config, method: 'GET' });
}
/**
* POST request
*/
async post<T = any>(endpoint: string, data?: any, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
return this.request<T>(endpoint, {
...config,
method: 'POST',
body: data ? JSON.stringify(data) : null
});
}
/**
* PUT request
*/
async put<T = any>(endpoint: string, data?: any, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
return this.request<T>(endpoint, {
...config,
method: 'PUT',
body: data ? JSON.stringify(data) : null
});
}
/**
* PATCH request
*/
async patch<T = any>(endpoint: string, data?: any, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
return this.request<T>(endpoint, {
...config,
method: 'PATCH',
body: data ? JSON.stringify(data) : null
});
}
/**
* DELETE request
*/
async delete<T = any>(endpoint: string, config?: HttpRequestConfig): Promise<HttpResponse<T>> {
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
}
/**
* Execute request with optional retry logic
*/
private async executeRequest(url: string, config: RequestInit): Promise<Response> {
let lastError: Error | null = null;
const maxAttempts = this.enableRetry ? this.retryAttempts : 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await fetch(url, config);
// Don't retry on client errors (4xx) except 408, 429
if (response.status >= 400 && response.status < 500) {
if (response.status !== 408 && response.status !== 429) {
return response;
}
}
// Return response (successful or retryable error)
if (response.ok || attempt === maxAttempts) {
return response;
}
// Wait before retry
if (attempt < maxAttempts) {
await this.delay(this.retryDelay * attempt);
}
} catch (error) {
lastError = error as Error;
// Don't retry on abort
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
// Wait before retry
if (attempt < maxAttempts) {
await this.delay(this.retryDelay * attempt);
}
}
}
throw lastError || new Error('Request failed after retries');
}
/**
* Handle HTTP error responses
*/
private async handleHttpError(response: Response, context: ErrorContext = {}): Promise<DisplayError> {
let responseText: string;
try {
responseText = await response.text();
} catch {
responseText = '';
}
// Try to parse as your API error format first
let apiError: ApiError;
try {
const errorData: HttpErrorResponse = JSON.parse(responseText);
const details: Record<string, string> = {};
if (errorData.details) {
Object.entries(errorData.details).forEach(([key, value]) => {
if (typeof value === 'string') {
details[key] = value;
} else {
details[key] = String(value);
}
});
}
apiError = {
error: errorData.error || errorData.message,
code: errorData.code,
details: Object.keys(details).length > 0 ? details : undefined,
trace_id: errorData.correlation_id
};
} catch {
// Fallback to standard HTTP error parsing
apiError = parseHttpError(response, responseText);
}
const enrichedContext: ErrorContext = {
...context,
action: 'http_request',
metadata: {
url: response.url,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries())
}
};
return this.errorHandler.handleApiError(apiError, enrichedContext);
}
/**
* Parse response data
*/
private async parseResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json();
}
if (contentType?.includes('text/')) {
return response.text() as unknown as T;
}
return response.blob() as unknown as T;
}
/**
* Handle errors (logging, callbacks, toasts)
*/
private handleError(error: HttpClientError): void {
// Call custom error handler
if (this.onError) {
this.onError(error, error.displayError);
}
// Show toast if enabled (implementation would depend on toast system)
if (this.showToasts) {
this.showErrorToast(error.displayError);
}
}
/**
* Show error toast
*/
private showErrorToast(displayError: DisplayError): void {
// Integration point for toast notifications
// Apps should configure this via onError callback
}
/**
* Build full URL
*/
private buildUrl(endpoint: string): string {
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
return endpoint;
}
const base = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `${base}${path}`;
}
/**
* Delay helper for retry logic
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Update configuration
*/
configure(config: Partial<HttpClientConfig>): void {
if (config.baseUrl !== undefined) this.baseUrl = config.baseUrl;
if (config.defaultHeaders) {
this.defaultHeaders = { ...this.defaultHeaders, ...config.defaultHeaders };
}
if (config.timeout !== undefined) this.timeout = config.timeout;
if (config.enableRetry !== undefined) this.enableRetry = config.enableRetry;
if (config.retryAttempts !== undefined) this.retryAttempts = config.retryAttempts;
if (config.retryDelay !== undefined) this.retryDelay = config.retryDelay;
if (config.onError !== undefined) this.onError = config.onError;
if (config.showToasts !== undefined) this.showToasts = config.showToasts;
}
}
/**
* Create a configured HTTP client instance
*/
export function createHttpClient(config?: HttpClientConfig): HttpClient {
return new HttpClient(config);
}
/**
* Global HTTP client instance
*/
let globalHttpClient: HttpClient | null = null;
/**
* Get or create global HTTP client
*/
export function getGlobalHttpClient(): HttpClient {
if (!globalHttpClient) {
globalHttpClient = new HttpClient();
}
return globalHttpClient;
}
/**
* Configure global HTTP client
*/
export function configureGlobalHttpClient(config: HttpClientConfig): void {
globalHttpClient = new HttpClient(config);
}