Files
esg/src/common/helpers/provider-resilience.helper.ts
s.hajizadeh 61098f1bf4 feat: add car inquiries with resilience, ownership privacy, and retry visibility
Introduce plate, chassis, and third-party car endpoints, a wall-clock inquiry
deadline with transport-only retries, and ownership-safe no-match errors so
another person's identity never leaks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:21:16 +03:30

278 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AxiosError } from 'axios';
import { AttemptErrorDto, AttemptSummaryDto } from '../dto/attempt-summary.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { ProviderExecutionContext } from '../interfaces/inquiry-provider.interface';
import { buildNormalizedError } from '../constants/error-messages';
import { translateError } from './translate-error.helper';
import { withRetry, RetryAttemptInfo } from './retry.helper';
import { withTimeout } from './timeout.helper';
/**
* Hard wall-clock budget for one inquiry (all attempts + fallbacks).
* Must exceed a single slow CentInsur SOAP over proxy (~25–30s).
* thirdPartyCar may need two SOAP calls, so default is 90s.
* Override with INQUIRY_DEADLINE_MS env.
*/
export const INQUIRY_DEADLINE_MS = (() => {
const fromEnv = Number.parseInt(process.env.INQUIRY_DEADLINE_MS ?? '', 10);
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 90_000;
})();
/** Default max HTTP attempts per provider execution. */
export const PROVIDER_DEFAULT_MAX_ATTEMPTS = 3;
/** Initial backoff before the second attempt (then ×2). */
export const PROVIDER_RETRY_DELAY_MS = 400;
/** Auth login/refresh: one gentle retry only. */
export const AUTH_MAX_ATTEMPTS = 2;
export const AUTH_RETRY_DELAY_MS = 500;
/** Do not start another attempt if remaining budget is below this. */
export const MIN_ATTEMPT_BUDGET_MS = 100;
export interface AttemptTrail {
totalAttempts: number;
failedAttempts: AttemptErrorDto[];
}
export function createAttemptTrail(): AttemptTrail {
return { totalAttempts: 0, failedAttempts: [] };
}
export function ensureAttemptTrail(context: ProviderExecutionContext): AttemptTrail {
if (!context.attemptTrail) {
context.attemptTrail = createAttemptTrail();
}
return context.attemptTrail;
}
export function buildAttemptSummary(
trail: AttemptTrail | undefined,
durationMs: number,
): AttemptSummaryDto {
const totalAttempts = trail?.totalAttempts ?? 0;
return {
totalAttempts,
retried: totalAttempts > 1,
durationMs,
attempts: trail?.failedAttempts ?? [],
};
}
/** Attach summary on failure always; on success only when retried. */
export function shouldExposeAttemptSummary(
success: boolean,
summary: AttemptSummaryDto | undefined,
): boolean {
if (!summary || summary.totalAttempts === 0) {
return false;
}
return !success || summary.retried;
}
export function createDeadlineError(label = 'Inquiry'): Error {
const normalized = buildNormalizedError('INQUIRY_DEADLINE_EXCEEDED', {
message: `${label} exceeded the ${INQUIRY_DEADLINE_MS}ms deadline`,
});
const error = new Error(normalized.message);
(error as Error & { normalizedError: NormalizedErrorDto; code: string }).normalizedError =
normalized;
(error as NodeJS.ErrnoException).code = 'ETIMEDOUT';
return error;
}
/**
* Transport / transient failures only.
* Does NOT retry 429 (rate limit / ban risk) or business rejects.
*/
export function isTransportRetryable(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
if ('code' in error) {
const code = (error as { code?: string }).code;
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET') {
return true;
}
}
if ('response' in error) {
const status = (error as { response?: { status?: number } }).response?.status;
if (typeof status === 'number') {
if (status === 408 || status === 499 || (status >= 500 && status < 600)) {
return true;
}
}
}
if ('normalizedError' in error) {
const normalized = (error as { normalizedError: NormalizedErrorDto }).normalizedError;
if (
normalized.code === 'PROVIDER_TIMEOUT' ||
normalized.code === 'PROVIDER_NETWORK_ERROR' ||
normalized.code === 'INQUIRY_DEADLINE_EXCEEDED'
) {
return normalized.code !== 'INQUIRY_DEADLINE_EXCEEDED';
}
const providerCode = normalized.providerCode?.trim();
if (providerCode && /^(408|499|5\d\d)$/.test(providerCode)) {
return true;
}
}
return false;
}
/** Auth: timeout / connection blips only — never 401/403/429/business rejects. */
export function isAuthTransportRetryable(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
if (error instanceof AxiosError) {
if (error.response) {
return false;
}
const code = error.code;
return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET';
}
if ('code' in error) {
const code = (error as { code?: string }).code;
return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET';
}
return false;
}
export function toAttemptErrorFields(error: unknown): {
code: string;
message: string;
messageFa?: string;
} {
if (error && typeof error === 'object' && 'normalizedError' in error) {
const normalized = translateError(
(error as { normalizedError: NormalizedErrorDto }).normalizedError,
);
return {
code: normalized.code,
message: normalized.message,
messageFa: normalized.messageFa,
};
}
if (error instanceof AxiosError) {
const status = error.response?.status;
const code =
error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED'
? 'PROVIDER_TIMEOUT'
: status && status >= 500
? 'PROVIDER_ERROR'
: 'PROVIDER_NETWORK_ERROR';
const normalized = translateError(
buildNormalizedError(code as 'PROVIDER_TIMEOUT' | 'PROVIDER_ERROR' | 'PROVIDER_NETWORK_ERROR', {
message: error.message,
}),
);
return {
code: normalized.code,
message: normalized.message,
messageFa: normalized.messageFa,
};
}
const errno = (error as NodeJS.ErrnoException | undefined)?.code;
const message = error instanceof Error ? error.message : 'Provider request failed';
const looksLikeTimeout =
errno === 'ETIMEDOUT' ||
errno === 'ECONNABORTED' ||
/timed?\s*out|deadline exceeded/i.test(message);
const code = looksLikeTimeout
? /deadline exceeded/i.test(message)
? 'INQUIRY_DEADLINE_EXCEEDED'
: 'PROVIDER_TIMEOUT'
: errno === 'ECONNRESET' || errno === 'NETWORK_ERROR'
? 'PROVIDER_NETWORK_ERROR'
: 'PROVIDER_ERROR';
const normalized = translateError(buildNormalizedError(code, { message }));
return {
code: normalized.code,
message: normalized.message,
messageFa: normalized.messageFa,
};
}
export function recordResilienceAttempt(
context: ProviderExecutionContext,
provider: string,
info: RetryAttemptInfo,
): void {
const trail = ensureAttemptTrail(context);
trail.totalAttempts += 1;
if (!info.succeeded && info.error !== undefined) {
const fields = toAttemptErrorFields(info.error);
trail.failedAttempts.push({
attempt: trail.totalAttempts,
provider,
code: fields.code,
durationMs: info.durationMs,
message: fields.message,
messageFa: fields.messageFa,
});
}
}
export interface RunWithResilienceOptions {
providerName: string;
maxAttempts: number;
timeoutMs: number;
context: ProviderExecutionContext;
label?: string;
shouldRetry?: (error: unknown) => boolean;
}
/**
* Shared retry + per-attempt timeout + inquiry deadline for all inquiry providers.
*/
export async function runWithProviderResilience<T>(
fn: () => Promise<T>,
options: RunWithResilienceOptions,
): Promise<T> {
const {
providerName,
maxAttempts,
timeoutMs,
context,
label = `${providerName} request`,
shouldRetry = isTransportRetryable,
} = options;
ensureAttemptTrail(context);
return withRetry(
async () => {
const remaining = context.deadlineAt
? context.deadlineAt - Date.now()
: timeoutMs;
if (remaining < MIN_ATTEMPT_BUDGET_MS) {
throw createDeadlineError(label);
}
const attemptTimeout = Math.min(timeoutMs, remaining);
return withTimeout(fn(), attemptTimeout, label);
},
{
maxAttempts,
delayMs: PROVIDER_RETRY_DELAY_MS,
backoffMultiplier: 2,
deadlineAt: context.deadlineAt,
minRemainingMs: MIN_ATTEMPT_BUDGET_MS,
shouldRetry,
onAttempt: (info) => recordResilienceAttempt(context, providerName, info),
},
);
}