Files
esg/src/providers/base/base-provider.abstract.ts
2026-06-20 18:18:48 +03:30

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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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>;
}