fix(vin-decode): 4 RCA-confirmed decode-chain bugs
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Root-cause analysis live re-decoded all 124 historically-undecoded prod
VINs; 38 already decode now. These 4 fixes target confirmed code bugs
that drop or mask real decodes (see undecoded-vin-rca.md):

Q2 — PL24 circuit breaker now only counts transient transport faults. A
definitive upstream negative (NotFound/BadRequest) no longer trips the
global 30s breaker that was starving PL24 for every subsequent VIN
(the sibling-VIN inconsistency in the report). Live-proven on VR7.

Q3 — previewVin / multi-candidate path no longer returns an empty
success: the pcat/emex candidate branches fill brandName (from catalogId
/ WMI), fixing the 6 "HTTP 200 with null brand+model" cases.

Q1 — EMEX fetch retries transient proxy failures with a FRESH ProxyAgent
per attempt (rotates the DataImpulse port; ~42% blip rate observed),
plus an opt-in direct fallback (EMEX_DIRECT_FALLBACK). HTTP answers are
never retried.

Q4 — VIN resolve cache keys namespaced by DECODE_CHAIN_VERSION and the
negative TTL drops 6h -> 30m, so a decode-chain fix self-heals stale
negatives on deploy instead of masking phantom-undecoded VINs for hours.
The admin cache-buster uses the same key builder.

Tests: 179 passed (+ new Q2/Q3/Q4 specs). typecheck + biome clean.

Deploy note: prod EMEX_PROXY_PORT_START/END are both 823 (single port);
widen to a real range (e.g. 10001-10099) in Coolify so Q1's port
rotation takes full effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:37:11 +03:00
parent bb60a95ec3
commit a3ae90ec29
6 changed files with 256 additions and 61 deletions

View File

