From 1c187847c493d9554ae9cdc21a9b721a3bfe3623 Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Mon, 25 May 2026 02:19:18 +0300 Subject: [PATCH] perf(api): short per-call timeout + more retries for PartsCatalogs VIN decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../parts-catalogs/parts-catalogs.service.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/api/src/integrations/parts-catalogs/parts-catalogs.service.ts b/apps/api/src/integrations/parts-catalogs/parts-catalogs.service.ts index 5cb0c0a..ba99657 100644 --- a/apps/api/src/integrations/parts-catalogs/parts-catalogs.service.ts +++ b/apps/api/src/integrations/parts-catalogs/parts-catalogs.service.ts @@ -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 { 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, externalSignal?: AbortSignal, + opts?: { timeoutMs?: number; maxRetries?: number }, ): Promise { - 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",