forked from Shared/esg
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:
277
src/common/helpers/provider-resilience.helper.ts
Normal file
277
src/common/helpers/provider-resilience.helper.ts
Normal 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),
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user