"""In-process negative cache for web scrape URLs.""" from __future__ import annotations import asyncio import os import threading import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from frontier_agent.infra.usage_meter import record_api_request # ── Tunables ────────────────────────────────────────────────────────────── # Ban durations per status (seconds). _BAN_403 = 3600 # 1 hour — matches Jina's rolling window. _BAN_422 = 1800 # 30 min — paywall/SPA/empty content. _BAN_429 = 300 # 5 min — transient rate limit. # Minimum consecutive failures before a 422 results in a ban. # 403 and 429 ban on the first occurrence. _MIN_FAILS_422 = 2 # Status codes we track. Everything else is not cached. _TRACKED_STATUSES = {403, 422, 429} @dataclass class _Entry: status: int fail_count: int = 0 ban_until: float = 0.0 # unix time; 0 means not banned last_failed_at: float = field(default_factory=time.time) class _ScrapeCache: """Thread-safe negative cache for scrape URLs.""" def __init__(self) -> None: self._data: dict[str, _Entry] = {} self._lock = threading.Lock() def check(self, url: str, now: float | None = None) -> _Entry | None: """Return active entry if URL is currently banned, else None.""" if not url: return None t = now if now is not None else time.time() with self._lock: entry = self._data.get(url) if entry is None: return None if entry.ban_until > t: return entry # Expired — drop it so future failures start fresh. if entry.ban_until and entry.ban_until <= t: self._data.pop(url, None) return None def record_failure(self, url: str, status: int, now: float | None = None) -> _Entry | None: """Record a failure; set ban_until if rules trigger. Returns updated entry.""" if not url or status not in _TRACKED_STATUSES: return None t = now if now is not None else time.time() with self._lock: entry = self._data.get(url) if entry is None or entry.status != status: # Reset counter if status changed (e.g., 429 → 403). entry = _Entry(status=status, fail_count=0, last_failed_at=t) entry.fail_count += 1 entry.last_failed_at = t entry.status = status if status == 403: entry.ban_until = t + _BAN_403 elif status == 429: entry.ban_until = t + _BAN_429 elif status == 422 and entry.fail_count >= _MIN_FAILS_422: entry.ban_until = t + _BAN_422 self._data[url] = entry return entry def record_success(self, url: str) -> None: """Clear any prior failure record for this URL.""" if not url: return with self._lock: self._data.pop(url, None) def clear(self) -> None: """Drop all entries. Primarily for tests.""" with self._lock: self._data.clear() def size(self) -> int: with self._lock: return len(self._data) # Module-level singleton. Callers should import this directly. cache = _ScrapeCache() # ── Positive scrape cache (cross-run, single-flight) ─────────────────────── # # The negative cache above only suppresses re-hammering KNOWN-BAD URLs. This # positive cache stores SUCCESSFUL scrape content so the same URL fetched by # many sibling agents costs one Jina round-trip, not N. # # Why it pays: sibling agents researching one question converge on the same # canonical pages, so unique URLs run far below total fetches (typically a # 3-5x ratio on a fan-out run). Agents dispatched with asyncio.gather share # one process and event loop, so a module-level singleton reaches all of them # with zero plumbing. # # Scope = SCRAPE ONLY. The per-call SUMMARY_LLM extraction is deliberately NOT # cached: sharing raw page bytes can't homogenise the runs' reasoning (same URL # is the same content for everyone), but sharing extraction would leak one # run's info_to_extract focus into another and couple the otherwise-independent # trajectories. Successes only — a failed scrape is never stored, and a waiter # whose leader failed/timed-out falls back to its own fetch, so the cache never # converts one transient failure into a correlated N-run failure. # Follower wait cap before falling back to an independent scrape. Bounds the # "leader stalled → everyone blocks" failure mode. _SINGLE_FLIGHT_TIMEOUT_S = 90.0 # Cap on distinct cached pages (FIFO eviction) — guards memory on long runs. _MAX_CACHE_ENTRIES = 1024 def _positive_cache_enabled() -> bool: """On by default; set ``WEB_FETCH_SCRAPE_CACHE=0`` (or false/off) to disable.""" raw = (os.environ.get("WEB_FETCH_SCRAPE_CACHE") or "").strip().lower() return raw not in {"0", "false", "off", "no"} class ScrapeUnavailable(Exception): """Raised by a scrape callable when content could not be obtained. Signals the cache to NOT store a result and lets waiters fall back to an independent fetch. Carries the original error string for the caller's user-facing message. """ class ScrapeResultCache: """Process-global positive cache for successful scrapes, with single-flight. ``get_or_scrape(url, scrape_fn)`` returns cached content on a hit; on a miss the first caller (the *leader*) runs ``scrape_fn`` while concurrent callers for the same URL (*followers*) await its result instead of duplicating the fetch. The leader caches only successful, non-empty content. """ def __init__(self) -> None: self._content: dict[str, str] = {} self._inflight: dict[str, asyncio.Future[str]] = {} self._lock = asyncio.Lock() self.hits = 0 self.misses = 0 self.coalesced = 0 async def get_or_scrape( self, url: str, scrape_fn: Callable[[], Awaitable[str]], *, single_flight_timeout: float = _SINGLE_FLIGHT_TIMEOUT_S, should_cache: Callable[[str], bool] | None = None, ) -> str: """Return cached content for ``url`` or run ``scrape_fn`` to produce it. ``scrape_fn`` must return the scraped content string on success and raise :class:`ScrapeUnavailable` (or any exception) on failure — only successful, non-empty returns accepted by ``should_cache`` are cached. ``should_cache`` defaults to accepting every non-empty string; callers can return a low-confidence result to the current request without poisoning later requests with it. Raises whatever ``scrape_fn`` raises for the leader; followers fall back to their own ``scrape_fn`` on the leader's failure or a wait timeout. """ if not url or not _positive_cache_enabled(): return await scrape_fn() async with self._lock: if url in self._content: self.hits += 1 # A cache hit is one Jina round-trip saved. record_api_request("jina", requests=0, cache_hits=1) return self._content[url] fut = self._inflight.get(url) leader = fut is None if leader: self.misses += 1 fut = asyncio.get_event_loop().create_future() self._inflight[url] = fut else: self.coalesced += 1 # Coalesced follower shares the leader's round-trip — # also a saved upstream request. record_api_request("jina", requests=0, cache_hits=1) if not leader: try: # ``shield`` so a waiter's timeout can't cancel the shared work. return await asyncio.wait_for( asyncio.shield(fut), timeout=single_flight_timeout, ) except Exception: # Leader failed / timed out → independent fetch (resilience). return await scrape_fn() # Leader path. try: content = await scrape_fn() except BaseException as exc: async with self._lock: self._inflight.pop(url, None) if not fut.done(): fut.set_exception(exc) # Retrieve eagerly so a leader failure with no waiting follower # doesn't log "Future exception was never retrieved". Waiters # (if any) still see it via their own ``await``. fut.exception() raise cacheable = isinstance(content, str) and bool(content.strip()) if cacheable and should_cache is not None: try: cacheable = bool(should_cache(content)) except Exception: # Cache policy is an optimization boundary: a buggy predicate # must not fail a successful scrape or strand coalesced waiters. cacheable = False async with self._lock: self._inflight.pop(url, None) if cacheable: if len(self._content) >= _MAX_CACHE_ENTRIES: self._content.pop(next(iter(self._content)), None) self._content[url] = content if not fut.done(): fut.set_result(content) return content def clear(self) -> None: """Drop all entries + counters. Primarily for tests.""" self._content.clear() self._inflight.clear() self.hits = self.misses = self.coalesced = 0 def stats(self) -> dict[str, int]: return { "hits": self.hits, "misses": self.misses, "coalesced": self.coalesced, "size": len(self._content), } # Module-level singleton — shared across all sibling agents in the process. scrape_result_cache = ScrapeResultCache() def format_skip_message(url: str, entry: _Entry, now: float | None = None) -> str: """Format a short, LLM-facing message explaining why the URL was skipped.""" t = now if now is not None else time.time() remaining = max(0, int(entry.ban_until - t)) mins = remaining // 60 reason = { 403: "returned 403 (origin blocked or Jina URL ban)", 422: f"returned 422 {entry.fail_count}x (paywall, empty, or unparseable content)", 429: "was rate-limited (429)", }.get(entry.status, f"failed with status {entry.status}") return ( f"URL skipped: {url} {reason} earlier in this session. " f"Cached for ~{mins} more min. Try a different source or search query." )