|
28 | 28 | TRequestManager = TypeVar('TRequestManager', bound=RequestManager) |
29 | 29 |
|
30 | 30 | _NEVER_THROTTLED = datetime.min.replace(tzinfo=timezone.utc) |
31 | | -"""Sentinel `throttled_until` value meaning the domain has no active backoff.""" |
| 31 | +"""Sentinel timestamp meaning a dispatch clock has never been armed.""" |
32 | 32 |
|
33 | 33 |
|
34 | 34 | @docs_group('Request loaders') |
@@ -120,13 +120,12 @@ async def purge(self) -> None: |
120 | 120 | """Empty the inner manager and all sub-managers, and reset transient per-domain throttle state. |
121 | 121 |
|
122 | 122 | The configured domain list and any robots.txt-derived `crawl_delay` are preserved; only the dynamic backoff |
123 | | - state (consecutive 429 counter and `throttled_until`) is cleared. Sub-managers are kept around so they don't |
124 | | - need to be re-opened on the next request — they're just emptied. |
| 123 | + state (consecutive 429 counter and the two dispatch clocks) is cleared. Sub-managers are kept around so they |
| 124 | + don't need to be re-opened on the next request — they're just emptied. |
125 | 125 | """ |
126 | 126 | await asyncio.gather(self._inner.purge(), *(sm.purge() for sm in self._sub_managers.values())) |
127 | 127 | for state in self._domain_states.values(): |
128 | | - state.consecutive_429_count = 0 |
129 | | - state.throttled_until = _NEVER_THROTTLED |
| 128 | + state.reset_throttling() |
130 | 129 |
|
131 | 130 | @override |
132 | 131 | async def add_request(self, request: str | Request, *, forefront: bool = False) -> ProcessedRequest | None: |
@@ -258,9 +257,7 @@ async def reclaim_request(self, request: Request, *, forefront: bool = False) -> |
258 | 257 | @override |
259 | 258 | async def mark_request_as_handled(self, request: Request) -> ProcessedRequest | None: |
260 | 259 | manager = self._select_manager(request.url) |
261 | | - result = await manager.mark_request_as_handled(request) |
262 | | - self.record_success(request.url) |
263 | | - return result |
| 260 | + return await manager.mark_request_as_handled(request) |
264 | 261 |
|
265 | 262 | @override |
266 | 263 | async def get_handled_count(self) -> int: |
@@ -291,33 +288,54 @@ async def is_finished(self) -> bool: |
291 | 288 | def record_domain_delay(self, url: str, *, retry_after: timedelta | None = None) -> bool: |
292 | 289 | """Record a 429 Too Many Requests response for the domain of the given URL. |
293 | 290 |
|
294 | | - Increments the consecutive 429 count and calculates the next allowed request time using exponential backoff or |
295 | | - the `Retry-After` value. |
| 291 | + Advances the consecutive 429 count and calculates the next allowed request time using exponential backoff or |
| 292 | + the `Retry-After` value. Only the first 429 of a burst advances the count, so the delay tracks how hard the |
| 293 | + domain pushes back, not how many requests were in flight. |
296 | 294 |
|
297 | 295 | Args: |
298 | 296 | url: The URL that received a 429 response. |
299 | | - retry_after: Optional delay from the `Retry-After` header. If provided, it takes priority over the |
300 | | - calculated exponential backoff. |
| 297 | + retry_after: Optional delay from the `Retry-After` header. If it describes a positive delay, it takes |
| 298 | + priority over the calculated exponential backoff. |
301 | 299 |
|
302 | 300 | Returns: |
303 | | - True if the URL's domain is configured for throttling and the delay was applied; False if the domain is not |
| 301 | + True if the URL's domain is configured for throttling and the 429 was recorded; False if the domain is not |
304 | 302 | in the configured `domains` list, in which case the call is a no-op. |
305 | 303 | """ |
306 | 304 | state = self._get_domain_state(url) |
307 | 305 | if state is None: |
308 | 306 | return False |
309 | 307 |
|
| 308 | + now = datetime.now(timezone.utc) |
| 309 | + |
| 310 | + # Requests in flight when the limit was hit all come back 429. That is one rate-limit event, so only the first |
| 311 | + # advances the exponent. Checking `crawl_delay_until` too would swallow every 429, as it is armed on every |
| 312 | + # dispatch. |
| 313 | + if now < state.backoff_until: |
| 314 | + return True |
| 315 | + |
| 316 | + # The domain has been quiet for a full extra window, so this 429 opens a new run instead of continuing the old. |
| 317 | + if now >= state.backoff_decays_at: |
| 318 | + state.consecutive_429_count = 0 |
| 319 | + |
310 | 320 | state.consecutive_429_count += 1 |
311 | | - delay = retry_after if retry_after is not None else self._base_delay * (2 ** (state.consecutive_429_count - 1)) |
| 321 | + |
| 322 | + # A non-positive `Retry-After` is no delay at all, so fall back to the backoff and let it engage. |
| 323 | + if retry_after is not None and retry_after > timedelta(0): |
| 324 | + delay = retry_after |
| 325 | + source = 'Retry-After header' |
| 326 | + else: |
| 327 | + delay = self._base_delay * (2 ** (state.consecutive_429_count - 1)) |
| 328 | + source = 'exponential backoff' |
| 329 | + |
312 | 330 | if delay > self._max_delay: |
313 | | - source = 'Retry-After header' if retry_after is not None else 'exponential backoff' |
314 | 331 | logger.warning( |
315 | 332 | f'Capping {source} delay of {delay.total_seconds():.1f}s for domain "{state.domain}" ' |
316 | 333 | f'to max_delay ({self._max_delay.total_seconds():.1f}s); the domain may continue to rate-limit. ' |
317 | 334 | f'Consider increasing max_delay if this recurs.' |
318 | 335 | ) |
319 | 336 | delay = self._max_delay |
320 | | - state.throttled_until = datetime.now(timezone.utc) + delay |
| 337 | + |
| 338 | + state.apply_backoff(now, delay) |
321 | 339 |
|
322 | 340 | logger.info( |
323 | 341 | f'Rate limit (429) detected for domain "{state.domain}" ' |
@@ -398,11 +416,11 @@ def _get_earliest_available_time(self, now: datetime) -> datetime: |
398 | 416 | def _mark_domain_dispatched(self, domain: str) -> None: |
399 | 417 | """Record that a request to this domain was just dispatched. |
400 | 418 |
|
401 | | - If a crawl-delay is configured, push throttled_until forward by that amount. |
| 419 | + If a crawl-delay is configured, push `crawl_delay_until` forward by that amount. |
402 | 420 | """ |
403 | 421 | state = self._domain_states.get(domain) |
404 | | - if state is not None and state.crawl_delay is not None: |
405 | | - state.throttled_until = datetime.now(timezone.utc) + state.crawl_delay |
| 422 | + if state is not None: |
| 423 | + state.apply_crawl_delay(datetime.now(timezone.utc)) |
406 | 424 |
|
407 | 425 | def _signal_new_work(self) -> None: |
408 | 426 | """Wake `fetch_next_request` if it is sleeping inside a throttle wait.""" |
@@ -450,11 +468,41 @@ class _DomainState: |
450 | 468 | domain: str |
451 | 469 | """The domain being tracked.""" |
452 | 470 |
|
453 | | - throttled_until: datetime = _NEVER_THROTTLED |
454 | | - """Earliest time the next request to this domain is allowed.""" |
| 471 | + backoff_until: datetime = _NEVER_THROTTLED |
| 472 | + """Earliest time the next request is allowed by the 429 backoff. Kept apart from `crawl_delay_until`, which is |
| 473 | + armed on every dispatch and would otherwise pass for an active backoff. |
| 474 | + """ |
| 475 | + |
| 476 | + crawl_delay_until: datetime = _NEVER_THROTTLED |
| 477 | + """Earliest time the next request is allowed by the domain's crawl-delay.""" |
| 478 | + |
| 479 | + backoff_decays_at: datetime = _NEVER_THROTTLED |
| 480 | + """Time after which an incoming 429 is treated as a fresh burst rather than a continuation of the current one.""" |
455 | 481 |
|
456 | 482 | consecutive_429_count: int = 0 |
457 | 483 | """Number of consecutive 429 responses (for exponential backoff).""" |
458 | 484 |
|
459 | 485 | crawl_delay: timedelta | None = None |
460 | | - """Minimum interval between requests, used to push `throttled_until` on dispatch.""" |
| 486 | + """Minimum interval between requests, used to push `crawl_delay_until` on dispatch.""" |
| 487 | + |
| 488 | + @property |
| 489 | + def throttled_until(self) -> datetime: |
| 490 | + """Earliest time the next request to this domain is allowed by either of its two independent clocks.""" |
| 491 | + return max(self.backoff_until, self.crawl_delay_until) |
| 492 | + |
| 493 | + def apply_backoff(self, now: datetime, delay: timedelta) -> None: |
| 494 | + """Block the domain for `delay`. If no 429 arrives for another `delay` after that, the exponent resets.""" |
| 495 | + self.backoff_until = now + delay |
| 496 | + self.backoff_decays_at = self.backoff_until + delay |
| 497 | + |
| 498 | + def apply_crawl_delay(self, now: datetime) -> None: |
| 499 | + """Block the domain for its crawl-delay, if it declared one.""" |
| 500 | + if self.crawl_delay is not None: |
| 501 | + self.crawl_delay_until = now + self.crawl_delay |
| 502 | + |
| 503 | + def reset_throttling(self) -> None: |
| 504 | + """Clear the transient throttle state.""" |
| 505 | + self.consecutive_429_count = 0 |
| 506 | + self.backoff_until = _NEVER_THROTTLED |
| 507 | + self.crawl_delay_until = _NEVER_THROTTLED |
| 508 | + self.backoff_decays_at = _NEVER_THROTTLED |
0 commit comments