forked from Shared/esg
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>
278 lines
8.2 KiB
TypeScript
278 lines
8.2 KiB
TypeScript
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),
|
||
},
|
||
);
|
||
}
|