agent_search_gateway.scheduler.fetch
Sequential URL-fetch provider scheduler with typed outcomes.
1"""Sequential URL-fetch provider scheduler with typed outcomes.""" 2 3import asyncio 4import logging 5import time 6from collections.abc import Callable, Sequence 7 8from ..concurrency import CapacityLease, ProviderQuotaManager 9from ..errors import ErrorCode, ExecutionFailure 10from ..llm.stages import LLMStages, cheap_check 11from ..models import FetchOutcome 12from ..observability import elapsed_ms, log_event, target_url_for_log 13from ..providers.contracts import URLFetchCandidate, URLFetchProvider 14from ..url_normalization import NormalizedURL 15 16 17class FetchScheduler: 18 def __init__( 19 self, 20 providers: Sequence[URLFetchProvider], 21 quotas: ProviderQuotaManager, 22 stages: LLMStages, 23 *, 24 logger: logging.Logger | None = None, 25 monotonic: Callable[[], float] = time.monotonic, 26 ) -> None: 27 self._providers = tuple(providers) 28 self._quotas = quotas 29 self._stages = stages 30 self._logger = logger or logging.getLogger(__name__) 31 self._monotonic = monotonic 32 33 @property 34 def provider_names(self) -> tuple[str, ...]: 35 return tuple(provider.name for provider in self._providers) 36 37 async def fetch_until_accepted(self, url: NormalizedURL) -> FetchOutcome: 38 semantic_failure_seen = False 39 failures: list[ExecutionFailure] = [] 40 remaining = list(self._providers) 41 while remaining: 42 provider, lease = await self._select_available_provider(remaining) 43 remaining.remove(provider) 44 log_event( 45 self._logger, 46 logging.DEBUG, 47 "provider_selected", 48 provider=provider.name, 49 url=target_url_for_log(str(url)), 50 candidate_count=len(remaining) + 1, 51 ) 52 try: 53 async with lease: 54 outcome = await self._attempt(provider, url) 55 except asyncio.CancelledError: 56 raise 57 if outcome.kind == "accepted": 58 return FetchOutcome("accepted", outcome.candidate, tuple(failures)) 59 log_event( 60 self._logger, 61 logging.DEBUG, 62 "provider_fallback", 63 provider=provider.name, 64 url=target_url_for_log(str(url)), 65 outcome=outcome.kind, 66 remaining=len(remaining), 67 ) 68 if outcome.kind == "semantic_failure": 69 semantic_failure_seen = True 70 else: 71 failures.extend(outcome.failures) 72 73 if semantic_failure_seen: 74 return FetchOutcome("semantic_failure", failures=tuple(failures)) 75 return FetchOutcome("execution_failure", failures=tuple(failures)) 76 77 async def _select_available_provider( 78 self, 79 remaining: list[URLFetchProvider], 80 ) -> tuple[URLFetchProvider, CapacityLease]: 81 while True: 82 for provider in remaining: 83 lease = await self._quotas.get_web(provider.name).try_lease() 84 if lease is not None: 85 return provider, lease 86 log_event( 87 self._logger, 88 logging.DEBUG, 89 "scheduler_waiting", 90 candidate_count=len(remaining), 91 ) 92 await self._quotas.wait_until_any_web_available( 93 tuple(provider.name for provider in remaining) 94 ) 95 96 async def _attempt( 97 self, 98 provider: URLFetchProvider, 99 url: NormalizedURL, 100 ) -> FetchOutcome: 101 started = self._monotonic() 102 log_event( 103 self._logger, 104 logging.DEBUG, 105 "provider_started", 106 provider=provider.name, 107 stage="fetch", 108 url=target_url_for_log(str(url)), 109 ) 110 try: 111 candidate = await provider.fetch(url) 112 self._validate_candidate(candidate) 113 except asyncio.CancelledError: 114 raise 115 except ExecutionFailure as exc: 116 self._log_provider_failed(provider.name, url, started, exc) 117 return FetchOutcome("execution_failure", failures=(exc,)) 118 except Exception as exc: 119 self._log_provider_failed(provider.name, url, started, exc) 120 failure = ExecutionFailure( 121 ErrorCode.ALL_PROVIDERS_FAILED, 122 f"Fetch provider {provider.name} returned invalid data", 123 ) 124 return FetchOutcome("execution_failure", failures=(failure,)) 125 126 log_event( 127 self._logger, 128 logging.DEBUG, 129 "candidate_accepted", 130 provider=provider.name, 131 url=target_url_for_log(str(url)), 132 raw_chars=len(candidate.raw_content), 133 content_chars=len(candidate.content), 134 ) 135 validation_candidate = candidate.content or candidate.raw_content 136 if not cheap_check(validation_candidate): 137 self._log_body_rejected(provider.name, url, "cheap_check") 138 self._log_provider_completed(provider.name, url, started, "semantic_failure") 139 return FetchOutcome("semantic_failure") 140 141 try: 142 decision = await self._stages.judge(validation_candidate) 143 except asyncio.CancelledError: 144 raise 145 except ExecutionFailure as exc: 146 failure = ExecutionFailure( 147 exc.code, 148 exc.message, 149 reason=exc.reason or "judge_execution_failed", 150 ) 151 self._log_provider_failed( 152 provider.name, 153 url, 154 started, 155 failure, 156 failed_stage="judge", 157 dependency=self._stages.judge_provider, 158 ) 159 raise failure from exc 160 except Exception as exc: 161 failure = ExecutionFailure( 162 ErrorCode.LLM_STAGE_FAILED, 163 "Judge LLM stage failed", 164 reason="judge_execution_failed", 165 ) 166 self._log_provider_failed( 167 provider.name, 168 url, 169 started, 170 failure, 171 failed_stage="judge", 172 dependency=self._stages.judge_provider, 173 ) 174 raise failure from exc 175 176 if not decision.ok: 177 self._log_body_rejected(provider.name, url, "judge_rejected") 178 self._log_provider_completed(provider.name, url, started, "semantic_failure") 179 return FetchOutcome("semantic_failure") 180 log_event( 181 self._logger, 182 logging.DEBUG, 183 "body_accepted", 184 provider=provider.name, 185 url=target_url_for_log(str(url)), 186 raw_chars=len(candidate.raw_content), 187 content_chars=len(candidate.content), 188 ) 189 self._log_provider_completed(provider.name, url, started, "accepted") 190 return FetchOutcome("accepted", candidate) 191 192 def _log_body_rejected( 193 self, 194 provider: str, 195 url: NormalizedURL, 196 reason: str, 197 ) -> None: 198 log_event( 199 self._logger, 200 logging.DEBUG, 201 "body_rejected", 202 provider=provider, 203 url=target_url_for_log(str(url)), 204 reason=reason, 205 ) 206 207 def _log_provider_completed( 208 self, 209 provider: str, 210 url: NormalizedURL, 211 started: float, 212 outcome: str, 213 ) -> None: 214 log_event( 215 self._logger, 216 logging.DEBUG, 217 "provider_completed", 218 provider=provider, 219 stage="fetch", 220 url=target_url_for_log(str(url)), 221 outcome=outcome, 222 elapsed_ms=elapsed_ms(self._monotonic, started), 223 ) 224 225 def _log_provider_failed( 226 self, 227 provider: str, 228 url: NormalizedURL, 229 started: float, 230 exc: Exception, 231 *, 232 failed_stage: str | None = None, 233 dependency: str | None = None, 234 ) -> None: 235 reason = exc.reason if isinstance(exc, ExecutionFailure) else None 236 if failed_stage is not None: 237 log_event( 238 self._logger, 239 logging.DEBUG, 240 "provider_failed", 241 provider=provider, 242 stage="fetch", 243 url=target_url_for_log(str(url)), 244 error_type=type(exc).__name__, 245 elapsed_ms=elapsed_ms(self._monotonic, started), 246 failed_stage=failed_stage, 247 dependency=dependency or "-", 248 reason=reason or "judge_execution_failed", 249 ) 250 return 251 if reason: 252 log_event( 253 self._logger, 254 logging.DEBUG, 255 "provider_failed", 256 provider=provider, 257 stage="fetch", 258 url=target_url_for_log(str(url)), 259 error_type=type(exc).__name__, 260 elapsed_ms=elapsed_ms(self._monotonic, started), 261 reason=reason, 262 ) 263 return 264 log_event( 265 self._logger, 266 logging.DEBUG, 267 "provider_failed", 268 provider=provider, 269 stage="fetch", 270 url=target_url_for_log(str(url)), 271 error_type=type(exc).__name__, 272 elapsed_ms=elapsed_ms(self._monotonic, started), 273 ) 274 275 @staticmethod 276 def _validate_candidate(candidate: URLFetchCandidate) -> None: 277 if not isinstance(candidate, URLFetchCandidate): 278 raise TypeError("fetch candidate has invalid type") 279 if not isinstance(candidate.raw_content, str) or not isinstance(candidate.content, str): 280 raise TypeError("fetch candidate fields must be strings") 281 if candidate.raw_content == "": 282 raise ValueError("fetch candidate raw_content must be non-empty")
class
FetchScheduler:
18class FetchScheduler: 19 def __init__( 20 self, 21 providers: Sequence[URLFetchProvider], 22 quotas: ProviderQuotaManager, 23 stages: LLMStages, 24 *, 25 logger: logging.Logger | None = None, 26 monotonic: Callable[[], float] = time.monotonic, 27 ) -> None: 28 self._providers = tuple(providers) 29 self._quotas = quotas 30 self._stages = stages 31 self._logger = logger or logging.getLogger(__name__) 32 self._monotonic = monotonic 33 34 @property 35 def provider_names(self) -> tuple[str, ...]: 36 return tuple(provider.name for provider in self._providers) 37 38 async def fetch_until_accepted(self, url: NormalizedURL) -> FetchOutcome: 39 semantic_failure_seen = False 40 failures: list[ExecutionFailure] = [] 41 remaining = list(self._providers) 42 while remaining: 43 provider, lease = await self._select_available_provider(remaining) 44 remaining.remove(provider) 45 log_event( 46 self._logger, 47 logging.DEBUG, 48 "provider_selected", 49 provider=provider.name, 50 url=target_url_for_log(str(url)), 51 candidate_count=len(remaining) + 1, 52 ) 53 try: 54 async with lease: 55 outcome = await self._attempt(provider, url) 56 except asyncio.CancelledError: 57 raise 58 if outcome.kind == "accepted": 59 return FetchOutcome("accepted", outcome.candidate, tuple(failures)) 60 log_event( 61 self._logger, 62 logging.DEBUG, 63 "provider_fallback", 64 provider=provider.name, 65 url=target_url_for_log(str(url)), 66 outcome=outcome.kind, 67 remaining=len(remaining), 68 ) 69 if outcome.kind == "semantic_failure": 70 semantic_failure_seen = True 71 else: 72 failures.extend(outcome.failures) 73 74 if semantic_failure_seen: 75 return FetchOutcome("semantic_failure", failures=tuple(failures)) 76 return FetchOutcome("execution_failure", failures=tuple(failures)) 77 78 async def _select_available_provider( 79 self, 80 remaining: list[URLFetchProvider], 81 ) -> tuple[URLFetchProvider, CapacityLease]: 82 while True: 83 for provider in remaining: 84 lease = await self._quotas.get_web(provider.name).try_lease() 85 if lease is not None: 86 return provider, lease 87 log_event( 88 self._logger, 89 logging.DEBUG, 90 "scheduler_waiting", 91 candidate_count=len(remaining), 92 ) 93 await self._quotas.wait_until_any_web_available( 94 tuple(provider.name for provider in remaining) 95 ) 96 97 async def _attempt( 98 self, 99 provider: URLFetchProvider, 100 url: NormalizedURL, 101 ) -> FetchOutcome: 102 started = self._monotonic() 103 log_event( 104 self._logger, 105 logging.DEBUG, 106 "provider_started", 107 provider=provider.name, 108 stage="fetch", 109 url=target_url_for_log(str(url)), 110 ) 111 try: 112 candidate = await provider.fetch(url) 113 self._validate_candidate(candidate) 114 except asyncio.CancelledError: 115 raise 116 except ExecutionFailure as exc: 117 self._log_provider_failed(provider.name, url, started, exc) 118 return FetchOutcome("execution_failure", failures=(exc,)) 119 except Exception as exc: 120 self._log_provider_failed(provider.name, url, started, exc) 121 failure = ExecutionFailure( 122 ErrorCode.ALL_PROVIDERS_FAILED, 123 f"Fetch provider {provider.name} returned invalid data", 124 ) 125 return FetchOutcome("execution_failure", failures=(failure,)) 126 127 log_event( 128 self._logger, 129 logging.DEBUG, 130 "candidate_accepted", 131 provider=provider.name, 132 url=target_url_for_log(str(url)), 133 raw_chars=len(candidate.raw_content), 134 content_chars=len(candidate.content), 135 ) 136 validation_candidate = candidate.content or candidate.raw_content 137 if not cheap_check(validation_candidate): 138 self._log_body_rejected(provider.name, url, "cheap_check") 139 self._log_provider_completed(provider.name, url, started, "semantic_failure") 140 return FetchOutcome("semantic_failure") 141 142 try: 143 decision = await self._stages.judge(validation_candidate) 144 except asyncio.CancelledError: 145 raise 146 except ExecutionFailure as exc: 147 failure = ExecutionFailure( 148 exc.code, 149 exc.message, 150 reason=exc.reason or "judge_execution_failed", 151 ) 152 self._log_provider_failed( 153 provider.name, 154 url, 155 started, 156 failure, 157 failed_stage="judge", 158 dependency=self._stages.judge_provider, 159 ) 160 raise failure from exc 161 except Exception as exc: 162 failure = ExecutionFailure( 163 ErrorCode.LLM_STAGE_FAILED, 164 "Judge LLM stage failed", 165 reason="judge_execution_failed", 166 ) 167 self._log_provider_failed( 168 provider.name, 169 url, 170 started, 171 failure, 172 failed_stage="judge", 173 dependency=self._stages.judge_provider, 174 ) 175 raise failure from exc 176 177 if not decision.ok: 178 self._log_body_rejected(provider.name, url, "judge_rejected") 179 self._log_provider_completed(provider.name, url, started, "semantic_failure") 180 return FetchOutcome("semantic_failure") 181 log_event( 182 self._logger, 183 logging.DEBUG, 184 "body_accepted", 185 provider=provider.name, 186 url=target_url_for_log(str(url)), 187 raw_chars=len(candidate.raw_content), 188 content_chars=len(candidate.content), 189 ) 190 self._log_provider_completed(provider.name, url, started, "accepted") 191 return FetchOutcome("accepted", candidate) 192 193 def _log_body_rejected( 194 self, 195 provider: str, 196 url: NormalizedURL, 197 reason: str, 198 ) -> None: 199 log_event( 200 self._logger, 201 logging.DEBUG, 202 "body_rejected", 203 provider=provider, 204 url=target_url_for_log(str(url)), 205 reason=reason, 206 ) 207 208 def _log_provider_completed( 209 self, 210 provider: str, 211 url: NormalizedURL, 212 started: float, 213 outcome: str, 214 ) -> None: 215 log_event( 216 self._logger, 217 logging.DEBUG, 218 "provider_completed", 219 provider=provider, 220 stage="fetch", 221 url=target_url_for_log(str(url)), 222 outcome=outcome, 223 elapsed_ms=elapsed_ms(self._monotonic, started), 224 ) 225 226 def _log_provider_failed( 227 self, 228 provider: str, 229 url: NormalizedURL, 230 started: float, 231 exc: Exception, 232 *, 233 failed_stage: str | None = None, 234 dependency: str | None = None, 235 ) -> None: 236 reason = exc.reason if isinstance(exc, ExecutionFailure) else None 237 if failed_stage is not None: 238 log_event( 239 self._logger, 240 logging.DEBUG, 241 "provider_failed", 242 provider=provider, 243 stage="fetch", 244 url=target_url_for_log(str(url)), 245 error_type=type(exc).__name__, 246 elapsed_ms=elapsed_ms(self._monotonic, started), 247 failed_stage=failed_stage, 248 dependency=dependency or "-", 249 reason=reason or "judge_execution_failed", 250 ) 251 return 252 if reason: 253 log_event( 254 self._logger, 255 logging.DEBUG, 256 "provider_failed", 257 provider=provider, 258 stage="fetch", 259 url=target_url_for_log(str(url)), 260 error_type=type(exc).__name__, 261 elapsed_ms=elapsed_ms(self._monotonic, started), 262 reason=reason, 263 ) 264 return 265 log_event( 266 self._logger, 267 logging.DEBUG, 268 "provider_failed", 269 provider=provider, 270 stage="fetch", 271 url=target_url_for_log(str(url)), 272 error_type=type(exc).__name__, 273 elapsed_ms=elapsed_ms(self._monotonic, started), 274 ) 275 276 @staticmethod 277 def _validate_candidate(candidate: URLFetchCandidate) -> None: 278 if not isinstance(candidate, URLFetchCandidate): 279 raise TypeError("fetch candidate has invalid type") 280 if not isinstance(candidate.raw_content, str) or not isinstance(candidate.content, str): 281 raise TypeError("fetch candidate fields must be strings") 282 if candidate.raw_content == "": 283 raise ValueError("fetch candidate raw_content must be non-empty")
FetchScheduler( providers: Sequence[agent_search_gateway.providers.contracts.URLFetchProvider], quotas: agent_search_gateway.concurrency.ProviderQuotaManager, stages: agent_search_gateway.llm.stages.LLMStages, *, logger: logging.Logger | None = None, monotonic: Callable[[], float] = <built-in function monotonic>)
19 def __init__( 20 self, 21 providers: Sequence[URLFetchProvider], 22 quotas: ProviderQuotaManager, 23 stages: LLMStages, 24 *, 25 logger: logging.Logger | None = None, 26 monotonic: Callable[[], float] = time.monotonic, 27 ) -> None: 28 self._providers = tuple(providers) 29 self._quotas = quotas 30 self._stages = stages 31 self._logger = logger or logging.getLogger(__name__) 32 self._monotonic = monotonic
async def
fetch_until_accepted( self, url: agent_search_gateway.url_normalization.NormalizedURL) -> agent_search_gateway.models.FetchOutcome:
38 async def fetch_until_accepted(self, url: NormalizedURL) -> FetchOutcome: 39 semantic_failure_seen = False 40 failures: list[ExecutionFailure] = [] 41 remaining = list(self._providers) 42 while remaining: 43 provider, lease = await self._select_available_provider(remaining) 44 remaining.remove(provider) 45 log_event( 46 self._logger, 47 logging.DEBUG, 48 "provider_selected", 49 provider=provider.name, 50 url=target_url_for_log(str(url)), 51 candidate_count=len(remaining) + 1, 52 ) 53 try: 54 async with lease: 55 outcome = await self._attempt(provider, url) 56 except asyncio.CancelledError: 57 raise 58 if outcome.kind == "accepted": 59 return FetchOutcome("accepted", outcome.candidate, tuple(failures)) 60 log_event( 61 self._logger, 62 logging.DEBUG, 63 "provider_fallback", 64 provider=provider.name, 65 url=target_url_for_log(str(url)), 66 outcome=outcome.kind, 67 remaining=len(remaining), 68 ) 69 if outcome.kind == "semantic_failure": 70 semantic_failure_seen = True 71 else: 72 failures.extend(outcome.failures) 73 74 if semantic_failure_seen: 75 return FetchOutcome("semantic_failure", failures=tuple(failures)) 76 return FetchOutcome("execution_failure", failures=tuple(failures))