CoolFace
Apppublic

apodex/frontier-agent-demo

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
14likes
_scrape_cache.py278 linesDownload Raw Back to tools
1"""In-process negative cache for web scrape URLs."""2 3from __future__ import annotations4 5import asyncio6import os7import threading8import time9from collections.abc import Awaitable, Callable10from dataclasses import dataclass, field11 12from frontier_agent.infra.usage_meter import record_api_request13 14# ── Tunables ──────────────────────────────────────────────────────────────15 16# Ban durations per status (seconds).17_BAN_403 = 3600           # 1 hour — matches Jina's rolling window.18_BAN_422 = 1800           # 30 min — paywall/SPA/empty content.19_BAN_429 = 300            # 5 min — transient rate limit.20 21# Minimum consecutive failures before a 422 results in a ban.22# 403 and 429 ban on the first occurrence.23_MIN_FAILS_422 = 224 25# Status codes we track. Everything else is not cached.26_TRACKED_STATUSES = {403, 422, 429}27 28 29@dataclass30class _Entry:31    status: int32    fail_count: int = 033    ban_until: float = 0.0      # unix time; 0 means not banned34    last_failed_at: float = field(default_factory=time.time)35 36 37class _ScrapeCache:38    """Thread-safe negative cache for scrape URLs."""39 40    def __init__(self) -> None:41        self._data: dict[str, _Entry] = {}42        self._lock = threading.Lock()43 44    def check(self, url: str, now: float | None = None) -> _Entry | None:45        """Return active entry if URL is currently banned, else None."""46        if not url:47            return None48        t = now if now is not None else time.time()49        with self._lock:50            entry = self._data.get(url)51            if entry is None:52                return None53            if entry.ban_until > t:54                return entry55            # Expired — drop it so future failures start fresh.56            if entry.ban_until and entry.ban_until <= t:57                self._data.pop(url, None)58            return None59 60    def record_failure(self, url: str, status: int, now: float | None = None) -> _Entry | None:61        """Record a failure; set ban_until if rules trigger. Returns updated entry."""62        if not url or status not in _TRACKED_STATUSES:63            return None64        t = now if now is not None else time.time()65        with self._lock:66            entry = self._data.get(url)67            if entry is None or entry.status != status:68                # Reset counter if status changed (e.g., 429 → 403).69                entry = _Entry(status=status, fail_count=0, last_failed_at=t)70            entry.fail_count += 171            entry.last_failed_at = t72            entry.status = status73 74            if status == 403:75                entry.ban_until = t + _BAN_40376            elif status == 429:77                entry.ban_until = t + _BAN_42978            elif status == 422 and entry.fail_count >= _MIN_FAILS_422:79                entry.ban_until = t + _BAN_42280 81            self._data[url] = entry82            return entry83 84    def record_success(self, url: str) -> None:85        """Clear any prior failure record for this URL."""86        if not url:87            return88        with self._lock:89            self._data.pop(url, None)90 91    def clear(self) -> None:92        """Drop all entries. Primarily for tests."""93        with self._lock:94            self._data.clear()95 96    def size(self) -> int:97        with self._lock:98            return len(self._data)99 100 101# Module-level singleton. Callers should import this directly.102cache = _ScrapeCache()103 104 105# ── Positive scrape cache (cross-run, single-flight) ───────────────────────106#107# The negative cache above only suppresses re-hammering KNOWN-BAD URLs. This108# positive cache stores SUCCESSFUL scrape content so the same URL fetched by109# many sibling agents costs one Jina round-trip, not N.110#111# Why it pays: sibling agents researching one question converge on the same112# canonical pages, so unique URLs run far below total fetches (typically a113# 3-5x ratio on a fan-out run). Agents dispatched with asyncio.gather share114# one process and event loop, so a module-level singleton reaches all of them115# with zero plumbing.116#117# Scope = SCRAPE ONLY. The per-call SUMMARY_LLM extraction is deliberately NOT118# cached: sharing raw page bytes can't homogenise the runs' reasoning (same URL119# is the same content for everyone), but sharing extraction would leak one120# run's info_to_extract focus into another and couple the otherwise-independent121# trajectories. Successes only — a failed scrape is never stored, and a waiter122# whose leader failed/timed-out falls back to its own fetch, so the cache never123# converts one transient failure into a correlated N-run failure.124 125# Follower wait cap before falling back to an independent scrape. Bounds the126# "leader stalled → everyone blocks" failure mode.127_SINGLE_FLIGHT_TIMEOUT_S = 90.0128# Cap on distinct cached pages (FIFO eviction) — guards memory on long runs.129_MAX_CACHE_ENTRIES = 1024130 131 132def _positive_cache_enabled() -> bool:133    """On by default; set ``WEB_FETCH_SCRAPE_CACHE=0`` (or false/off) to disable."""134    raw = (os.environ.get("WEB_FETCH_SCRAPE_CACHE") or "").strip().lower()135    return raw not in {"0", "false", "off", "no"}136 137 138class ScrapeUnavailable(Exception):139    """Raised by a scrape callable when content could not be obtained.140 141    Signals the cache to NOT store a result and lets waiters fall back to an142    independent fetch. Carries the original error string for the caller's143    user-facing message.144    """145 146 147class ScrapeResultCache:148    """Process-global positive cache for successful scrapes, with single-flight.149 150    ``get_or_scrape(url, scrape_fn)`` returns cached content on a hit; on a miss151    the first caller (the *leader*) runs ``scrape_fn`` while concurrent callers152    for the same URL (*followers*) await its result instead of duplicating the153    fetch. The leader caches only successful, non-empty content.154    """155 156    def __init__(self) -> None:157        self._content: dict[str, str] = {}158        self._inflight: dict[str, asyncio.Future[str]] = {}159        self._lock = asyncio.Lock()160        self.hits = 0161        self.misses = 0162        self.coalesced = 0163 164    async def get_or_scrape(165        self,166        url: str,167        scrape_fn: Callable[[], Awaitable[str]],168        *,169        single_flight_timeout: float = _SINGLE_FLIGHT_TIMEOUT_S,170        should_cache: Callable[[str], bool] | None = None,171    ) -> str:172        """Return cached content for ``url`` or run ``scrape_fn`` to produce it.173 174        ``scrape_fn`` must return the scraped content string on success and175        raise :class:`ScrapeUnavailable` (or any exception) on failure — only176        successful, non-empty returns accepted by ``should_cache`` are cached.177        ``should_cache`` defaults to accepting every non-empty string; callers178        can return a low-confidence result to the current request without179        poisoning later requests with it. Raises whatever ``scrape_fn`` raises180        for the leader; followers fall back to their own ``scrape_fn`` on the181        leader's failure or a wait timeout.182        """183        if not url or not _positive_cache_enabled():184            return await scrape_fn()185 186        async with self._lock:187            if url in self._content:188                self.hits += 1189                # A cache hit is one Jina round-trip saved.190                record_api_request("jina", requests=0, cache_hits=1)191                return self._content[url]192            fut = self._inflight.get(url)193            leader = fut is None194            if leader:195                self.misses += 1196                fut = asyncio.get_event_loop().create_future()197                self._inflight[url] = fut198            else:199                self.coalesced += 1200                # Coalesced follower shares the leader's round-trip —201                # also a saved upstream request.202                record_api_request("jina", requests=0, cache_hits=1)203 204        if not leader:205            try:206                # ``shield`` so a waiter's timeout can't cancel the shared work.207                return await asyncio.wait_for(208                    asyncio.shield(fut), timeout=single_flight_timeout,209                )210            except Exception:211                # Leader failed / timed out → independent fetch (resilience).212                return await scrape_fn()213 214        # Leader path.215        try:216            content = await scrape_fn()217        except BaseException as exc:218            async with self._lock:219                self._inflight.pop(url, None)220            if not fut.done():221                fut.set_exception(exc)222                # Retrieve eagerly so a leader failure with no waiting follower223                # doesn't log "Future exception was never retrieved". Waiters224                # (if any) still see it via their own ``await``.225                fut.exception()226            raise227        cacheable = isinstance(content, str) and bool(content.strip())228        if cacheable and should_cache is not None:229            try:230                cacheable = bool(should_cache(content))231            except Exception:232                # Cache policy is an optimization boundary: a buggy predicate233                # must not fail a successful scrape or strand coalesced waiters.234                cacheable = False235        async with self._lock:236            self._inflight.pop(url, None)237            if cacheable:238                if len(self._content) >= _MAX_CACHE_ENTRIES:239                    self._content.pop(next(iter(self._content)), None)240                self._content[url] = content241        if not fut.done():242            fut.set_result(content)243        return content244 245    def clear(self) -> None:246        """Drop all entries + counters. Primarily for tests."""247        self._content.clear()248        self._inflight.clear()249        self.hits = self.misses = self.coalesced = 0250 251    def stats(self) -> dict[str, int]:252        return {253            "hits": self.hits,254            "misses": self.misses,255            "coalesced": self.coalesced,256            "size": len(self._content),257        }258 259 260# Module-level singleton — shared across all sibling agents in the process.261scrape_result_cache = ScrapeResultCache()262 263 264def format_skip_message(url: str, entry: _Entry, now: float | None = None) -> str:265    """Format a short, LLM-facing message explaining why the URL was skipped."""266    t = now if now is not None else time.time()267    remaining = max(0, int(entry.ban_until - t))268    mins = remaining // 60269    reason = {270        403: "returned 403 (origin blocked or Jina URL ban)",271        422: f"returned 422 {entry.fail_count}x (paywall, empty, or unparseable content)",272        429: "was rate-limited (429)",273    }.get(entry.status, f"failed with status {entry.status}")274    return (275        f"URL skipped: {url} {reason} earlier in this session. "276        f"Cached for ~{mins} more min. Try a different source or search query."277    )278