UNPKG

@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
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); }