import { FetchaError, assertUrlAllowed, extractDomain, extractPageMetadata, htmlToMainText, htmlToMarkdown, htmlToText, isTransientStatus, looksBlocked, newId, normalizeGeo, scrubProviderText, type BlockVerdict, type ConcreteNetwork, type FetchRequest, type FetchResponseBody, type FetchTiming, type PageLink, type PageMetadata, type Plan, } from "@fetcha/core"; import { CookieJar, ProviderError, pickProfile, retryReferer, type FingerprintProfile, type ProviderId, type ProviderResponse } from "@fetcha/providers"; import type { BrowserPool, RenderResult } from "@fetcha/browser"; import type { CircuitBreaker } from "./circuit"; import { routeKey, type DomainKnowledge, type RouteCandidate, type RoutingEngine } from "./engine"; export type AttemptOutcome = "success" | "blocked" | "timeout" | "error" | "provider_error" | "too_large"; export type AttemptMode = "http" | "browser"; /** Customer-facing aliases: upstream names are never revealed without provider visibility. */ export const PUBLIC_PROVIDER_ALIAS: Record = { oxylabs: "network-a", decodo: "network-b", soax: "network-c", direct: "fetcha-edge", }; export interface AttemptRecord { attemptId: string; attemptNo: number; provider: ProviderId; network: ConcreteNetwork; mode: AttemptMode; country: string | null; sessionKey: string | null; outcome: AttemptOutcome; httpStatus: number | null; errorCode: string | null; errorDetail: string | null; blockReason: string | null; blockVendor: string | null; profileId: string | null; /** A captcha solver token was purchased and the attempt passed thanks to it. */ captchaSolved: boolean; durationMs: number; bytesIn: number; bytesOut: number; unitPricePerGb: number; costUsd: number; routingScore: number; timing: FetchTiming | null; } export type SerializedCookie = { name: string; value: string; domain: string; path: string; secure: boolean; expires: number | null }; export interface ExecutionContext { requestId: string; plan: Plan; request: FetchRequest; /** Provider-agnostic sticky key resolved from a Fetcha session (if any). */ sessionKey?: string | null; sessionProvider?: ProviderId | null; sessionNetwork?: ConcreteNetwork | null; /** Cookies persisted on the session from earlier requests. */ sessionCookies?: SerializedCookie[] | null; knowledge?: DomainKnowledge | null; /** Called after every attempt (success or failure) for persistence & metrics. */ onAttempt?: (a: AttemptRecord) => Promise | void; /** Whether to include provider names in the debug payload. */ providerVisibility?: boolean; maxResponseBytes?: number; /** Managed browser pool (null/undefined = browser mode unavailable). */ browserPool?: BrowserPool | null; /** Whether the plan allows browser rendering. */ browserAllowed?: boolean; } export interface ExecutionResult { body: FetchResponseBody; attempts: AttemptRecord[]; network: ConcreteNetwork | null; provider: ProviderId | null; mode: AttemptMode; costUsd: number; bytesIn: number; bytesOut: number; finalUrl: string; domain: string; /** Cookie jar state at the end of the request (for sticky-session persistence). */ cookies: SerializedCookie[]; /** True when the browser was needed after HTTP attempts were blocked (feeds domain intelligence). */ browserRequired: boolean; /** Raw response for optional storage. */ raw?: ProviderResponse; } interface FinalResponse { status: number; headers: Record; body: Buffer; finalUrl: string; timing: FetchTiming; screenshot?: Buffer; } const HTML_CT = /text\/html|application\/xhtml/i; export class FetchExecutor { constructor( private readonly engine: RoutingEngine, private readonly circuit: CircuitBreaker, ) {} async execute(ctx: ExecutionContext): Promise { const { request, requestId } = ctx; const started = performance.now(); const domain = extractDomain(request.url); const browserAvailable = Boolean(ctx.browserPool?.enabled) && ctx.browserAllowed !== false; if (request.browser && !browserAvailable) { throw new FetchaError("BROWSER_UNAVAILABLE", undefined, { requestId }); } const allowed = await assertUrlAllowed(request.url); const geo = normalizeGeo({ country: request.country, region: request.region, city: request.city }); const plan = this.engine.plan({ domain, network: request.network, geo, plan: ctx.plan, sessionRequired: Boolean(request.session), browser: request.browser, retries: request.retries, knowledge: ctx.knowledge ?? null, }); // A sticky session pins provider + network for its lifetime. let candidates: RouteCandidate[] = plan.candidates; if (ctx.sessionKey && ctx.sessionProvider) { const pinned = candidates.filter((c) => c.provider.id === ctx.sessionProvider && (!ctx.sessionNetwork || c.network === ctx.sessionNetwork)); if (pinned.length) candidates = [pinned[0]!, ...candidates.filter((c) => c !== pinned[0])]; } if (!candidates.length) { if (request.network !== "auto") { throw new FetchaError("NETWORK_UNAVAILABLE", `The "${request.network}" network class is not available right now.`, { requestId }); } throw new FetchaError("PROVIDER_UNAVAILABLE", undefined, { requestId }); } // Domain intelligence: sites that consistently need the browser go straight there. const knowledgeSaysBrowser = Boolean(ctx.knowledge && (ctx.knowledge.browserRequiredRate ?? 0) >= 0.5 && (ctx.knowledge.browserSamples ?? 0) >= 3); let browserMode = request.browser || (browserAvailable && request.browser_fallback && knowledgeSaysBrowser); let browserEscalated = false; const attempts: AttemptRecord[] = []; let lastError: FetchaError | null = null; let lastBlocked: { res: FinalResponse; cand: RouteCandidate; mode: AttemptMode; verdict: BlockVerdict } | null = null; let lastTransient: { res: FinalResponse; cand: RouteCandidate; mode: AttemptMode } | null = null; const maxBytes = ctx.maxResponseBytes ?? request.max_response_bytes ?? 20_000_000; const deadline = started + request.timeout; const usedProfiles: string[] = []; const jar = CookieJar.fromSerialized(ctx.sessionCookies ?? null, allowed.hostname); if (request.cookies) for (const [k, v] of Object.entries(request.cookies)) jar.store(`${k}=${v}; Path=/`, allowed.url); let browserRequired = false; // The browser fallback is one extra attempt on top of the plan's HTTP budget. const maxAttempts = plan.maxAttempts + (browserAvailable && request.browser_fallback && !request.browser ? 1 : 0); for (let i = 0; i < maxAttempts; i++) { const cand = candidates[Math.min(i, candidates.length - 1)]!; const remaining = deadline - performance.now(); if (remaining < 800) { lastError = new FetchaError("TARGET_TIMEOUT", undefined, { requestId }); break; } if (i > 0) await this.backoff(i, lastBlocked?.verdict.retryAfterMs, deadline); const attemptId = newId("att"); const key = routeKey(cand.provider.id, cand.network); const t0 = performance.now(); const sessionKey = ctx.sessionKey ?? null; const profile: FingerprintProfile = pickProfile({ device: request.device ?? null, seed: sessionKey, attempt: i, exclude: sessionKey ? [] : usedProfiles }); usedProfiles.push(profile.id); const headers = { ...(request.headers ?? {}) }; if (request.device === "mobile" && headers["user-agent"]) delete headers["user-agent"]; // profile already mobile const referer = request.referer === "none" ? null : request.referer === "auto" ? (i === 0 && !browserEscalated ? null : retryReferer(i, geo.country)) : request.referer; const body = request.body === undefined ? undefined : typeof request.body === "string" ? request.body : JSON.stringify(request.body); const mode: AttemptMode = browserMode ? "browser" : "http"; const timeoutMs = Math.max(1000, Math.min(deadline - performance.now(), request.timeout)); let res: FinalResponse | null = null; let verdict: BlockVerdict = { blocked: false }; let captchaOutcome: RenderResult["captcha"] = null; let record: AttemptRecord; try { let bytesIn = 0; let bytesOut = 0; if (mode === "browser") { const render: RenderResult = await ctx.browserPool!.render({ url: allowed.url.toString(), method: request.method, body, proxy: cand.provider.proxyEndpoint({ geo, sessionKey, sessionMinutes: 10 }), profile, locale: request.locale ?? null, country: geo.country, headers, cookies: jar.serialize(), referer, timeoutMs, waitUntil: request.wait_until, waitFor: request.wait_for ?? null, waitMs: request.wait_ms ?? null, javascript: request.javascript !== false, // A site that already blocked us (or is known to need the browser) gets every asset: anti-bot // verification checks that images/fonts really load. blockResources: request.block_resources && !browserEscalated && !knowledgeSaysBrowser, screenshot: request.screenshot, solveCaptcha: request.solve_captcha, maxResponseBytes: maxBytes, onRedirect: async (next) => { await assertUrlAllowed(next); }, }); for (const c of render.cookies) jar.store(`${c.name}=${c.value}; Domain=${c.domain}; Path=${c.path}${c.secure ? "; Secure" : ""}`, new URL(render.finalUrl)); bytesIn = render.bytesIn; bytesOut = render.bytesOut; res = { status: render.status, headers: render.headers, body: render.body, finalUrl: render.finalUrl, screenshot: render.screenshot, timing: { dns_ms: allowed.dns_ms, proxy_connect_ms: 0, tls_ms: 0, origin_ms: render.timing.navigation_ms, processing_ms: render.timing.challenge_ms + render.timing.settle_ms + render.timing.capture_ms, total_ms: render.timing.total_ms }, }; verdict = render.block; if (render.challengeSolved) browserRequired = true; captchaOutcome = render.captcha; } else { const pr = await cand.provider.fetch({ requestId, attemptId, url: allowed.url.toString(), method: request.method, headers, body, timeoutMs, network: cand.network, geo, sessionKey, sessionMinutes: 10, followRedirects: request.follow_redirects, maxRedirects: request.max_redirects, maxResponseBytes: maxBytes, profile, locale: request.locale ?? null, referer, jar, onRedirect: async (next) => { await assertUrlAllowed(next); }, }); bytesIn = pr.bytesIn; bytesOut = pr.bytesOut; res = { status: pr.status, headers: pr.headers, body: pr.body, finalUrl: pr.finalUrl, timing: { ...pr.timing, dns_ms: allowed.dns_ms } }; const ct = pr.headers["content-type"] ?? ""; verdict = looksBlocked(pr.status, !ct || HTML_CT.test(ct) || /json|xml|text/i.test(ct) ? pr.body.subarray(0, 40_000).toString("utf8") : "", pr.headers); } const durationMs = Math.round(performance.now() - t0); const transient = !verdict.blocked && isTransientStatus(res.status); const cost = cand.provider.estimateCost(cand.network, bytesIn + bytesOut) + (captchaOutcome?.costUsd ?? 0); record = { attemptId, attemptNo: i + 1, provider: cand.provider.id, network: cand.network, mode, country: geo.country, sessionKey, outcome: verdict.blocked ? "blocked" : transient ? "error" : "success", httpStatus: res.status, errorCode: verdict.blocked ? "TARGET_BLOCKED" : transient ? "TARGET_UNAVAILABLE" : null, errorDetail: null, blockReason: verdict.reason ?? null, blockVendor: verdict.vendor ?? null, profileId: profile.id, captchaSolved: Boolean(captchaOutcome?.solved), durationMs, bytesIn, bytesOut, unitPricePerGb: cand.provider.pricePerGb(cand.network), costUsd: cost, routingScore: cand.score, timing: res.timing, }; attempts.push(record); await ctx.onAttempt?.(record); // Provider health: only proxy-side failures count against the route. this.circuit.record(key, !(verdict.blocked && verdict.reason === "http_407")); if (!verdict.blocked && !transient) { if (mode === "browser" && browserEscalated) browserRequired = true; return this.finish(ctx, res, cand, mode, attempts, started, domain, jar, browserRequired); } if (transient) { lastTransient = { res, cand, mode }; continue; } lastBlocked = { res, cand, mode, verdict }; // Escalate to the browser after an HTTP block that a real browser can typically pass. if (mode === "http" && browserAvailable && request.browser_fallback && (verdict.challenge || i >= plan.maxAttempts - 1 || knowledgeSaysBrowser)) { browserMode = true; browserEscalated = true; } continue; } catch (e) { const durationMs = Math.round(performance.now() - t0); if (e instanceof FetchaError) { if (e.code === "URL_NOT_ALLOWED" || e.code === "RESPONSE_TOO_LARGE" || e.code === "TOO_MANY_REDIRECTS" || e.code === "INVALID_REQUEST") { // Policy violation or non-retryable: do not retry. record = this.errorRecord(attemptId, i, cand, mode, profile.id, geo.country, sessionKey, "error", e.code, e.message, durationMs); attempts.push(record); await ctx.onAttempt?.(record); throw e; } // Browser-side failure (timeout / unavailable): record and let the loop continue (HTTP or another route). const outcome: AttemptOutcome = e.code === "BROWSER_TIMEOUT" || e.code === "TARGET_TIMEOUT" ? "timeout" : e.code === "BROWSER_UNAVAILABLE" ? "provider_error" : "error"; record = this.errorRecord(attemptId, i, cand, mode, profile.id, geo.country, sessionKey, outcome, e.code, e.message, durationMs); attempts.push(record); await ctx.onAttempt?.(record); lastError = new FetchaError(e.code, e.message, { requestId }); if (e.code === "BROWSER_UNAVAILABLE" && !request.browser) browserMode = false; // fall back to HTTP for the remaining budget if (request.browser && e.code === "BROWSER_UNAVAILABLE") throw lastError; continue; } const pe = e instanceof ProviderError ? e : new ProviderError(cand.provider.id, "unknown", (e as Error).message ?? String(e), { cause: e }); let outcome: AttemptOutcome = "provider_error"; let code = "PROVIDER_UNAVAILABLE"; if (pe.kind === "timeout") { outcome = "timeout"; code = "TARGET_TIMEOUT"; } else if (pe.kind === "too_large") { outcome = "too_large"; code = "RESPONSE_TOO_LARGE"; } else if (pe.kind === "redirect") { outcome = "error"; code = "TOO_MANY_REDIRECTS"; } else if (pe.kind === "connect" || pe.kind === "tls") { outcome = "error"; code = "TARGET_UNAVAILABLE"; } record = this.errorRecord(attemptId, i, cand, mode, profile.id, geo.country, sessionKey, outcome, code, pe.message, durationMs); attempts.push(record); await ctx.onAttempt?.(record); // Provider-side failures count against the circuit; target-side ones don't. this.circuit.record(key, !(pe.kind === "auth" || pe.kind === "proxy")); lastError = new FetchaError(code as FetchaError["code"], undefined, { requestId }); if (pe.kind === "too_large" || pe.kind === "redirect") throw lastError; // A TLS failure through a proxy is often the route, not the site: escalate to the browser when we can. if (pe.kind === "tls" && browserAvailable && request.browser_fallback && !browserMode) { browserMode = true; browserEscalated = true; } continue; } } // Exhausted: if the last route returned a blocked page, surface that page (customer still // gets the status) but mark success=false with TARGET_BLOCKED. if (lastBlocked) { const result = this.finish(ctx, lastBlocked.res, lastBlocked.cand, lastBlocked.mode, attempts, started, domain, jar, browserRequired); result.body.success = false; return result; } if (lastTransient) { const result = this.finish(ctx, lastTransient.res, lastTransient.cand, lastTransient.mode, attempts, started, domain, jar, browserRequired); result.body.success = false; return result; } throw lastError ?? new FetchaError("TARGET_UNAVAILABLE", undefined, { requestId }); } /** Jittered backoff between attempts; honours short Retry-After hints. */ private async backoff(attempt: number, retryAfterMs: number | undefined, deadline: number): Promise { let wait = 250 + Math.random() * 650 + Math.min(attempt, 3) * 200; if (retryAfterMs && retryAfterMs > 0) wait = Math.max(wait, Math.min(retryAfterMs, 6000)); wait = Math.min(wait, Math.max(0, deadline - performance.now() - 1500)); if (wait > 0) await new Promise((r) => setTimeout(r, wait)); } private errorRecord( attemptId: string, i: number, cand: RouteCandidate, mode: AttemptMode, profileId: string, country: string | null, sessionKey: string | null, outcome: AttemptOutcome, code: string, detail: string, durationMs: number, ): AttemptRecord { return { attemptId, attemptNo: i + 1, provider: cand.provider.id, network: cand.network, mode, country, sessionKey, outcome, httpStatus: null, errorCode: code, errorDetail: detail.slice(0, 500), blockReason: null, blockVendor: null, profileId, captchaSolved: false, durationMs, bytesIn: 0, bytesOut: 0, unitPricePerGb: cand.provider.pricePerGb(cand.network), costUsd: 0, routingScore: cand.score, timing: null, }; } private finish(ctx: ExecutionContext, res: FinalResponse, cand: RouteCandidate, mode: AttemptMode, attempts: AttemptRecord[], started: number, domain: string, jar: CookieJar, browserRequired: boolean): ExecutionResult { const { request, requestId } = ctx; const contentType = res.headers["content-type"] ?? null; const isText = !contentType || /text\/|json|xml|javascript|x-www-form-urlencoded/i.test(contentType); const isHtml = isText && (!contentType || HTML_CT.test(contentType) || (/^\s*<(!doctype|html)/i.test(res.body.subarray(0, 200).toString("utf8")) && !/json|xml|javascript/i.test(contentType ?? ""))); const content = isText ? res.body.toString("utf8") : res.body.toString("base64"); const tProc = performance.now(); let text: string | null | undefined; let markdown: string | null | undefined; let json: unknown; let page: PageMetadata | null = null; let links: PageLink[] | undefined; if (isHtml) { try { const meta = extractPageMetadata(content, res.finalUrl); page = meta.page; if (request.links) links = meta.links; } catch { page = null; } } if (request.format === "text") text = isText ? (isHtml ? htmlToMainText(content) || htmlToText(content) : content) : null; if (request.format === "markdown") markdown = isText ? (isHtml ? htmlToMarkdown(content, { baseUrl: res.finalUrl }) : content) : null; if (request.format === "json") { try { json = JSON.parse(content); } catch { json = undefined; } } const durationMs = Math.round(performance.now() - started); const bytesIn = attempts.reduce((s, a) => s + a.bytesIn, 0); const bytesOut = attempts.reduce((s, a) => s + a.bytesOut, 0); const costUsd = attempts.reduce((s, a) => s + a.costUsd, 0); const timing: FetchTiming = { ...res.timing, processing_ms: res.timing.processing_ms + Math.round(performance.now() - tProc), total_ms: durationMs, }; const body: FetchResponseBody = { request_id: requestId, success: res.status >= 200 && res.status < 400, status: res.status, url: request.url, final_url: res.finalUrl, content: request.format === "raw" || request.format === "html" || request.format === "json" ? content : null, content_type: contentType, headers: res.headers, cookies: jar.publicList(), page, metadata: { network: cand.network, country: attempts.at(-1)?.country ?? null, mode, attempts: attempts.length, duration_ms: durationMs, bytes: bytesIn + bytesOut, session: request.session ?? null, cached: false, timing, }, }; if (request.format === "text") body.text = text ?? null; if (request.format === "markdown") body.markdown = markdown ?? null; if (request.format === "json") body.json = json; if (links) body.links = links; if (request.screenshot && mode === "browser") body.screenshot = res.screenshot ? res.screenshot.toString("base64") : null; if (request.debug) { body.metadata.debug = { attempts: attempts.map((a) => ({ provider: ctx.providerVisibility ? a.provider : PUBLIC_PROVIDER_ALIAS[a.provider], network: a.network, mode: a.mode, country: a.country, outcome: a.outcome, block_reason: a.blockReason, ...(a.captchaSolved ? { captcha_solved: true } : {}), status: a.httpStatus, duration_ms: a.durationMs, ...(a.errorDetail && ctx.providerVisibility ? { error: scrubProviderText(a.errorDetail) } : {}), })), }; } return { body, attempts, network: cand.network, provider: cand.provider.id, mode, costUsd, bytesIn, bytesOut, finalUrl: res.finalUrl, domain, cookies: jar.serialize(), browserRequired, raw: { status: res.status, headers: res.headers, body: res.body, finalUrl: res.finalUrl, redirects: 0, bytesIn, bytesOut, timing: res.timing }, }; } } export function parseSetCookies(raw: string | undefined): Array<{ name: string; value: string; domain?: string; path?: string }> { if (!raw) return []; // Header was flattened with ", " — split conservatively on ", =" boundaries. const parts = raw.split(/,(?=\s*[A-Za-z0-9_\-!#$%&'*+.^`|~]+=)/); const out: Array<{ name: string; value: string; domain?: string; path?: string }> = []; for (const part of parts) { const segs = part.split(";").map((s) => s.trim()); const [nv, ...attrs] = segs; if (!nv) continue; const eq = nv.indexOf("="); if (eq <= 0) continue; const c: { name: string; value: string; domain?: string; path?: string } = { name: nv.slice(0, eq), value: nv.slice(eq + 1) }; for (const a of attrs) { const [k, v] = a.split("="); if (!k) continue; if (k.toLowerCase() === "domain" && v) c.domain = v; if (k.toLowerCase() === "path" && v) c.path = v; } out.push(c); } return out; }