SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
8.1 KB · 213 lines typescript
Raw Blame History
1import type { ConcreteNetwork, GeoTarget, NetworkClass, Plan } from "@fetcha/core";2import { PLAN_LIMITS } from "@fetcha/core";3import type { ProviderId, ProviderRegistry, ProxyProvider } from "@fetcha/providers";4import type { CircuitBreaker } from "./circuit";56export type RouteKey = `${ProviderId}:${ConcreteNetwork}`;78export function routeKey(provider: ProviderId, network: ConcreteNetwork): RouteKey {9  return `${provider}:${network}`;10}1112/** Aggregated statistics for a domain, as stored in `domain_profiles.route_stats`. */13export type RouteStats = Record<string, { n: number; ok: number; blocked: number; lat: number; cost: number }>;1415export interface DomainKnowledge {16  domain: string;17  routeStats: RouteStats;18  policy?: { order?: string[]; force_network?: string; browser?: boolean } | null;19  /** Share of requests where the browser was needed after HTTP attempts were blocked. */20  browserRequiredRate?: number;21  /** Number of requests behind `browserRequiredRate`. */22  browserSamples?: number;23}2425export interface RoutingInput {26  domain: string;27  network: NetworkClass;28  geo: GeoTarget;29  plan: Plan;30  sessionRequired: boolean;31  browser: boolean;32  /** Override the max number of attempts (bounded by plan). */33  retries?: number;34  knowledge?: DomainKnowledge | null;35}3637export interface RouteCandidate {38  provider: ProxyProvider;39  network: ConcreteNetwork;40  score: number;41  reasons: string[];42  estimatedCostPerMb: number;43}4445export interface RoutingPlan {46  candidates: RouteCandidate[];47  maxAttempts: number;48  networksConsidered: ConcreteNetwork[];49}5051export const SCORE_WEIGHTS = {52  success: 0.35,53  cost: 0.2,54  latency: 0.15,55  health: 0.15,56  geo: 0.1,57  session: 0.05,58} as const;5960/** Escalation order for `auto`: cheap first, then more expensive/reliable classes. */61const AUTO_NETWORK_ORDER: ConcreteNetwork[] = ["datacenter", "isp", "residential", "mobile"];6263const NETWORK_BASE_SUCCESS: Record<ConcreteNetwork, number> = {64  datacenter: 0.7,65  isp: 0.85,66  residential: 0.93,67  mobile: 0.95,68};6970export class RoutingEngine {71  constructor(72    private readonly registry: ProviderRegistry,73    private readonly circuit: CircuitBreaker,74  ) {}7576  plan(input: RoutingInput): RoutingPlan {77    const limits = PLAN_LIMITS[input.plan];78    const maxAttempts = Math.min((input.retries ?? limits.max_retries) + 1, limits.max_retries + 1);7980    const networks: ConcreteNetwork[] =81      input.network === "auto"82        ? AUTO_NETWORK_ORDER.filter((n) => limits.networks.includes(n))83        : [input.network];8485    const forced = input.knowledge?.policy?.force_network as ConcreteNetwork | undefined;86    const consider = forced && input.network === "auto" ? [forced] : networks;8788    const candidates: RouteCandidate[] = [];89    for (const network of consider) {90      for (const provider of this.registry.available(network)) {91        if (!provider.supportsGeo(input.geo)) continue;92        const key = routeKey(provider.id, network);93        if (!this.circuit.allow(key)) continue;94        candidates.push(this.score(provider, network, input));95      }96    }9798    // Admin-pinned order for the domain wins over score, within the same network class.99    const pinned = input.knowledge?.policy?.order;100    if (pinned?.length) {101      const rank = new Map(pinned.map((k, i) => [k, i]));102      candidates.sort((a, b) => {103        const ra = rank.get(routeKey(a.provider.id, a.network)) ?? 999;104        const rb = rank.get(routeKey(b.provider.id, b.network)) ?? 999;105        return ra - rb || b.score - a.score;106      });107    } else if (input.network === "auto") {108      // Auto: escalate by class order, but inside a class order by score. If a domain has109      // strong evidence a cheaper class is blocked (>60% block rate), skip it entirely.110      const classRank = new Map(AUTO_NETWORK_ORDER.map((n, i) => [n, i]));111      const filtered = candidates.filter((c) => {112        const st = input.knowledge?.routeStats?.[routeKey(c.provider.id, c.network)];113        if (st && st.n >= 5 && st.blocked / st.n > 0.6 && c.network !== "residential") return false;114        return true;115      });116      const pool = filtered.length ? filtered : candidates;117      pool.sort((a, b) => (classRank.get(a.network)! - classRank.get(b.network)!) || b.score - a.score);118      return { candidates: dedupeProviders(pool, maxAttempts), maxAttempts, networksConsidered: consider };119    } else {120      candidates.sort((a, b) => b.score - a.score);121    }122    return { candidates: candidates.slice(0, Math.max(maxAttempts, 1)), maxAttempts, networksConsidered: consider };123  }124125  score(provider: ProxyProvider, network: ConcreteNetwork, input: RoutingInput): RouteCandidate {126    const key = routeKey(provider.id, network);127    const stats = input.knowledge?.routeStats?.[key];128    const reasons: string[] = [];129130    // 35% historical success (Bayesian-smoothed toward the network prior)131    const prior = NETWORK_BASE_SUCCESS[network];132    const n = stats?.n ?? 0;133    const ok = stats?.ok ?? 0;134    const success = (ok + prior * 5) / (n + 5);135    reasons.push(`success=${(success * 100).toFixed(0)}%${n ? ` (n=${n})` : " (prior)"}`);136137    // 20% cost efficiency: normalize against the most expensive class (~$15/GB)138    const pricePerGb = provider.pricePerGb(network);139    const cost = Math.max(0, 1 - pricePerGb / 15);140    reasons.push(`price=$${pricePerGb}/GB`);141142    // 15% latency: 300 ms → 1.0, 5 s → 0143    const lat = stats?.n ? stats.lat : network === "datacenter" ? 500 : 1200;144    const latency = Math.max(0, Math.min(1, 1 - (lat - 300) / 4700));145    reasons.push(`latency≈${Math.round(lat)}ms`);146147    // 15% provider health (circuit breaker window)148    const health = this.circuit.healthFactor(key);149    reasons.push(`health=${(health * 100).toFixed(0)}%`);150151    // 10% geography match152    const geo = input.geo.country ? (provider.supportsGeo(input.geo) ? 1 : 0) : 1;153154    // 5% session stability (all upstream residential networks support sticky sessions; direct does not)155    const session = input.sessionRequired ? (provider.id === "direct" ? 0 : 1) : 1;156157    const total =158      SCORE_WEIGHTS.success * success +159      SCORE_WEIGHTS.cost * cost +160      SCORE_WEIGHTS.latency * latency +161      SCORE_WEIGHTS.health * health +162      SCORE_WEIGHTS.geo * geo +163      SCORE_WEIGHTS.session * session;164165    return { provider, network, score: Math.round(total * 1000) / 1000, reasons, estimatedCostPerMb: pricePerGb / 1024 };166  }167}168169/** Keep escalation diversity: alternate providers before repeating the same one. */170function dedupeProviders(sorted: RouteCandidate[], max: number): RouteCandidate[] {171  const out: RouteCandidate[] = [];172  const seen = new Set<string>();173  for (const c of sorted) {174    const k = routeKey(c.provider.id, c.network);175    if (seen.has(k)) continue;176    seen.add(k);177    out.push(c);178    if (out.length >= max) break;179  }180  // Fewer distinct routes than the attempt budget: round-robin over them (each retry gets a new exit IP).181  const distinct = out.length;182  while (distinct > 0 && out.length < max) out.push(out[out.length % distinct]!);183  return out;184}185186/** Update aggregated route stats after an attempt (pure helper used by the persistence layer). */187export function foldRouteStat(stats: RouteStats, key: string, outcome: { ok: boolean; blocked: boolean; latencyMs: number; costUsd: number }): RouteStats {188  const cur = stats[key] ?? { n: 0, ok: 0, blocked: 0, lat: 0, cost: 0 };189  const n = cur.n + 1;190  stats[key] = {191    n,192    ok: cur.ok + (outcome.ok ? 1 : 0),193    blocked: cur.blocked + (outcome.blocked ? 1 : 0),194    lat: cur.lat + (outcome.latencyMs - cur.lat) / n,195    cost: cur.cost + outcome.costUsd,196  };197  return stats;198}199200export function preferredRoute(stats: RouteStats): { provider: ProviderId; network: ConcreteNetwork } | null {201  let best: { key: string; v: number } | null = null;202  for (const [key, s] of Object.entries(stats)) {203    if (s.n < 3) continue;204    const successRate = s.ok / s.n;205    const costPerSuccess = s.ok ? s.cost / s.ok : Infinity;206    const v = successRate - Math.min(costPerSuccess * 100, 0.5);207    if (!best || v > best.v) best = { key, v };208  }209  if (!best) return null;210  const [provider, network] = best.key.split(":") as [ProviderId, ConcreteNetwork];211  return { provider, network };212}213