fix(pcat-auth): allow 3 concurrent captures + validate slots upstream

Two follow-ups to the warm-pool patch:

* Semaphore 1 → 3. Playwright contexts are isolated, the captureToPool
  site/port allocation is synchronous (no race), and concurrent user
  clicks that all miss the pool no longer serialize behind a single
  ~5s capture. Peak memory grows from one context to three; each is
  short-lived.

* Capture-time validation. After Playwright extracts the JWT, do one
  cheap upstream call (/car/info with the public demo VIN) through the
  same proxy port before pushing the slot to the pool. DataImpulse
  occasionally rotates to IPs the partner widget can load but the
  upstream API can't reach, or that get instantly 401/403'd; those
  ports used to spend 30s timing out on the first real user click.
  Failures rotate to the next site within the existing 4-retry budget.

Adds ~1s to each successful capture; saves up to 30s per dead slot.
This commit is contained in:
2026-06-01 19:50:19 +03:00
parent a4644463e4
commit 9627e58cdd

View File

@@ -29,6 +29,14 @@ const PAGE_TIMEOUT = 10_000; // 10s — healthy partner sites load in <5s throug
const CONTEXT_CLOSE_TIMEOUT = 5_000;
const SITE_COOLDOWN = 10 * 60 * 1000; // 10 min per site
const MAX_POOL_SIZE = 5;
// Capture-time validation: do one tiny upstream call with the freshly-captured
// JWT + proxy port BEFORE inserting the slot in the pool. DataImpulse's rotating
// proxy occasionally hands out IPs that the partner-site widget can load but
// the upstream API cannot reach (or that get instantly 401/403'd). Catching
// those at capture time means the bad slot never reaches a real user.
const VALIDATION_VIN = "WVWZZZ1JZ3W597935"; // public demo VIN from the homepage
const VALIDATION_TIMEOUT_MS = 5_000;
const PCAT_API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
const RPM_WINDOW = 60_000; // 1-minute rolling window
const RPM_PER_SLOT = 6; // 1 token per 6 req/min
@@ -95,7 +103,10 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private browser: Browser | null = null;
private launching: Promise<void> | null = null;
private readonly semaphore = new Semaphore(1); // Max 1 concurrent JWT capture
// 3 concurrent captures: Playwright contexts are isolated, the synchronous
// site/port allocation hands out distinct values per call, and concurrent
// user clicks that all miss the pool no longer serialize behind one capture.
private readonly semaphore = new Semaphore(3);
// Pool state
private pool: JwtSlot[] = [];
@@ -253,6 +264,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
const jwt = await this.attemptCapture(siteUrl, port);
if (jwt) {
// Validate the JWT+proxy combo against the real upstream before
// publishing the slot — see VALIDATION_VIN comment above.
const valid = await this.validateSlot(jwt, port);
if (!valid) {
this.logger.warn(
`JWT capture attempt ${attempt + 1}/${maxRetries} validation failed (port ${port}, ${new URL(siteUrl).hostname}), trying next site...`,
);
// Mark site as used anyway so we rotate away rather than retry it.
this.siteLastUsedAt.set(siteUrl, Date.now());
continue;
}
const slot: JwtSlot = {
jwt,
proxyPort: port,
@@ -290,6 +313,46 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
}
}
/**
* Probe the just-captured token + proxy port with a single cheap upstream
* call (/car/info with a public demo VIN). Returns true iff the upstream
* accepts the combo with 2xx within VALIDATION_TIMEOUT_MS. A failure here
* (proxy dead, IP blocked, token 401/403) is far cheaper to absorb at
* capture time than to inherit when a real user clicks a category.
*/
private async validateSlot(jwt: PcatJwtToken, port: number): Promise<boolean> {
const url = `${PCAT_API_BASE}/car/info?q=${VALIDATION_VIN}`;
const fetchOptions: RequestInit & { dispatcher?: any } = {
method: "GET",
headers: {
"x-api-key": jwt.raw,
"x-api-path": jwt.apiPath,
"x-gui-version": jwt.guiVersion,
"x-user-id": jwt.userId,
origin: jwt.origin,
referer: jwt.referer,
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
},
signal: AbortSignal.timeout(VALIDATION_TIMEOUT_MS),
};
if (this.useProxy) {
const { ProxyAgent } = await import("undici");
fetchOptions.dispatcher = new ProxyAgent({
uri: `http://${this.proxyUser}:${this.proxyPass}@${this.proxyHost}:${port}`,
connect: { timeout: 6_000 },
});
}
try {
const r = await fetch(url, fetchOptions);
return r.ok;
} catch (err) {
this.logger.debug(`Slot validation threw: ${(err as Error).message}`);
return false;
}
}
// ─── Timer-based refresh per slot ─────────────────────────
private scheduleSlotRefresh(slot: JwtSlot): void {