Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m53s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Has been cancelled
- Replace all ../../domain/ imports with @domain/ across 67 files - Configure NestJS to use tsconfig.build.json with rootDir - Add tsc-alias to resolve path aliases after build - This fixes 'Cannot find module' TypeScript compilation errors Fixed files: - 30 files in application layer - 37 files in infrastructure layer
170 lines
5.4 KiB
TypeScript
170 lines
5.4 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',
|
|
},
|
|
});
|
|
|
|
// 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<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;
|
|
}
|
|
}
|
|
|
|
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<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 searchRates(input: CarrierRateSearchInput): Promise<RateQuote[]>;
|
|
abstract checkAvailability(input: CarrierAvailabilityInput): Promise<number>;
|
|
}
|