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."
    )