402 lines
10 KiB
TypeScript
402 lines
10 KiB
TypeScript
/**
|
|
* Error handling utilities for the adapter system
|
|
*
|
|
* This module provides custom error classes and utility functions
|
|
* for consistent error handling across all adapters.
|
|
*
|
|
* @module adapters/errors
|
|
*/
|
|
|
|
import type { AdapterError } from './types'
|
|
|
|
/**
|
|
* Custom error class for adapter-specific errors
|
|
* Extends the native Error class with additional properties
|
|
*/
|
|
export class ApiError extends Error implements AdapterError {
|
|
name = 'AdapterError' as const
|
|
code: string
|
|
status: number
|
|
details?: any
|
|
|
|
/**
|
|
* Creates a new ApiError instance
|
|
*
|
|
* @param code - Error code (e.g., 'NOT_FOUND', 'UNAUTHORIZED')
|
|
* @param status - HTTP status code
|
|
* @param message - Human-readable error message
|
|
* @param details - Additional error details from the API
|
|
*/
|
|
constructor(code: string, status: number, message: string, details?: any) {
|
|
super(message)
|
|
this.code = code
|
|
this.status = status
|
|
this.details = details
|
|
|
|
// Maintains proper stack trace for where error was thrown
|
|
if (Error.captureStackTrace) {
|
|
Error.captureStackTrace(this, ApiError)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts the error to a plain object
|
|
* Useful for serialization and logging
|
|
*/
|
|
toJSON(): AdapterError {
|
|
return {
|
|
name: this.name,
|
|
code: this.code,
|
|
status: this.status,
|
|
message: this.message,
|
|
details: this.details
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates an ApiError from a plain object
|
|
* Useful for deserializing errors from API responses
|
|
*/
|
|
static fromJSON(json: any): ApiError {
|
|
return new ApiError(
|
|
json.code || 'UNKNOWN_ERROR',
|
|
json.status || 0,
|
|
json.message || 'An unknown error occurred',
|
|
json.details
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for network-related failures
|
|
*/
|
|
export class NetworkError extends ApiError {
|
|
constructor(message = 'Network request failed', details?: any) {
|
|
super('NETWORK_ERROR', 0, message, details)
|
|
this.name = 'NetworkError' as any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for request timeout
|
|
*/
|
|
export class TimeoutError extends ApiError {
|
|
constructor(timeout: number, details?: any) {
|
|
super('TIMEOUT', 0, `Request timed out after ${timeout}ms`, details)
|
|
this.name = 'TimeoutError' as any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for request cancellation
|
|
*/
|
|
export class CancelledError extends ApiError {
|
|
constructor(details?: any) {
|
|
super('CANCELLED', 0, 'Request was cancelled', details)
|
|
this.name = 'CancelledError' as any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for validation failures
|
|
*/
|
|
export class ValidationError extends ApiError {
|
|
constructor(message: string, details?: any) {
|
|
super('VALIDATION_ERROR', 422, message, details)
|
|
this.name = 'ValidationError' as any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for authentication failures
|
|
*/
|
|
export class AuthenticationError extends ApiError {
|
|
constructor(message = 'Authentication required', details?: any) {
|
|
super('UNAUTHORIZED', 401, message, details)
|
|
this.name = 'AuthenticationError' as any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for authorization failures
|
|
*/
|
|
export class AuthorizationError extends ApiError {
|
|
constructor(message = 'Access denied', details?: any) {
|
|
super('FORBIDDEN', 403, message, details)
|
|
this.name = 'AuthorizationError' as any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for resource not found
|
|
*/
|
|
export class NotFoundError extends ApiError {
|
|
constructor(resource?: string, details?: any) {
|
|
const message = resource ? `${resource} not found` : 'Resource not found'
|
|
super('NOT_FOUND', 404, message, details)
|
|
this.name = 'NotFoundError' as any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for conflict errors (e.g., duplicate resources)
|
|
*/
|
|
export class ConflictError extends ApiError {
|
|
constructor(message = 'Resource conflict', details?: any) {
|
|
super('CONFLICT', 409, message, details)
|
|
this.name = 'ConflictError' as any
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error class for rate limiting
|
|
*/
|
|
export class RateLimitError extends ApiError {
|
|
retryAfter?: number
|
|
|
|
constructor(retryAfter?: number, details?: any) {
|
|
const message = retryAfter
|
|
? `Rate limit exceeded. Retry after ${retryAfter} seconds`
|
|
: 'Rate limit exceeded'
|
|
|
|
super('RATE_LIMITED', 429, message, details)
|
|
this.name = 'RateLimitError' as any
|
|
this.retryAfter = retryAfter
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Maps HTTP status codes to specific error classes
|
|
*
|
|
* @param status - HTTP status code
|
|
* @param message - Error message
|
|
* @param details - Additional error details
|
|
* @returns Appropriate error instance based on status code
|
|
*/
|
|
export function createErrorFromStatus(status: number, message?: string, details?: any): ApiError {
|
|
switch (status) {
|
|
case 400:
|
|
return new ApiError('BAD_REQUEST', status, message || 'Bad request', details)
|
|
|
|
case 401:
|
|
return new AuthenticationError(message, details)
|
|
|
|
case 403:
|
|
return new AuthorizationError(message, details)
|
|
|
|
case 404:
|
|
// Pass the message to NotFoundError if provided
|
|
return message
|
|
? new ApiError('NOT_FOUND', 404, message, details)
|
|
: new NotFoundError(undefined, details)
|
|
|
|
case 409:
|
|
return new ConflictError(message, details)
|
|
|
|
case 422:
|
|
return new ValidationError(message || 'Validation failed', details)
|
|
|
|
case 429:
|
|
// Try to extract retry-after header from details
|
|
const retryAfter = details?.headers?.['retry-after']
|
|
return new RateLimitError(retryAfter, details)
|
|
|
|
case 500:
|
|
return new ApiError('SERVER_ERROR', status, message || 'Internal server error', details)
|
|
|
|
case 502:
|
|
return new ApiError('BAD_GATEWAY', status, message || 'Bad gateway', details)
|
|
|
|
case 503:
|
|
return new ApiError('SERVICE_UNAVAILABLE', status, message || 'Service unavailable', details)
|
|
|
|
case 504:
|
|
return new ApiError('GATEWAY_TIMEOUT', status, message || 'Gateway timeout', details)
|
|
|
|
default:
|
|
// For any other status codes
|
|
if (status >= 400 && status < 500) {
|
|
return new ApiError('CLIENT_ERROR', status, message || 'Client error', details)
|
|
} else if (status >= 500) {
|
|
return new ApiError('SERVER_ERROR', status, message || 'Server error', details)
|
|
}
|
|
|
|
return new ApiError('UNKNOWN_ERROR', status, message || 'Unknown error', details)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determines if an error is retryable based on its type and code
|
|
*
|
|
* @param error - The error to check
|
|
* @returns True if the error is retryable
|
|
*/
|
|
export function isRetryableError(error: any): boolean {
|
|
// Network-like conditions are retryable
|
|
// Handle both class instances and normalized plain objects
|
|
if (
|
|
error instanceof NetworkError ||
|
|
error instanceof TimeoutError ||
|
|
error?.name === 'NetworkError' ||
|
|
error?.code === 'NETWORK_ERROR' ||
|
|
// Some environments normalize to status 0 without specific codes
|
|
error?.status === 0
|
|
) {
|
|
return true
|
|
}
|
|
|
|
// Rate limit errors are retryable after delay
|
|
if (error instanceof RateLimitError) {
|
|
return true
|
|
}
|
|
|
|
// Check by error code (handles both ApiError instances and plain objects)
|
|
// Note: NetworkError sets name to 'NetworkError' but still has AdapterError structure
|
|
if (
|
|
error instanceof ApiError ||
|
|
error?.name === 'AdapterError' ||
|
|
error?.name === 'NetworkError'
|
|
) {
|
|
const retryableCodes = [
|
|
'NETWORK_ERROR',
|
|
'TIMEOUT',
|
|
'GATEWAY_TIMEOUT',
|
|
'SERVICE_UNAVAILABLE',
|
|
'BAD_GATEWAY',
|
|
'SERVER_ERROR'
|
|
]
|
|
|
|
if (retryableCodes.includes(error.code)) {
|
|
return true
|
|
}
|
|
|
|
// Server errors (5xx) are generally retryable
|
|
if (error.status >= 500 && error.status < 600) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check for specific error properties
|
|
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
|
return true
|
|
}
|
|
|
|
// Client errors (4xx) are not retryable except rate limits
|
|
if (error.status >= 400 && error.status < 500) {
|
|
return error.status === 429 // Only rate limit is retryable
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Normalizes various error types into a consistent AdapterError structure
|
|
*
|
|
* @param error - Any error type
|
|
* @returns Normalized AdapterError
|
|
*/
|
|
export function normalizeError(error: any): AdapterError {
|
|
// Already an AdapterError
|
|
if (error?.name === 'AdapterError') {
|
|
return error
|
|
}
|
|
|
|
// ApiError instance
|
|
if (error instanceof ApiError) {
|
|
return error.toJSON()
|
|
}
|
|
|
|
// Fetch abort error
|
|
if (error?.name === 'AbortError') {
|
|
return new CancelledError().toJSON()
|
|
}
|
|
|
|
// Network error
|
|
if (error?.name === 'NetworkError' || error?.name === 'TypeError') {
|
|
return new NetworkError(error.message).toJSON()
|
|
}
|
|
|
|
// Timeout error
|
|
if (error?.name === 'TimeoutError') {
|
|
return new TimeoutError(0, error).toJSON()
|
|
}
|
|
|
|
// Generic Error with status
|
|
if (error?.status) {
|
|
return createErrorFromStatus(error.status, error.message || error.statusText, error).toJSON()
|
|
}
|
|
|
|
// Fallback to generic error
|
|
return new ApiError(
|
|
error?.code || 'UNKNOWN_ERROR',
|
|
error?.status || 0,
|
|
error?.message || 'An unknown error occurred',
|
|
error
|
|
).toJSON()
|
|
}
|
|
|
|
/**
|
|
* Extracts user-friendly error message from an error
|
|
*
|
|
* @param error - The error to extract message from
|
|
* @returns User-friendly error message
|
|
*/
|
|
export function getErrorMessage(error: any): string {
|
|
if (!error) {
|
|
return 'An unknown error occurred'
|
|
}
|
|
|
|
// Try to get message from various error formats
|
|
const message =
|
|
error.message ||
|
|
error.error ||
|
|
error.errors?.[0]?.message ||
|
|
error.statusText ||
|
|
'An unknown error occurred'
|
|
|
|
// Make network errors more user-friendly
|
|
if (message.includes('NetworkError') || message.includes('Failed to fetch')) {
|
|
return 'Unable to connect to the server. Please check your internet connection.'
|
|
}
|
|
|
|
if (message.includes('TimeoutError')) {
|
|
return 'The request took too long. Please try again.'
|
|
}
|
|
|
|
if (message.includes('AbortError') || message.includes('cancelled')) {
|
|
return 'The request was cancelled.'
|
|
}
|
|
|
|
return message
|
|
}
|
|
|
|
/**
|
|
* Calculates retry delay based on attempt number and error type
|
|
*
|
|
* @param attempt - Current attempt number (1-indexed)
|
|
* @param error - The error that triggered the retry
|
|
* @param baseDelay - Base delay in milliseconds (default: 1000)
|
|
* @param maxDelay - Maximum delay in milliseconds (default: 30000)
|
|
* @returns Delay in milliseconds before next retry
|
|
*/
|
|
export function calculateRetryDelay(
|
|
attempt: number,
|
|
error: any,
|
|
baseDelay = 1000,
|
|
maxDelay = 30000
|
|
): number {
|
|
// Use retry-after header for rate limit errors
|
|
if (error instanceof RateLimitError && error.retryAfter) {
|
|
return error.retryAfter * 1000
|
|
}
|
|
|
|
// Exponential backoff: 1s, 2s, 4s, 8s, ...
|
|
let delay = Math.pow(2, attempt - 1) * baseDelay
|
|
|
|
// Add jitter (±25%) to prevent thundering herd
|
|
const jitter = delay * 0.25
|
|
delay = delay + (Math.random() * jitter * 2 - jitter)
|
|
|
|
// Cap at maximum delay
|
|
return Math.min(delay, maxDelay)
|
|
}
|