feat(proxy): otomatik Floxy→DataImpulse failover (decode regresyonu)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Floxy residential proxy bitince/çökünce (402 bakiye veya tünel reddi —
ERR_TUNNEL_CONNECTION_FAILED), EMEX + pcat decode istekleri ölü proxy'ye
çarpıp decode başarı oranını çökertiyordu (2026-06-13: ~17k tünel hatası/24s,
decode %78→%45). Önceki "DataImpulse last-ditch fallback" tarayıcı yolunda hiç
yoktu ve HTTP yolunda her istekte 2 ölü Floxy denemesi ziyan ediyordu.

Paylaşılan ProxyHealthService kapısı (in-memory cooldown): herhangi bir tüketici
bir Floxy bağlantı hatası görünce kapıyı tetikler; cooldown boyunca TÜM tüketiciler
DataImpulse'a düşer. Floxy'den ilk başarılı yanıt veya cooldown bitişi kapıyı
temizler (kendi kendini iyileştirir, periyodik yeniden-deneme). FLOXY_FAILOVER_COOLDOWN_MS
ile ayarlanır (varsayılan 180s).

Bağlanan tüketiciler:
- EMEX HTTP (fetchEmexHtml): kapı açıkken DataImpulse-öncelikli zamanlama
- EMEX tarayıcı (Playwright): launch'ta dinamik sağlayıcı seçimi + ensureSession
  health-gate'i (Floxy ölünce DataImpulse'a relaunch, kapı temizlenince Floxy'i
  yeniden dene); scrape-içi tünel ölümünde tripFloxyFailover
- pcat (buildProxy): hem call hem capture (Playwright JWT) bacakları DataImpulse'a düşer

isProxyConnectFailure(): yalnız gerçek bağlantı hatalarında tetikler — yavaş-ama-
canlı exit'in nav timeout'u failover'ı tetiklemez.

Test: ProxyHealthService + isProxyConnectFailure birim testleri; tüm api suite (303) yeşil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:03:23 +03:00
parent aba8e5eb9b
commit b992c0075b
9 changed files with 365 additions and 43 deletions

View File

