perf(api): short per-call timeout + more retries for PartsCatalogs VIN decode
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

The DataImpulse proxy has dead ports that stall ~10s on connect, and undici's
ProxyAgent connect.timeout does NOT bound the proxy connection (verified: runtime
still 10s despite the Phase-2 setting). With the 30s request timeout, 2-3 dead
ports blow the 25s decode budget → false misses + 12-32s p95 (prod). /car/info
answers in <1s on a healthy proxy, so bound it at the fetch level instead: a 6s
per-call AbortSignal + 4 retries (both env-tunable: PCAT_DECODE_TIMEOUT_MS /
PCAT_DECODE_MAX_RETRIES) make a stuck port abort fast and rotate to a live one
within budget. Helps every PCAT decode, not just Renault.

Band-aid for proxy flakiness; proper fix is proxy port health-tracking (follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 02:19:18 +03:00
parent 497f9cbc41
commit 1c187847c4

View File

@@ -24,6 +24,13 @@ const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
// timeout before the retry rotates to a fresh port — three of those blow the
// caller's 25s decode budget. Fail fast so retries reach a live port in time.
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
// VIN decode (/car/info) answers in <1s on a healthy proxy, so a long timeout only
// prolongs dead-port connects. undici's ProxyAgent connect.timeout does NOT bound the
// connection to the proxy itself (it stays at undici's 10s default), so we bound it at
// the fetch level: a short per-call timeout + extra retries makes a stuck DataImpulse
// port abort fast and rotate to a live one within the caller's 25s decode budget.
const DECODE_REQUEST_TIMEOUT = Number(process.env.PCAT_DECODE_TIMEOUT_MS) || 6_000;
const DECODE_MAX_RETRIES = Number(process.env.PCAT_DECODE_MAX_RETRIES) || 4;
@Injectable()
export class PartsCatalogsService {
@@ -52,7 +59,10 @@ export class PartsCatalogsService {
outcome?: { transient: boolean },
): Promise<PcatVinResult | null> {
try {
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal);
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal, {
timeoutMs: DECODE_REQUEST_TIMEOUT,
maxRetries: DECODE_MAX_RETRIES,
});
if (!data || typeof data !== "object") {
return null;
@@ -192,8 +202,10 @@ export class PartsCatalogsService {
endpoint: string,
params?: Record<string, string>,
externalSignal?: AbortSignal,
opts?: { timeoutMs?: number; maxRetries?: number },
): Promise<any> {
const maxRetries = MAX_RETRIES;
const maxRetries = opts?.maxRetries ?? MAX_RETRIES;
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT;
let session: PcatSession | null = null;
@@ -211,7 +223,7 @@ export class PartsCatalogsService {
}
try {
const signals = [AbortSignal.timeout(REQUEST_TIMEOUT)];
const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) signals.push(externalSignal);
const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET",