-
Notifications
You must be signed in to change notification settings - Fork 803
feat: Add opt-in per-domain request throttling for HTTP 429 backoff #1762
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
vdusek
merged 29 commits into
apify:master
from
MrAliHasan:fix/request-throttler-429-backoff
May 6, 2026
Merged
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
64f7247
fix: add per-domain RequestThrottler for 429 backoff (#1437)
MrAliHasan 62ab3b8
refactor: replace RequestThrottler with ThrottlingRequestManager
MrAliHasan 1065e9b
refactor: reimplement `ThrottlingRequestManager` with per-domain sub-…
MrAliHasan 138fd67
test: fix typing and linting checks in ThrottlingRequestManager tests
MrAliHasan abdf51c
feat: Add explicit domain routing and management to the request throt…
MrAliHasan dd99d9d
feat: Implement recreate_purged for ThrottlingRequestManager and refa…
MrAliHasan 497b782
deps: Pin ty to version 0.0.18 and update uv.lock to include Python 3…
MrAliHasan 902f885
fix: Ensure ThrottlingRequestManager.add_request explicitly handles N…
MrAliHasan e02dd68
refactor: Address reviewer feedback on ThrottlingRequestManager and r…
MrAliHasan 2e3493c
fix: Restore uv.lock from upstream master
MrAliHasan ac18556
fix: Add type narrowing for add_request to satisfy ty type checker
MrAliHasan 44b93bb
fix: Change add_request return type to ProcessedRequest | None
MrAliHasan 412df15
refactor: Address review feedback — proper typing, recreate_purged gu…
MrAliHasan a249a23
refactor: Add request_manager_opener callback, move record_success in…
MrAliHasan 4be7b2d
Merge branch 'master' into fix/request-throttler-429-backoff
vdusek a3f5c7c
Make `ThrottlingRequestManager` generic over inner manager type
vdusek bebf2db
Cache crawl-delay configuration per origin in `BasicCrawler`
vdusek e5fe554
Reflow docstrings to full 120-char width and fix backticks
vdusek 6dbe696
Simplify
vdusek 8f10c10
Fix flaky test
vdusek ed0dcc0
warn when capping Retry-After or backoff at max_delay
vdusek f2f47a5
make ThrottlingRequestManager sub-managers honor the generic inner type
vdusek 546d7ac
wake ThrottlingRequestManager fetch loop when new work arrives during…
vdusek 10481c4
Match throttled domains case-insensitively
vdusek fac6367
address review nits on ThrottlingRequestManager
vdusek 8acbb43
Polishment & ordering
vdusek 0f1574a
Final
vdusek b60c720
Address review comments
vdusek 851d044
Add warnings
vdusek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| # Per-domain rate limit tracker for handling HTTP 429 responses. | ||
| # See: https://github.com/apify/crawlee-python/issues/1437 | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from datetime import datetime, timedelta, timezone | ||
| from logging import getLogger | ||
| from urllib.parse import urlparse | ||
|
|
||
| from crawlee._utils.docs import docs_group | ||
|
|
||
| logger = getLogger(__name__) | ||
|
|
||
|
|
||
| @dataclass | ||
| class _DomainState: | ||
| """Tracks rate limit state for a single domain.""" | ||
|
|
||
| domain: str | ||
| """The domain being tracked.""" | ||
|
|
||
| next_allowed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) | ||
| """Earliest time the next request to this domain is allowed.""" | ||
|
|
||
| consecutive_429_count: int = 0 | ||
| """Number of consecutive 429 responses (for exponential backoff).""" | ||
|
|
||
|
|
||
| @docs_group('Crawlers') | ||
| class RequestThrottler: | ||
| """Per-domain rate limit tracker and request throttler. | ||
|
|
||
| When a target website returns HTTP 429 (Too Many Requests), this component | ||
| tracks the rate limit event per domain and applies exponential backoff. | ||
| Requests to other (non-rate-limited) domains are unaffected. | ||
|
|
||
| This solves the "death spiral" problem where 429 responses reduce CPU usage, | ||
| causing the `AutoscaledPool` to incorrectly scale UP concurrency. | ||
| """ | ||
|
|
||
| _BASE_DELAY = timedelta(seconds=2) | ||
| """Initial delay after the first 429 response from a domain.""" | ||
|
|
||
| _MAX_DELAY = timedelta(seconds=60) | ||
| """Maximum delay between requests to a rate-limited domain.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self._domain_states: dict[str, _DomainState] = {} | ||
|
|
||
| @staticmethod | ||
| def _extract_domain(url: str) -> str: | ||
| """Extract the domain (hostname) from a URL. | ||
|
|
||
| Args: | ||
| url: The URL to extract the domain from. | ||
|
|
||
| Returns: | ||
| The hostname portion of the URL, or an empty string if parsing fails. | ||
| """ | ||
| parsed = urlparse(url) | ||
| return parsed.hostname or '' | ||
|
|
||
| def record_rate_limit(self, url: str, *, retry_after: timedelta | None = None) -> None: | ||
| """Record a 429 Too Many Requests response for the domain of the given URL. | ||
|
|
||
| Increments the consecutive 429 count and calculates the next allowed | ||
| request time using exponential backoff or the Retry-After value. | ||
|
|
||
| Args: | ||
| url: The URL that received a 429 response. | ||
| retry_after: Optional delay from the Retry-After header. If provided, | ||
| it takes priority over the calculated exponential backoff. | ||
| """ | ||
| domain = self._extract_domain(url) | ||
| if not domain: | ||
| return | ||
|
|
||
| now = datetime.now(timezone.utc) | ||
|
|
||
| if domain not in self._domain_states: | ||
| self._domain_states[domain] = _DomainState(domain=domain) | ||
|
|
||
| state = self._domain_states[domain] | ||
| state.consecutive_429_count += 1 | ||
|
|
||
| # Calculate delay: use Retry-After if provided, otherwise exponential backoff. | ||
| if retry_after is not None: | ||
| delay = retry_after | ||
| else: | ||
| delay = self._BASE_DELAY * (2 ** (state.consecutive_429_count - 1)) | ||
|
|
||
| # Cap the delay at _MAX_DELAY. | ||
| if delay > self._MAX_DELAY: | ||
| delay = self._MAX_DELAY | ||
|
|
||
| state.next_allowed_at = now + delay | ||
|
|
||
| logger.info( | ||
| f'Rate limit (429) detected for domain "{domain}" ' | ||
| f'(consecutive: {state.consecutive_429_count}, delay: {delay.total_seconds():.1f}s)' | ||
| ) | ||
|
|
||
| def is_throttled(self, url: str) -> bool: | ||
| """Check if requests to the domain of the given URL should be delayed. | ||
|
|
||
| Args: | ||
| url: The URL to check. | ||
|
|
||
| Returns: | ||
| True if the domain is currently rate-limited and the cooldown has not expired. | ||
| """ | ||
| domain = self._extract_domain(url) | ||
| state = self._domain_states.get(domain) | ||
|
|
||
| if state is None: | ||
| return False | ||
|
|
||
| return datetime.now(timezone.utc) < state.next_allowed_at | ||
|
|
||
| def get_delay(self, url: str) -> timedelta: | ||
| """Get the remaining delay before the next request to this domain is allowed. | ||
|
|
||
| Args: | ||
| url: The URL to check. | ||
|
|
||
| Returns: | ||
| The remaining time to wait. Returns zero if no delay is needed. | ||
| """ | ||
| domain = self._extract_domain(url) | ||
| state = self._domain_states.get(domain) | ||
|
|
||
| if state is None: | ||
| return timedelta(0) | ||
|
|
||
| remaining = state.next_allowed_at - datetime.now(timezone.utc) | ||
| return max(remaining, timedelta(0)) | ||
|
|
||
| def record_success(self, url: str) -> None: | ||
| """Record a successful request to the domain, resetting its backoff state. | ||
|
|
||
| Args: | ||
| url: The URL that received a successful response. | ||
| """ | ||
| domain = self._extract_domain(url) | ||
| state = self._domain_states.get(domain) | ||
|
|
||
| if state is not None and state.consecutive_429_count > 0: | ||
| logger.debug(f'Resetting rate limit state for domain "{domain}" after successful request') | ||
| state.consecutive_429_count = 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.