@@ -14,6 +14,11 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Browser, BrowserContext, Page } from "playwright";
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
import {
classifyTransportError,
isProxyConnectFailure,
} from "../proxy-telemetry/proxy-telemetry.service";
const SESSION_TTL_MS = 25 * 60 * 1000; // 25 minutes
const MAX_CONCURRENT_PAGES = 3;
@@ -76,8 +81,16 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
private readonly floxyPass: string;
private readonly floxyLifetime: number;
private startedAt = 0;
// Provider the live browser was actually launched with. Tracks failover: when
// the configured provider is "floxy" but the shared health gate is tripped,
// (re)launch picks "dataimpulse" instead. Whole-browser scope because Playwright
// binds the proxy at launch — a relaunch is what swaps the exit.
private activeProvider: "floxy" | "dataimpulse" | "none" = "none";
constructor(private configService: ConfigService) {
constructor(
private configService: ConfigService,
private readonly proxyHealth: ProxyHealthService,
) {
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
@@ -206,7 +219,11 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
if (executablePath) launchOptions.executablePath = executablePath;
if (this.proxyProvider === "floxy") {
// Pick the provider for THIS launch: honour the configured one, but while the
// shared Floxy-health gate is tripped, a "floxy" config launches on DataImpulse.
this.activeProvider = this.pickLaunchProvider();
if (this.activeProvider === "floxy") {
// Sticky Floxy session for the browser's lifetime → one residential exit IP
// for the whole chained scrape; a fresh id is minted on each (re)launch.
const sid = Math.random().toString(36).slice(2, 10);
@@ -222,14 +239,18 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
this.logger.log(
`Using Floxy residential proxy: ${this.floxyHost}:${this.floxyPort} (sticky ${this.floxyLifetime}s)`,
);
} else if (this.proxyProvider === "dataimpulse") {
} else if (this.activeProvider === "dataimpulse") {
const port = this.randomProxyPort();
launchOptions.proxy = {
server: `http://${this.proxyHost}:${port}`,
username: this.proxyUsername,
password: this.proxyPassword,
};
this.logger.log(`Using DataImpulse proxy: ${this.proxyHost}:${port}`);
this.logger.log(
`Using DataImpulse proxy: ${this.proxyHost}:${port}${
this.proxyProvider === "floxy" ? " (Floxy failover)" : ""
}`,
);
}
this.browser = await chromium.launch(launchOptions);
@@ -270,34 +291,94 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
await this.launchBrowser();
}
/** Provider for the next launch: DataImpulse while the Floxy gate is tripped. */
private pickLaunchProvider(): "floxy" | "dataimpulse" | "none" {
if (this.proxyProvider === "floxy" && this.proxyHealth.isFloxyDown()) {
return "dataimpulse"; // DI creds are always present (env defaults) when proxy on
}
return this.proxyProvider;
}
private async relaunchBrowser(): Promise<void> {
await this.closeBrowser();
await this.launchBrowser();
}
/**
* Visit baseUrl to establish/refresh ASP.NET session cookie
* Trip Floxy failover from a scrape-level transport failure (a connect error
* that surfaced AFTER the session was established, so ensureSession's own gate
* didn't catch it). Opens the shared cooldown and forces the next acquirePage
* to re-establish the session — which switches the browser to DataImpulse.
*/
async tripFloxyFailover(reason?: string): Promise<void> {
this.proxyHealth.reportFloxyFailure(reason);
if (this.activeProvider === "floxy") {
this.sessionExpiry = 0; // force ensureSession → provider switch on next acquire
}
}
/**
* Visit baseUrl to establish/refresh ASP.NET session cookie. Doubles as the
* Floxy health gate for the browser path: only at a session boundary, switch
* the browser's proxy to match pickLaunchProvider() (fail over to DataImpulse
* when Floxy is down, probe Floxy again once the gate clears). A connect
* failure during the probe trips the gate and retries once on DataImpulse.
*/
private async ensureSession(): Promise<void> {
// Only re-evaluate the provider when establishing a fresh session, so an
// in-flight scrape on a working browser is never interrupted mid-flow.
if (Date.now() >= this.sessionExpiry && this.proxyProvider !== "none") {
const desired = this.pickLaunchProvider();
if (this.browser?.isConnected() && desired !== this.activeProvider) {
this.logger.log(`EMEX switching browser proxy: ${this.activeProvider}${desired}`);
await this.relaunchBrowser();
}
}
if (Date.now() < this.sessionExpiry) return;
this.logger.log("Establishing EMEX session...");
if (!this.context) throw new Error("EMEX browser context not initialized");
const ctx = this.context;
const page = await ctx.newPage();
try {
await page.goto(EMEX_BASE_URL, {
waitUntil: "networkidle",
timeout: 30000,
});
// Up to 2 attempts: a Floxy tunnel failure trips the gate, relaunches on
// DataImpulse, and tries once more so the caller still gets a live session.
for (let attempt = 1; attempt <= 2; attempt++) {
if (!this.context) throw new Error("EMEX browser context not initialized");
const ctx = this.context;
const page = await ctx.newPage();
try {
this.logger.log(`Establishing EMEX session (${this.activeProvider})...`);
await page.goto(EMEX_BASE_URL, {
waitUntil: "networkidle",
timeout: 30000,
});
const cookies = await ctx.cookies();
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
if (session) {
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
this.logger.log("Session established, TTL 25 min");
} else {
this.logger.warn("No session cookie found after visiting baseUrl");
// Still set a short TTL to avoid hammering
this.sessionExpiry = Date.now() + 60_000;
if (this.activeProvider === "floxy") this.proxyHealth.reportFloxySuccess();
const cookies = await ctx.cookies();
const session = cookies.find((c) => c.name === "ASP.NET_SessionId");
if (session) {
this.sessionExpiry = Date.now() + SESSION_TTL_MS;
this.logger.log("Session established, TTL 25 min");
} else {
this.logger.warn("No session cookie found after visiting baseUrl");
// Still set a short TTL to avoid hammering
this.sessionExpiry = Date.now() + 60_000;
}
return;
} catch (err) {
await page.close().catch(() => {});
const canFailover =
attempt < 2 &&
this.proxyProvider === "floxy" &&
this.activeProvider === "floxy" &&
isProxyConnectFailure(err);
if (canFailover) {
this.proxyHealth.reportFloxyFailure(`emex session: ${classifyTransportError(err)}`);
await this.relaunchBrowser(); // gate now tripped → relaunch picks DataImpulse
continue;
}
throw err;
} finally {
await page.close().catch(() => {});
}
} finally {
await page.close();
}
}

View File

@@ -19,6 +19,11 @@ const browser = {} as never;
const redis = { set: vi.fn() } as never;
const posthog = {} as never;
const proxyTelemetry = { record: vi.fn() } as never;
const proxyHealth = {
isFloxyDown: vi.fn(() => false),
reportFloxyFailure: vi.fn(),
reportFloxySuccess: vi.fn(),
} as never;
describe("EmexService proxy-port coercion (regression)", () => {
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
@@ -29,7 +34,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_END: "10099",
});
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
expect(
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
).not.toThrow();
});
it("constructs with a single-port range (string env)", () => {
@@ -38,7 +45,9 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "823",
EMEX_PROXY_PORT_END: "823",
});
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
expect(
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
).not.toThrow();
});
it("falls back to a valid default when the port env is garbage", () => {
@@ -47,11 +56,15 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "not-a-number",
EMEX_PROXY_PORT_END: "999999",
});
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
expect(
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
).not.toThrow();
});
it("constructs cleanly with the proxy disabled", () => {
const config = makeConfig({ EMEX_USE_PROXY: "false" });
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
expect(
() => new EmexService(config, browser, redis, posthog, proxyTelemetry, proxyHealth),
).not.toThrow();
});
});

