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>
This commit is contained in:
2026-09-05 17:21:16 +03:30
parent b4be98b156
commit 61098f1bf4
40 changed files with 2595 additions and 374 deletions

View File

@@ -0,0 +1,277 @@
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),
},
);
}