xpeditis2.0/apps/backend/src/infrastructure/carriers/base-carrier.connector.ts
David-Henri ARNAUD 1044900e98 feature phase
2025-10-08 16:56:27 +02:00

200 lines
6.1 KiB
TypeScript

/**
* 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<T>(
config: AxiosRequestConfig,
retries = this.config.maxRetries
): Promise<AxiosResponse<T>> {
try {
return await this.httpClient.request<T>(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<T>(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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Make request with circuit breaker protection
*/
protected async requestWithCircuitBreaker<T>(
config: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
try {
return (await this.circuitBreaker.fire(config)) as AxiosResponse<T>;
} 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<boolean> {
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<RateQuote[]>;
abstract checkAvailability(input: CarrierAvailabilityInput): Promise<number>;
}