forked from Shared/esg
181 lines
5.4 KiB
TypeScript
181 lines
5.4 KiB
TypeScript
import { Logger } from '@nestjs/common';
|
|
import { AxiosError } from 'axios';
|
|
import { InquiryType } from '../../common/enums/inquiry-type.enum';
|
|
import { ProviderName } from '../../common/enums/provider-name.enum';
|
|
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
|
|
import {
|
|
InquiryProvider,
|
|
ProviderExecutionContext,
|
|
} from '../../common/interfaces/inquiry-provider.interface';
|
|
import { withRetry } from '../../common/helpers/retry.helper';
|
|
import { withTimeout } from '../../common/helpers/timeout.helper';
|
|
import { RequestLogger } from '../../common/helpers/request-logger.helper';
|
|
import { ProviderConfigSlice } from '../interfaces/provider-config.interface';
|
|
|
|
/**
|
|
* Abstract base for all provider adapters.
|
|
*
|
|
* Responsibilities:
|
|
* - Retry + timeout orchestration
|
|
* - Standardized error normalization
|
|
* - Response formatting hooks
|
|
* - Structured logging
|
|
*
|
|
* Subclasses implement provider-specific HTTP/business logic only.
|
|
*/
|
|
export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
|
|
implements InquiryProvider<TRequest, TResponse>
|
|
{
|
|
abstract readonly name: ProviderName;
|
|
abstract readonly supportedInquiryTypes: InquiryType[];
|
|
|
|
protected readonly logger: RequestLogger;
|
|
protected readonly nestLogger: Logger;
|
|
|
|
constructor(protected readonly config: ProviderConfigSlice) {
|
|
this.logger = new RequestLogger(this.constructor.name);
|
|
this.nestLogger = new Logger(this.constructor.name);
|
|
}
|
|
|
|
isEnabled(): boolean {
|
|
return this.config.enabled;
|
|
}
|
|
|
|
async execute(
|
|
inquiryType: InquiryType,
|
|
payload: TRequest,
|
|
context: ProviderExecutionContext,
|
|
): Promise<TResponse> {
|
|
if (!this.supportedInquiryTypes.includes(inquiryType)) {
|
|
throw this.normalizeError({
|
|
code: 'UNSUPPORTED_INQUIRY',
|
|
message: `Provider ${this.name} does not support ${inquiryType}`,
|
|
});
|
|
}
|
|
|
|
const start = Date.now();
|
|
this.logger.logStart(
|
|
{
|
|
requestId: context.requestId,
|
|
trackingCode: context.trackingCode,
|
|
provider: this.name,
|
|
inquiryType,
|
|
},
|
|
'Provider execution started',
|
|
);
|
|
|
|
try {
|
|
const result = await this.executeWithResilience(
|
|
() => this.callProvider(inquiryType, payload, context),
|
|
context,
|
|
);
|
|
|
|
this.logger.logSuccess(
|
|
{
|
|
requestId: context.requestId,
|
|
trackingCode: context.trackingCode,
|
|
provider: this.name,
|
|
inquiryType,
|
|
durationMs: Date.now() - start,
|
|
},
|
|
'Provider execution succeeded',
|
|
);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
this.logger.logFailure(
|
|
{
|
|
requestId: context.requestId,
|
|
trackingCode: context.trackingCode,
|
|
provider: this.name,
|
|
inquiryType,
|
|
durationMs: Date.now() - start,
|
|
},
|
|
'Provider execution failed',
|
|
error,
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
protected async executeWithResilience<T>(
|
|
fn: () => Promise<T>,
|
|
context: ProviderExecutionContext,
|
|
): Promise<T> {
|
|
const operation = () =>
|
|
withTimeout(fn(), this.config.timeout, `${this.name} request`);
|
|
|
|
return withRetry(operation, {
|
|
maxAttempts: this.config.maxRetries,
|
|
delayMs: 300,
|
|
shouldRetry: (error) => this.isRetryable(error),
|
|
});
|
|
}
|
|
|
|
protected isRetryable(error: unknown): boolean {
|
|
if (error && typeof error === 'object' && 'code' in error) {
|
|
const code = (error as { code?: string }).code;
|
|
return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
|
|
return {
|
|
code: partial.code ?? 'PROVIDER_ERROR',
|
|
message: partial.message ?? 'Provider request failed',
|
|
providerMessage: partial.providerMessage,
|
|
providerCode: partial.providerCode,
|
|
providerTrackingCode: partial.providerTrackingCode,
|
|
details: partial.details,
|
|
conflict: partial.conflict,
|
|
};
|
|
}
|
|
|
|
protected formatProviderError(
|
|
providerMessage?: string,
|
|
providerCode?: string,
|
|
fallbackMessage = 'Provider request failed',
|
|
extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details' | 'conflict'>,
|
|
): Error {
|
|
const message = providerMessage?.trim() || fallbackMessage;
|
|
const code = providerCode?.trim() || 'PROVIDER_ERROR';
|
|
const normalized = this.normalizeError({
|
|
code,
|
|
message,
|
|
providerMessage: message,
|
|
providerCode: code,
|
|
...extras,
|
|
});
|
|
const err = new Error(message);
|
|
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
|
|
return err;
|
|
}
|
|
|
|
protected formatAxiosError(error: AxiosError): Error {
|
|
const data = error.response?.data;
|
|
if (typeof data === 'string') {
|
|
const faultMatch = data.match(
|
|
/<(?:\w+:)?faultstring[^>]*>([\s\S]*?)<\/(?:\w+:)?faultstring>/i,
|
|
);
|
|
if (faultMatch?.[1]) {
|
|
return this.formatProviderError(
|
|
faultMatch[1].trim().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
|
String(error.response?.status ?? 'NETWORK_ERROR'),
|
|
);
|
|
}
|
|
}
|
|
|
|
return this.formatProviderError(
|
|
error.message,
|
|
String(error.response?.status ?? 'NETWORK_ERROR'),
|
|
);
|
|
}
|
|
|
|
protected abstract callProvider(
|
|
inquiryType: InquiryType,
|
|
payload: TRequest,
|
|
context: ProviderExecutionContext,
|
|
): Promise<TResponse>;
|
|
}
|