/** * Base Carrier Connector * * Abstract base class for carrier API integrations * Provides common functionality: HTTP client, retry logic, circuit breaker, logging */ import { Logger } from '@nestjs/common'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import CircuitBreaker from 'opossum'; import { CarrierConnectorPort, CarrierRateSearchInput, CarrierAvailabilityInput, } from '../../domain/ports/out/carrier-connector.port'; import { RateQuote } from '../../domain/entities/rate-quote.entity'; import { CarrierTimeoutException } from '../../domain/exceptions/carrier-timeout.exception'; import { CarrierUnavailableException } from '../../domain/exceptions/carrier-unavailable.exception'; export interface CarrierConfig { name: string; code: string; baseUrl: string; timeout: number; // milliseconds maxRetries: number; circuitBreakerThreshold: number; // failure threshold before opening circuit circuitBreakerTimeout: number; // milliseconds to wait before half-open } export abstract class BaseCarrierConnector implements CarrierConnectorPort { protected readonly logger: Logger; protected readonly httpClient: AxiosInstance; protected readonly circuitBreaker: CircuitBreaker; constructor(protected readonly config: CarrierConfig) { this.logger = new Logger(`${config.name}Connector`); // Create HTTP client this.httpClient = axios.create({ baseURL: config.baseUrl, timeout: config.timeout, headers: { 'Content-Type': 'application/json', 'User-Agent': 'Xpeditis/1.0', }, }); // Add request interceptor for logging this.httpClient.interceptors.request.use( (request: any) => { this.logger.debug( `Request: ${request.method?.toUpperCase()} ${request.url}`, request.data ? JSON.stringify(request.data).substring(0, 200) : '' ); return request; }, (error: any) => { this.logger.error(`Request error: ${error?.message || 'Unknown error'}`); return Promise.reject(error); } ); // Add response interceptor for logging this.httpClient.interceptors.response.use( (response: any) => { this.logger.debug(`Response: ${response.status} ${response.statusText}`); return response; }, (error: any) => { if (error?.code === 'ECONNABORTED') { this.logger.warn(`Request timeout after ${config.timeout}ms`); throw new CarrierTimeoutException(config.name, config.timeout); } this.logger.error(`Response error: ${error?.message || 'Unknown error'}`); return Promise.reject(error); } ); // Create circuit breaker this.circuitBreaker = new CircuitBreaker(this.makeRequest.bind(this), { timeout: config.timeout, errorThresholdPercentage: config.circuitBreakerThreshold, resetTimeout: config.circuitBreakerTimeout, name: `${config.name}-circuit-breaker`, }); // Circuit breaker event handlers this.circuitBreaker.on('open', () => { this.logger.warn('Circuit breaker opened - carrier unavailable'); }); this.circuitBreaker.on('halfOpen', () => { this.logger.log('Circuit breaker half-open - testing carrier availability'); }); this.circuitBreaker.on('close', () => { this.logger.log('Circuit breaker closed - carrier available'); }); } getCarrierName(): string { return this.config.name; } getCarrierCode(): string { return this.config.code; } /** * Make HTTP request with retry logic */ protected async makeRequest( config: AxiosRequestConfig, retries = this.config.maxRetries ): Promise> { try { return await this.httpClient.request(config); } catch (error: any) { if (retries > 0 && this.isRetryableError(error)) { const delay = this.calculateRetryDelay(this.config.maxRetries - retries); this.logger.warn(`Request failed, retrying in ${delay}ms (${retries} retries left)`); await this.sleep(delay); return this.makeRequest(config, retries - 1); } throw error; } } /** * Determine if error is retryable */ protected isRetryableError(error: any): boolean { // Retry on network errors, timeouts, and 5xx server errors if (error.code === 'ECONNABORTED') return false; // Don't retry timeouts if (error.code === 'ENOTFOUND') return false; // Don't retry DNS errors if (error.response) { const status = error.response.status; return status >= 500 && status < 600; } return true; // Retry network errors } /** * Calculate retry delay with exponential backoff */ protected calculateRetryDelay(attempt: number): number { const baseDelay = 1000; // 1 second const maxDelay = 5000; // 5 seconds const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); // Add jitter to prevent thundering herd return delay + Math.random() * 1000; } /** * Sleep utility */ protected sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Make request with circuit breaker protection */ protected async requestWithCircuitBreaker( config: AxiosRequestConfig ): Promise> { try { return (await this.circuitBreaker.fire(config)) as AxiosResponse; } catch (error: any) { if (error?.message === 'Breaker is open') { throw new CarrierUnavailableException(this.config.name, 'Circuit breaker is open'); } throw error; } } /** * Health check implementation */ async healthCheck(): Promise { try { await this.requestWithCircuitBreaker({ method: 'GET', url: '/health', timeout: 5000, }); return true; } catch (error: any) { this.logger.warn(`Health check failed: ${error?.message || 'Unknown error'}`); return false; } } /** * Abstract methods to be implemented by specific carriers */ abstract searchRates(input: CarrierRateSearchInput): Promise; abstract checkAvailability(input: CarrierAvailabilityInput): Promise; }