import type { ConcreteNetwork, GeoTarget, NetworkClass, Plan } from "@fetcha/core"; import { PLAN_LIMITS } from "@fetcha/core"; import type { ProviderId, ProviderRegistry, ProxyProvider } from "@fetcha/providers"; import type { CircuitBreaker } from "./circuit"; export type RouteKey = `${ProviderId}:${ConcreteNetwork}`; export function routeKey(provider: ProviderId, network: ConcreteNetwork): RouteKey { return `${provider}:${network}`; } /** Aggregated statistics for a domain, as stored in `domain_profiles.route_stats`. */ export type RouteStats = Record; export interface DomainKnowledge { domain: string; routeStats: RouteStats; policy?: { order?: string[]; force_network?: string; browser?: boolean } | null; /** Share of requests where the browser was needed after HTTP attempts were blocked. */ browserRequiredRate?: number; /** Number of requests behind `browserRequiredRate`. */ browserSamples?: number; } export interface RoutingInput { domain: string; network: NetworkClass; geo: GeoTarget; plan: Plan; sessionRequired: boolean; browser: boolean; /** Override the max number of attempts (bounded by plan). */ retries?: number; knowledge?: DomainKnowledge | null; } export interface RouteCandidate { provider: ProxyProvider; network: ConcreteNetwork; score: number; reasons: string[]; estimatedCostPerMb: number; } export interface RoutingPlan { candidates: RouteCandidate[]; maxAttempts: number; networksConsidered: ConcreteNetwork[]; } export const SCORE_WEIGHTS = { success: 0.35, cost: 0.2, latency: 0.15, health: 0.15, geo: 0.1, session: 0.05, } as const; /** Escalation order for `auto`: cheap first, then more expensive/reliable classes. */ const AUTO_NETWORK_ORDER: ConcreteNetwork[] = ["datacenter", "isp", "residential", "mobile"]; const NETWORK_BASE_SUCCESS: Record = { datacenter: 0.7, isp: 0.85, residential: 0.93, mobile: 0.95, }; export class RoutingEngine { constructor( private readonly registry: ProviderRegistry, private readonly circuit: CircuitBreaker, ) {} plan(input: RoutingInput): RoutingPlan { const limits = PLAN_LIMITS[input.plan]; const maxAttempts = Math.min((input.retries ?? limits.max_retries) + 1, limits.max_retries + 1); const networks: ConcreteNetwork[] = input.network === "auto" ? AUTO_NETWORK_ORDER.filter((n) => limits.networks.includes(n)) : [input.network]; const forced = input.knowledge?.policy?.force_network as ConcreteNetwork | undefined; const consider = forced && input.network === "auto" ? [forced] : networks; const candidates: RouteCandidate[] = []; for (const network of consider) { for (const provider of this.registry.available(network)) { if (!provider.supportsGeo(input.geo)) continue; const key = routeKey(provider.id, network); if (!this.circuit.allow(key)) continue; candidates.push(this.score(provider, network, input)); } } // Admin-pinned order for the domain wins over score, within the same network class. const pinned = input.knowledge?.policy?.order; if (pinned?.length) { const rank = new Map(pinned.map((k, i) => [k, i])); candidates.sort((a, b) => { const ra = rank.get(routeKey(a.provider.id, a.network)) ?? 999; const rb = rank.get(routeKey(b.provider.id, b.network)) ?? 999; return ra - rb || b.score - a.score; }); } else if (input.network === "auto") { // Auto: escalate by class order, but inside a class order by score. If a domain has // strong evidence a cheaper class is blocked (>60% block rate), skip it entirely. const classRank = new Map(AUTO_NETWORK_ORDER.map((n, i) => [n, i])); const filtered = candidates.filter((c) => { const st = input.knowledge?.routeStats?.[routeKey(c.provider.id, c.network)]; if (st && st.n >= 5 && st.blocked / st.n > 0.6 && c.network !== "residential") return false; return true; }); const pool = filtered.length ? filtered : candidates; pool.sort((a, b) => (classRank.get(a.network)! - classRank.get(b.network)!) || b.score - a.score); return { candidates: dedupeProviders(pool, maxAttempts), maxAttempts, networksConsidered: consider }; } else { candidates.sort((a, b) => b.score - a.score); } return { candidates: candidates.slice(0, Math.max(maxAttempts, 1)), maxAttempts, networksConsidered: consider }; } score(provider: ProxyProvider, network: ConcreteNetwork, input: RoutingInput): RouteCandidate { const key = routeKey(provider.id, network); const stats = input.knowledge?.routeStats?.[key]; const reasons: string[] = []; // 35% historical success (Bayesian-smoothed toward the network prior) const prior = NETWORK_BASE_SUCCESS[network]; const n = stats?.n ?? 0; const ok = stats?.ok ?? 0; const success = (ok + prior * 5) / (n + 5); reasons.push(`success=${(success * 100).toFixed(0)}%${n ? ` (n=${n})` : " (prior)"}`); // 20% cost efficiency: normalize against the most expensive class (~$15/GB) const pricePerGb = provider.pricePerGb(network); const cost = Math.max(0, 1 - pricePerGb / 15); reasons.push(`price=$${pricePerGb}/GB`); // 15% latency: 300 ms → 1.0, 5 s → 0 const lat = stats?.n ? stats.lat : network === "datacenter" ? 500 : 1200; const latency = Math.max(0, Math.min(1, 1 - (lat - 300) / 4700)); reasons.push(`latency≈${Math.round(lat)}ms`); // 15% provider health (circuit breaker window) const health = this.circuit.healthFactor(key); reasons.push(`health=${(health * 100).toFixed(0)}%`); // 10% geography match const geo = input.geo.country ? (provider.supportsGeo(input.geo) ? 1 : 0) : 1; // 5% session stability (all upstream residential networks support sticky sessions; direct does not) const session = input.sessionRequired ? (provider.id === "direct" ? 0 : 1) : 1; const total = SCORE_WEIGHTS.success * success + SCORE_WEIGHTS.cost * cost + SCORE_WEIGHTS.latency * latency + SCORE_WEIGHTS.health * health + SCORE_WEIGHTS.geo * geo + SCORE_WEIGHTS.session * session; return { provider, network, score: Math.round(total * 1000) / 1000, reasons, estimatedCostPerMb: pricePerGb / 1024 }; } } /** Keep escalation diversity: alternate providers before repeating the same one. */ function dedupeProviders(sorted: RouteCandidate[], max: number): RouteCandidate[] { const out: RouteCandidate[] = []; const seen = new Set(); for (const c of sorted) { const k = routeKey(c.provider.id, c.network); if (seen.has(k)) continue; seen.add(k); out.push(c); if (out.length >= max) break; } // Fewer distinct routes than the attempt budget: round-robin over them (each retry gets a new exit IP). const distinct = out.length; while (distinct > 0 && out.length < max) out.push(out[out.length % distinct]!); return out; } /** Update aggregated route stats after an attempt (pure helper used by the persistence layer). */ export function foldRouteStat(stats: RouteStats, key: string, outcome: { ok: boolean; blocked: boolean; latencyMs: number; costUsd: number }): RouteStats { const cur = stats[key] ?? { n: 0, ok: 0, blocked: 0, lat: 0, cost: 0 }; const n = cur.n + 1; stats[key] = { n, ok: cur.ok + (outcome.ok ? 1 : 0), blocked: cur.blocked + (outcome.blocked ? 1 : 0), lat: cur.lat + (outcome.latencyMs - cur.lat) / n, cost: cur.cost + outcome.costUsd, }; return stats; } export function preferredRoute(stats: RouteStats): { provider: ProviderId; network: ConcreteNetwork } | null { let best: { key: string; v: number } | null = null; for (const [key, s] of Object.entries(stats)) { if (s.n < 3) continue; const successRate = s.ok / s.n; const costPerSuccess = s.ok ? s.cost / s.ok : Infinity; const v = successRate - Math.min(costPerSuccess * 100, 0.5); if (!best || v > best.v) best = { key, v }; } if (!best) return null; const [provider, network] = best.key.split(":") as [ProviderId, ConcreteNetwork]; return { provider, network }; }