Skip to content

Commit 4b63cfe

Browse files
committed
correct the backoff for concurrent 429s and zero Retry-After
1 parent 263fff4 commit 4b63cfe

3 files changed

Lines changed: 195 additions & 51 deletions

File tree

src/crawlee/_utils/http.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def parse_retry_after_header(value: str | None) -> timedelta | None:
1919
value: The raw Retry-After header value.
2020
2121
Returns:
22-
A timedelta representing the delay, or None if the header is missing or unparsable.
22+
A timedelta representing the delay, or None if the header is missing, unparsable, or not a positive delay.
2323
"""
2424
if not value:
2525
return None
@@ -30,10 +30,10 @@ def parse_retry_after_header(value: str | None) -> timedelta | None:
3030
except ValueError:
3131
pass # Not an integer, fall through to the HTTP-date form below.
3232
else:
33-
if seconds < 0:
34-
# A negative delay is malformed. Reject it instead of returning a negative `timedelta`, which would
35-
# push `throttled_until` into the past and silently disable the 429 back-off downstream.
36-
logger.debug(f'Retry-After delay-seconds {value!r} is negative; ignoring.')
33+
if seconds <= 0:
34+
# A negative delay is malformed, a zero one carries no back-off. Reject both, so the caller falls back to
35+
# its own back-off instead of silently losing it.
36+
logger.debug(f'Retry-After delay-seconds {value!r} is not positive; ignoring.')
3737
return None
3838
return timedelta(seconds=seconds)
3939

src/crawlee/request_loaders/_throttling_request_manager.py

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
TRequestManager = TypeVar('TRequestManager', bound=RequestManager)
2929

3030
_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."""
3232

3333

3434
@docs_group('Request loaders')
@@ -120,13 +120,12 @@ async def purge(self) -> None:
120120
"""Empty the inner manager and all sub-managers, and reset transient per-domain throttle state.
121121
122122
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.
125125
"""
126126
await asyncio.gather(self._inner.purge(), *(sm.purge() for sm in self._sub_managers.values()))
127127
for state in self._domain_states.values():
128-
state.consecutive_429_count = 0
129-
state.throttled_until = _NEVER_THROTTLED
128+
state.reset_throttling()
130129

131130
@override
132131
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) ->
258257
@override
259258
async def mark_request_as_handled(self, request: Request) -> ProcessedRequest | None:
260259
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)
264261

265262
@override
266263
async def get_handled_count(self) -> int:
@@ -291,33 +288,54 @@ async def is_finished(self) -> bool:
291288
def record_domain_delay(self, url: str, *, retry_after: timedelta | None = None) -> bool:
292289
"""Record a 429 Too Many Requests response for the domain of the given URL.
293290
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.
296294
297295
Args:
298296
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.
301299
302300
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
304302
in the configured `domains` list, in which case the call is a no-op.
305303
"""
306304
state = self._get_domain_state(url)
307305
if state is None:
308306
return False
309307

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+
310320
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+
312330
if delay > self._max_delay:
313-
source = 'Retry-After header' if retry_after is not None else 'exponential backoff'
314331
logger.warning(
315332
f'Capping {source} delay of {delay.total_seconds():.1f}s for domain "{state.domain}" '
316333
f'to max_delay ({self._max_delay.total_seconds():.1f}s); the domain may continue to rate-limit. '
317334
f'Consider increasing max_delay if this recurs.'
318335
)
319336
delay = self._max_delay
320-
state.throttled_until = datetime.now(timezone.utc) + delay
337+
338+
state.apply_backoff(now, delay)
321339

322340
logger.info(
323341
f'Rate limit (429) detected for domain "{state.domain}" '
@@ -398,11 +416,11 @@ def _get_earliest_available_time(self, now: datetime) -> datetime:
398416
def _mark_domain_dispatched(self, domain: str) -> None:
399417
"""Record that a request to this domain was just dispatched.
400418
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.
402420
"""
403421
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))
406424

407425
def _signal_new_work(self) -> None:
408426
"""Wake `fetch_next_request` if it is sleeping inside a throttle wait."""
@@ -450,11 +468,41 @@ class _DomainState:
450468
domain: str
451469
"""The domain being tracked."""
452470

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

456482
consecutive_429_count: int = 0
457483
"""Number of consecutive 429 responses (for exponential backoff)."""
458484

459485
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

Comments
 (0)