Coverage for src/local_deep_research/web_search_engines/engines/_searxng_rate_limiter.py: 100%
51 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""Shared rate limiter for SearXNG engine instances.
3The research agent constructs a fresh `SearXNGSearchEngine` for every
4tool call, so the previous per-instance `last_request_time` only
5throttled requests on engines that were explicitly reused (for example
6the shared engine used by `JournalReputationFilter` for Tier 4). This
7module provides a process-wide tracker keyed by SearXNG ``instance_url``
8so the configured ``delay_between_requests`` actually applies across
9per-call engine instances.
11The locking pattern mirrors other per-key locks in the codebase
12(see ``database/backup/backup_service.py`` and
13``database/encrypted_db.py``): a meta-lock guards lazy creation of a
14per-URL lock, and that per-URL lock guards updates to the timestamp.
16Note on memory:
17`_url_state` is bounded to a maximum capacity (`MAX_TRACKED_URLS`). When
18capacity is reached, stale/least-recently-used entries are evicted under
19`_meta_lock`.
20"""
22import threading
23import time
24from dataclasses import dataclass, field
26from ...security import redact_url_for_log
27from ...security.secure_logging import logger
29MAX_TRACKED_URLS = 1000
32@dataclass
33class _UrlState:
34 """Lock for one tracked URL, and when it was last requested."""
36 lock: threading.Lock = field(default_factory=threading.Lock)
37 last_request: float = 0.0 # monotonic; 0.0 means "no request yet"
40_meta_lock = threading.Lock()
41_url_state: dict[str, _UrlState] = {}
44def _normalize_url(url: str) -> str:
45 """Normalize instance URL for keying rate limits."""
46 return url.rstrip("/")
49def _evict_stale_locks_unlocked() -> None:
50 """Evict oldest entries when tracked URLs reach capacity.
52 A lock another thread is currently holding is never a candidate: dropping
53 it lets the next caller for that URL build a fresh lock and read no
54 timestamp, so the holder's delay stops applying. When every tracked lock
55 is held there is nothing to reclaim and the tracker grows past
56 ``MAX_TRACKED_URLS`` until one is released.
58 ``locked()`` is not a complete answer, and this function does not make it
59 one. ``respect_rate_limit`` fetches its state from ``_get_url_state`` under
60 ``_meta_lock`` and acquires the lock afterwards, so between those two steps
61 the object reports ``locked() == False`` and stays evictable. A URL evicted
62 in that window loses its timestamp and skips one delay. The window is
63 narrower than the one this check closes, which spans
64 ``time.sleep(wait_time)``, and closing it as well would mean holding
65 ``_meta_lock`` across the sleep.
67 Must be called while holding ``_meta_lock``.
68 """
69 if len(_url_state) < MAX_TRACKED_URLS:
70 return
71 evictable = [
72 url for url, state in _url_state.items() if not state.lock.locked()
73 ]
74 if not evictable:
75 logger.warning(
76 f"SearXNG rate limiter: all {len(_url_state)} tracked URL locks "
77 "are in use, so none can be evicted at capacity"
78 )
79 return
80 # Remove oldest half of entries based on their last_request timestamp
81 sorted_urls = sorted(evictable, key=lambda u: _url_state[u].last_request)
82 to_remove = sorted_urls[: max(1, len(sorted_urls) // 2)]
83 for url in to_remove:
84 _url_state.pop(url, None)
87def _get_url_state(normalized_url: str) -> _UrlState:
88 """Return the per-URL state, creating it lazily.
90 Expects an already normalized URL string.
91 """
92 with _meta_lock:
93 state = _url_state.get(normalized_url)
94 if state is None:
95 _evict_stale_locks_unlocked()
96 state = _UrlState()
97 _url_state[normalized_url] = state
98 return state
101def respect_rate_limit(instance_url: str, delay_seconds: float) -> None:
102 """Ensure at least ``delay_seconds`` have passed since the previous call
103 for this ``instance_url`` (does not wait on the first call for a URL).
105 A ``delay_seconds`` of ``0`` (or less) returns immediately without
106 touching the tracker, preserving the prior "no throttling" behavior
107 when the user has not configured any delay.
108 """
109 if delay_seconds <= 0:
110 return
112 normalized_url = _normalize_url(instance_url)
113 state = _get_url_state(normalized_url)
114 with state.lock:
115 now = time.monotonic()
116 last = state.last_request
117 elapsed = now - last
118 if last > 0 and elapsed < delay_seconds:
119 wait_time = delay_seconds - elapsed
120 logger.info(
121 f"SearXNG rate limiting: waiting {wait_time:.2f}s for instance {redact_url_for_log(normalized_url)}"
122 )
123 time.sleep(wait_time)
124 now = time.monotonic()
125 # Attribute write on a held reference: an evicted entry stays evicted.
126 state.last_request = now
129def reset_for_tests() -> None:
130 """Clear all tracked state. Intended for unit tests only."""
131 with _meta_lock:
132 _url_state.clear()