Spaces:
Running
Running
File size: 10,985 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | """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."
)
|