View File

@@ -23,9 +23,11 @@ import { ProxyAgent } from "undici";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import { ProxyHealthService } from "../proxy-telemetry/proxy-health.service";
import {
ProxyTelemetryService,
classifyTransportError,
isProxyConnectFailure,
} from "../proxy-telemetry/proxy-telemetry.service";
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
import { EmexBrowserService } from "./emex.browser";
@@ -147,6 +149,7 @@ export class EmexService {
private redis: RedisService,
private posthog: PostHogService,
private proxyTelemetry: ProxyTelemetryService,
private proxyHealth: ProxyHealthService,
) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
@@ -383,8 +386,14 @@ export class EmexService {
// and a different proxy IP must not "retry" it away.
const schedule: Array<"dataimpulse" | "floxy"> = [];
if (this.emexProxyProvider === "floxy") {
if (this.emexFloxy) schedule.push("floxy", "floxy");
if (this.emexProxy) schedule.push("dataimpulse");
if (this.emexFloxy && this.proxyHealth.isFloxyDown() && this.emexProxy) {
// Floxy gate tripped → DataImpulse-first (rotating ports cover its ~50%
// dead ports), with one Floxy probe last so a recovery is still detected.
schedule.push("dataimpulse", "dataimpulse", "floxy");
} else {
if (this.emexFloxy) schedule.push("floxy", "floxy");
if (this.emexProxy) schedule.push("dataimpulse");
}
} else if (this.emexProxyProvider === "dataimpulse") {
if (this.emexProxy) schedule.push("dataimpulse", "dataimpulse", "dataimpulse");
if (this.emexFloxy) schedule.push("floxy", "floxy");
@@ -417,6 +426,7 @@ export class EmexService {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
}
if (provider === "floxy") {
this.proxyHealth.reportFloxySuccess(); // Floxy answered — clear cooldown
this.logger.log(`EMEX fetch via Floxy fallback succeeded for ${url}`);
}
return await res.text();
@@ -446,6 +456,11 @@ export class EmexService {
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR/i.test(
`${e.message} ${String(e.cause ?? "")}`,
));
// A Floxy connect failure (dead exit / tunnel refused) trips the shared
// gate so EMEX + pcat fail over to DataImpulse for the cooldown window.
if (provider === "floxy" && isProxyConnectFailure(e)) {
this.proxyHealth.reportFloxyFailure(`emex http: ${classifyTransportError(e)}`);
}
if (!transient || attempt === maxAttempts) break;
// A Floxy transport failure means the current sticky exit IP is dead —
// roll to a fresh session so the next Floxy attempt gets a new IP.
@@ -909,6 +924,12 @@ export class EmexService {
} catch (error) {
const err = error as Error;
// Floxy tunnel dead mid-scrape → trip failover so the next acquirePage
// re-establishes the session on DataImpulse.
if (isProxyConnectFailure(err)) {
void this.browserService.tripFloxyFailover(`emex decode: ${classifyTransportError(err)}`);
}
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException ||
@@ -1052,6 +1073,10 @@ export class EmexService {
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
} catch (error) {
const err = error as Error;
// Floxy tunnel dead mid-scrape → trip failover for the next acquirePage.
if (isProxyConnectFailure(err)) {
void this.browserService.tripFloxyFailover(`emex parts: ${classifyTransportError(err)}`);
}
this.logger.error(`Failed to fetch category parts: ${err.message}`);
return { parts: [], schemaImageUrl: null, hotspots: [], schemaWidth: 0, schemaHeight: 0 };
} finally {