@@ -106,6 +106,14 @@ export class EmexService {
private readonly timeout: number;
private readonly debug: boolean;
private readonly proxyAgent: ProxyAgent | null;
private readonly emexProxy: {
host: string;
user: string;
pass: string;
portStart: number;
portEnd: number;
} | null;
private readonly emexDirectFallback: boolean;
constructor(
private configService: ConfigService,
@@ -122,20 +130,24 @@ export class EmexService {
this.debug = this.configService.get<boolean>("EMEX_DEBUG", false);
const useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
this.emexDirectFallback =
this.configService.get<string>("EMEX_DIRECT_FALLBACK", "false") === "true";
if (useProxy) {
const host = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
const portStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
const portEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
const user = this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e");
const pass = this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6");
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
this.proxyAgent = new ProxyAgent({
uri: `http://${user}:${pass}@${host}:${port}`,
connect: { timeout: 30000 },
requestTls: { timeout: 30000 },
});
this.logger.log(`EMEX HTTP proxy enabled: ${host}:${port}`);
this.emexProxy = {
host: this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81"),
user: this.configService.get<string>("EMEX_PROXY_USER", "1726bbe361918676d44e"),
pass: this.configService.get<string>("EMEX_PROXY_PASS", "f11c7b6128cc86c6"),
portStart: this.configService.get<number>("EMEX_PROXY_PORT_START", 10001),
portEnd: this.configService.get<number>("EMEX_PROXY_PORT_END", 10099),
};
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
// fresh agent per request so a flaky port can't pin every call.
this.proxyAgent = this.newProxyAgent();
this.logger.log(
`EMEX HTTP proxy enabled: ${this.emexProxy.host}:${this.emexProxy.portStart}-${this.emexProxy.portEnd}`,
);
} else {
this.emexProxy = null;
this.proxyAgent = null;
}
@@ -244,16 +256,72 @@ export class EmexService {
* emexdwc.ae serves Vehicles.aspx, QuickGroups.aspx, and QuickDetails.aspx
* without requiring authentication cookies.
*/
/** Build a fresh proxy agent on a random port from the pool (null if proxy off). */
private newProxyAgent(): ProxyAgent | null {
if (!this.emexProxy) return null;
const { host, user, pass, portStart, portEnd } = this.emexProxy;
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
return new ProxyAgent({
uri: `http://${user}:${pass}@${host}:${port}`,
connect: { timeout: 30000 },
requestTls: { timeout: 30000 },
});
}
private async fetchEmexHtml(url: string): Promise<string> {
const res = await fetch(url, {
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(this.timeout),
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
} as RequestInit);
if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
const maxAttempts = this.emexProxy ? 3 : 1;
let lastErr: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Fresh agent every attempt: a single sticky DataImpulse port flakes
// intermittently ("fetch failed"/reset), silently dropping real emex hits
// (~42% blip rate observed — undecoded-vin-rca.md, EMEX #1). A new agent
// rotates the port and forces a new socket even on a single-port pool.
const agent = this.newProxyAgent();
try {
const res = await fetch(url, {
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(this.timeout),
...(agent ? { dispatcher: agent } : {}),
} as RequestInit);
if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
}
return await res.text();
} catch (err) {
lastErr = err;
const e = err as Error & { cause?: unknown };
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
const httpAnswer = /^EMEX HTTP \d/.test(e.message);
const transient =
!httpAnswer &&
(e.name === "TimeoutError" ||
e.name === "AbortError" ||
e.name === "TypeError" ||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
`${e.message} ${String(e.cause ?? "")}`,
));
if (!transient || attempt === maxAttempts) break;
this.logger.warn(
`EMEX fetch transient error (attempt ${attempt}/${maxAttempts}) for ${url}: ${e.message} — retrying with fresh proxy`,
);
await new Promise((r) => setTimeout(r, 300 * attempt));
}
}
return res.text();
// Last resort (opt-in via EMEX_DIRECT_FALLBACK): one direct, proxy-less attempt
// for when the whole pool is down. Off by default — it exposes the origin IP.
if (this.emexProxy && this.emexDirectFallback) {
try {
const res = await fetch(url, {
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(this.timeout),
} as RequestInit);
if (res.ok) return await res.text();
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
}
/**
@@ -735,9 +803,7 @@ export class EmexService {
);
return httpResult;
}
this.logger.log(
"Plain-HTTP path returned 0 parts; falling back to Playwright scraper",
);
this.logger.log("Plain-HTTP path returned 0 parts; falling back to Playwright scraper");
} catch (err) {
this.logger.warn(
`Plain-HTTP path failed: ${(err as Error).message}; falling back to Playwright`,
@@ -797,9 +863,7 @@ export class EmexService {
throw new Error("No Unit.aspx link in QuickDetails response");
}
const unitRel = unitMatch[1].replace(/&amp;/g, "&");
const unitUrl = unitRel.startsWith("http")
? unitRel
: new URL(unitRel, categoryUrl).toString();
const unitUrl = unitRel.startsWith("http") ? unitRel : new URL(unitRel, categoryUrl).toString();
// 2) Unit.aspx — main extraction target
const unitHtml = await this.fetchEmexHtml(unitUrl);
@@ -853,9 +917,13 @@ export class EmexService {
.trim();
for (const trMatch of html.matchAll(trRx)) {
const body = trMatch[1];
const oem = stripTags(body.match(/<td\b[^>]*\bname="c_oem"[^>]*>([\s\S]*?)<\/td>/)?.[1] ?? "");
const oem = stripTags(
body.match(/<td\b[^>]*\bname="c_oem"[^>]*>([\s\S]*?)<\/td>/)?.[1] ?? "",
);
if (!oem) continue;
const pnc = stripTags(body.match(/<td\b[^>]*\bname="c_pnc"[^>]*>([\s\S]*?)<\/td>/)?.[1] ?? "");
const pnc = stripTags(
body.match(/<td\b[^>]*\bname="c_pnc"[^>]*>([\s\S]*?)<\/td>/)?.[1] ?? "",
);
const name = stripTags(
body.match(/<td\b[^>]*\bname="c_name"[^>]*>([\s\S]*?)<\/td>/)?.[1] ?? "",
);
@@ -929,11 +997,7 @@ export class EmexService {
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
}
// PNG — width at byte 16 BE, height at byte 20 BE.
if (
buf.length >= 24 &&
buf[0] === 0x89 &&
buf.slice(1, 4).toString("ascii") === "PNG"
) {
if (buf.length >= 24 && buf[0] === 0x89 && buf.slice(1, 4).toString("ascii") === "PNG") {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
return { width: 0, height: 0 };