/** * 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', }, }); // Request interceptor this.httpClient.interceptors.request.use( request => { this.logger.debug( `Request: ${request.method?.toUpperCase()} ${request.url}`, request.data ? JSON.stringify(request.data).substring(0, 200) : '' ); return request; }, error => { this.logger.error(`Request error: ${error?.message || 'Unknown error'}`); return Promise.reject(error); } ); // Response interceptor this.httpClient.interceptors.response.use( response => { this.logger.debug(`Response: ${response.status} ${response.statusText}`); return response; }, error => { 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); } ); // Circuit breaker this.circuitBreaker = new CircuitBreaker(this.makeRequest.bind(this), { timeout: config.timeout, errorThresholdPercentage: config.circuitBreakerThreshold, resetTimeout: config.circuitBreakerTimeout, name: `${config.name}-circuit-breaker`, }); 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; } 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; } } protected isRetryableError(error: any): boolean { if (error.code === 'ECONNABORTED') return false; if (error.code === 'ENOTFOUND') return false; if (error.response) { const status = error.response.status; return status >= 500 && status < 600; } return true; } protected calculateRetryDelay(attempt: number): number { const baseDelay = 1000; const maxDelay = 5000; const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); return delay + Math.random() * 1000; // jitter } protected sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } 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; } } 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 searchRates(input: CarrierRateSearchInput): Promise; abstract checkAvailability(input: CarrierAvailabilityInput): Promise; }