You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Fix autoscaled pool scaling behavior on 429 Too Many Requests #1437
Imagine a general scenario where there are links to various different URLs in the request queue. You get 429 from one of those URLs, but the other unrelated URLs are not affected. Slowing down the whole crawler in general does not seem good in such a case. The crawler should probably implement some additional backoff logic on a different level without the autoscaled pool even noticing it.
That would require tracking 429s on groups of URLs and acting(back-off logic, fixed larger interval between requests on that specific site,...) on that group, without affecting different unrelated groups of URLs.
I would suggest to also not implementing this in BasicCrawler, but instead creating a dedicated component that would be only used by BasicCrawler and group related functionality there.
now we have in __run_task_function:
1 - get request
2 - possibly looks at robots.txt
3 - start processing the request, ...
I would extract the step 2 and together with this new functionality, delegate it to RequestAnalyzer (placeholder name).
What is the responsibility of RequestAnalyzer?
If desired, decide how the request should be handled based on robots.txt (optional, which can be turned on/off)
Keep track of intentionally time-blocked/ slowed down/ delayed requests. So, for example, something like this would be tracked internally:
RequestAnalyzer would implement fetch_next_request that would pick from one of the previously delayed requests if available and ready to go and some sort of add_request
And the new __run_task_function would be something like this:
0 - Check RequestAnalyzer if it has some previously delayed unhandled request that is ready to be handled now
1 - get new request if no previously fetched request is ready from RequestAnalyzer
2 - Use RequestAnalyzer only on new request to see if it belongs to one of the groups that should be delayed, or not handled at all
3 - start processing the request, if RequestAnalyzer is OK with it.
After a deep dive into the codebase, I fully agree with @Pijukatel's analysis: this should be handled at the request level, not the autoscaled pool level. A global concurrency reduction would punish unrelated domains that aren't rate-limited.
What I built:
A new RequestThrottler component (standalone module at src/crawlee/_request_throttler.py) that:
Respects Retry-After headers — parses both integer seconds and HTTP-date formats
Reclaims throttled requests — if a domain is rate-limited, the request goes back to the queue; other domains continue normally
Resets on success — consecutive 429 count resets when a request to that domain succeeds
The AutoscaledPool is completely untouched — the throttling happens transparently in BasicCrawler.__run_task_function before the request is processed.
Integration points:
_basic_crawler.py — throttle check before processing, 429 recording via _raise_for_session_blocked_status_code, success reset on RequestState.DONE
_abstract_http_crawler.py / _playwright_crawler.py — pass URL + Retry-After header to the detection method
Tests: 13 new unit tests passing (domain independence, exponential backoff, max delay cap, Retry-After priority, success reset, etc.) + all 8 existing autoscaling tests pass with zero regressions.
I see this as a focused first step toward the full RequestAnalyzer that @Pijukatel outlined (with robots.txt integration, URL group management, etc.) — keeping scope tight for reviewability.
Happy to open a PR if you'd like to review. Let me know if you'd prefer any changes to the approach.
Hey @vdusek
You can also try pyresilience. That is exactly the kind of overload loop pyresilience is trying to prevent with backoff, jitter, circuit breaking, and shared bulkhead/rate-limit controls. This library have all the 7 resilience patterns, wit 1 decorator.
Activity
janbuchar commentedon Sep 30, 2025
At the moment, the AutoscaledPool only cares about 429s returned by the Apify API. We never tried to prevent clobbering the target website.
Pijukatel commentedon Oct 3, 2025
Imagine a general scenario where there are links to various different URLs in the request queue. You get 429 from one of those URLs, but the other unrelated URLs are not affected. Slowing down the whole crawler in general does not seem good in such a case. The crawler should probably implement some additional backoff logic on a different level without the autoscaled pool even noticing it.
That would require tracking 429s on groups of URLs and acting(back-off logic, fixed larger interval between requests on that specific site,...) on that group, without affecting different unrelated groups of URLs.
Pijukatel commentedon Oct 3, 2025
I would suggest to also not implementing this in
BasicCrawler, but instead creating a dedicated component that would be only used by BasicCrawler and group related functionality there.now we have in
__run_task_function:I would extract the step 2 and together with this new functionality, delegate it to
RequestAnalyzer(placeholder name).What is the responsibility of
RequestAnalyzer?RequestAnalyzerwould implementfetch_next_requestthat would pick from one of the previously delayed requests if available and ready to go and some sort ofadd_requestAnd the new
__run_task_functionwould be something like this:MrAliHasan commentedon Feb 20, 2026
Hi @vdusek @janbuchar @Pijukatel — I've implemented a fix for this.
After a deep dive into the codebase, I fully agree with @Pijukatel's analysis: this should be handled at the request level, not the autoscaled pool level. A global concurrency reduction would punish unrelated domains that aren't rate-limited.
What I built:
A new RequestThrottler component (standalone module at
src/crawlee/_request_throttler.py) that:The AutoscaledPool is completely untouched — the throttling happens transparently in
BasicCrawler.__run_task_functionbefore the request is processed.Integration points:
_basic_crawler.py— throttle check before processing, 429 recording via_raise_for_session_blocked_status_code, success reset onRequestState.DONE_abstract_http_crawler.py/_playwright_crawler.py— pass URL + Retry-After header to the detection methodTests: 13 new unit tests passing (domain independence, exponential backoff, max delay cap, Retry-After priority, success reset, etc.) + all 8 existing autoscaling tests pass with zero regressions.
I see this as a focused first step toward the full RequestAnalyzer that @Pijukatel outlined (with robots.txt integration, URL group management, etc.) — keeping scope tight for reviewability.
Happy to open a PR if you'd like to review. Let me know if you'd prefer any changes to the approach.
fix: add per-domain RequestThrottler for 429 backoff (apify#1437)
refactor: replace RequestThrottler with ThrottlingRequestManager
AhsanSheraz commentedon Mar 24, 2026
Hey @vdusek
You can also try pyresilience. That is exactly the kind of overload loop pyresilience is trying to prevent with backoff, jitter, circuit breaking, and shared bulkhead/rate-limit controls. This library have all the 7 resilience patterns, wit 1 decorator.
Pypi: https://pypi.org/project/pyresilience/
Docs: https://pyresilience.readthedocs.io
feat: Add opt-in per-domain request throttling for HTTP 429 backoff (#…