Edit on GitHub

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
provider_names: tuple[str, ...]
34    @property
35    def provider_names(self) -> tuple[str, ...]:
36        return tuple(provider.name for provider in self._providers)
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))