Skip to content

Commit c17f4d5

Browse files
MrAliHasanvdusek
andauthored
feat: Add opt-in per-domain request throttling for HTTP 429 backoff (#1762)
Closes #1437 ### Problem When target websites return HTTP 429 (Too Many Requests), requests get retried without any **per-domain delay** — potentially making rate limiting worse. ### Solution Introduces the `ThrottlingRequestManager`, an **opt-in** request manager wrapper that enforces per-domain delays at the scheduling layer. > **Note:** Users must explicitly pass a `ThrottlingRequestManager` as the `request_manager` to enable throttling. There is no auto-wrapping or implicit behavior change. **Key features:** - **Per-domain sub-queues** — requests for configured domains are routed to dedicated sub-queues at insertion time - **HTTP 429 backoff** — `record_domain_delay()` sets a per-domain `throttled_until` timestamp based on `Retry-After` headers - **robots.txt crawl-delay** — `BasicCrawler` automatically calls `set_crawl_delay()` when `respect_robots_txt_file` is enabled and the request manager is a `ThrottlingRequestManager` - **Crawl-delay warning** — logs a warning if `respect_robots_txt_file` is enabled but the request manager is **not** a `ThrottlingRequestManager` - **Delay-aware scheduling** — `fetch_next_request()` skips throttled domains, falls back to the inner queue, and sleeps only when all sub-queues are throttled and the inner queue is empty - **Persistence support** — sub-queues use the same storage backend as the inner queue; `recreate_purged()` handles queue reconstruction across crawler restarts ### How it works 1. Requests are routed to **domain-specific sub-queues** at insertion time 2. If a domain is throttled (`throttled_until`), `fetch_next_request()` skips it and falls back to the inner queue 3. `record_domain_delay()` updates per-domain backoff on HTTP 429 responses, respecting `Retry-After` headers 4. `set_crawl_delay()` integrates robots.txt crawl-delay when enabled 5. On successful requests, backoff counters reset ### Usage ```python from crawlee.request_loaders import ThrottlingRequestManager from crawlee.storages import RequestQueue from crawlee.crawlers import BasicCrawler queue = await RequestQueue.open() manager = ThrottlingRequestManager( queue, domains=["example.com", "api.example.com"] ) crawler = BasicCrawler(request_manager=manager) ``` ### Files changed | File | Change | | -------------------------------------- | --------------------------------------------------------------------------------------- | | `_throttling_request_manager.py` | **NEW** — Per-domain throttling request manager | | `http.py` | `parse_retry_after_header` utility | | `_basic_crawler.py` | `recreate_purged()` integration, crawl-delay warning | | `_playwright_crawler.py` | Pass `Retry-After` header | | `test_throttling_request_manager.py` | **NEW** — 30 unit tests using real `RequestQueue` with `MemoryStorageClient` | ### Tests - **30 new tests** covering: domain routing, throttling, delay scheduling, sleep behavior, delegation, `recreate_purged()`, and edge cases - **All 1648 existing tests** pass with zero regressions ### Future work This is a focused first step toward a more complete `RequestAnalyzer` that may include: - robots.txt integration for multiple domains - URL group management - Enhanced per-domain scheduling and analytics --------- Co-authored-by: Vlada Dusek <v.dusek96@gmail.com>
1 parent b0f6c9f commit c17f4d5

14 files changed

Lines changed: 1397 additions & 27 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import asyncio
2+
3+
from crawlee.crawlers import BasicCrawler, BasicCrawlingContext
4+
from crawlee.request_loaders import ThrottlingRequestManager
5+
from crawlee.storages import RequestQueue
6+
7+
8+
async def main() -> None:
9+
# Open the default request queue.
10+
queue = await RequestQueue.open()
11+
12+
# Wrap it with ThrottlingRequestManager for specific domains. The throttler uses the
13+
# same storage backend as the underlying queue.
14+
throttler = ThrottlingRequestManager(
15+
queue,
16+
domains=['api.example.com', 'slow-site.org'],
17+
request_manager_opener=RequestQueue.open,
18+
)
19+
20+
# Pass the throttler as the crawler's request manager.
21+
crawler = BasicCrawler(request_manager=throttler)
22+
23+
@crawler.router.default_handler
24+
async def handler(context: BasicCrawlingContext) -> None:
25+
context.log.info(f'Processing {context.request.url}')
26+
27+
# Add requests. Listed domains are routed directly to their throttled sub-managers.
28+
# Others go to the inner manager.
29+
await throttler.add_requests(
30+
[
31+
'https://api.example.com/data',
32+
'https://api.example.com/users',
33+
'https://slow-site.org/page1',
34+
'https://fast-site.com/page1', # Not throttled
35+
]
36+
)
37+
38+
await crawler.run()
39+
40+
41+
if __name__ == '__main__':
42+
asyncio.run(main())

docs/guides/request_throttling.mdx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
id: request-throttling
3+
title: Request throttling
4+
description: How to throttle requests per domain using the ThrottlingRequestManager.
5+
---
6+
7+
import ApiLink from '@site/src/components/ApiLink';
8+
import RunnableCodeBlock from '@site/src/components/RunnableCodeBlock';
9+
10+
import ThrottlingExample from '!!raw-loader!roa-loader!./code_examples/request_throttling/throttling_example.py';
11+
12+
When crawling websites that enforce rate limits (HTTP 429) or specify `crawl-delay` in their `robots.txt`, you need a way to throttle requests per domain without blocking unrelated domains. The <ApiLink to="class/ThrottlingRequestManager">`ThrottlingRequestManager`</ApiLink> provides exactly this.
13+
14+
## Overview
15+
16+
The <ApiLink to="class/ThrottlingRequestManager">`ThrottlingRequestManager`</ApiLink> wraps a <ApiLink to="class/RequestManager">`RequestManager`</ApiLink> (typically a <ApiLink to="class/RequestQueue">`RequestQueue`</ApiLink>) and manages per-domain throttling. You specify which domains to throttle at initialization, and the manager automatically:
17+
18+
- **Routes requests** for listed domains into dedicated sub-managers at insertion time.
19+
- **Enforces delays** from HTTP 429 responses (exponential backoff) and `robots.txt` crawl-delay directives.
20+
- **Schedules fairly** by fetching from the domain that has been waiting the longest.
21+
- **Sleeps intelligently** when all configured domains are throttled, instead of busy-waiting.
22+
23+
Requests for domains **not** in the configured list pass through to the main queue without any throttling.
24+
25+
## Basic usage
26+
27+
To use request throttling, create a <ApiLink to="class/ThrottlingRequestManager">`ThrottlingRequestManager`</ApiLink> with the domains you want to throttle and pass it as the `request_manager` to your crawler:
28+
29+
<RunnableCodeBlock className="language-python" language="python">
30+
{ThrottlingExample}
31+
</RunnableCodeBlock>
32+
33+
## How it works
34+
35+
1. **Insertion-time routing**: When you add requests via `add_request` or `add_requests`, each request is checked against the configured domain list. Matching requests go directly into a per-domain sub-manager; all others go to the inner manager. This eliminates request duplication entirely.
36+
37+
2. **429 backoff**: When the crawler detects an HTTP 429 response, the `ThrottlingRequestManager` records an exponential backoff delay for that domain (starting at 2s, doubling up to 60s). If the response includes a `Retry-After` header, that value takes priority.
38+
39+
3. **Crawl-delay**: If `robots.txt` specifies a `crawl-delay`, the manager enforces a minimum interval between requests to that domain.
40+
41+
4. **Fair scheduling**: `fetch_next_request` sorts available sub-managers by how long each domain has been waiting, ensuring no domain is starved.
42+
43+
:::tip
44+
45+
The `ThrottlingRequestManager` is an opt-in feature. If you don't pass it to your crawler, requests are processed normally without any per-domain throttling.
46+
47+
:::

src/crawlee/_autoscaling/snapshotter.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@
22

33
from __future__ import annotations
44

5-
import functools
65
from bisect import insort
76
from datetime import datetime, timedelta, timezone
8-
from logging import getLogger
7+
from logging import WARNING, getLogger
98
from typing import TYPE_CHECKING, TypeVar, cast
109

1110
from crawlee import service_locator
1211
from crawlee._autoscaling._types import ClientSnapshot, CpuSnapshot, EventLoopSnapshot, MemorySnapshot, Ratio, Snapshot
1312
from crawlee._utils.byte_size import ByteSize
1413
from crawlee._utils.context import ensure_context
1514
from crawlee._utils.docs import docs_group
15+
from crawlee._utils.log import LoggerOnce
1616
from crawlee._utils.recurring_task import RecurringTask
1717
from crawlee._utils.system import MemoryInfo, MemoryUsageInfo, get_memory_info
1818
from crawlee.events._types import Event, EventSystemInfoData
@@ -23,16 +23,11 @@
2323
from crawlee.configuration import Configuration
2424

2525
logger = getLogger(__name__)
26+
logger_once = LoggerOnce(logger)
2627

2728
T = TypeVar('T', bound=Snapshot)
2829

2930

30-
@functools.lru_cache
31-
def _warn_once(warning_message: str) -> None:
32-
"""Log a warning message only once."""
33-
logger.warning(warning_message)
34-
35-
3631
class SortedSnapshotList(list[T]):
3732
"""A list that maintains sorted order by `created_at` attribute for snapshot objects."""
3833

@@ -303,9 +298,11 @@ async def _snapshot_memory(self, event_data: EventSystemInfoData) -> None:
303298
# This is just hypothetical case, that will most likely not happen in practice.
304299
# `LocalEventManager` should always provide `MemoryInfo` in the event data.
305300
# When running on Apify, `self._max_memory_size` is always `ByteSize`, not `Ratio`.
306-
_warn_once(
301+
logger_once.log(
307302
'It is recommended that a custom implementation of `LocalEventManager` emits `SYSTEM_INFO` events '
308-
'with `MemoryInfo` and not just `MemoryUsageInfo`.'
303+
'with `MemoryInfo` and not just `MemoryUsageInfo`.',
304+
key='memory_usage_info_event',
305+
level=WARNING,
309306
)
310307
max_memory_size = get_memory_info().total_size * ratio.value
311308
system_wide_used_size = None

src/crawlee/_utils/http.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""HTTP utility functions for Crawlee."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime, timedelta, timezone
6+
from email.utils import parsedate_to_datetime
7+
from logging import getLogger
8+
9+
logger = getLogger(__name__)
10+
11+
12+
def parse_retry_after_header(value: str | None) -> timedelta | None:
13+
"""Parse the Retry-After HTTP header value.
14+
15+
The header can contain either a number of seconds or an HTTP-date.
16+
See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
17+
18+
Args:
19+
value: The raw Retry-After header value.
20+
21+
Returns:
22+
A timedelta representing the delay, or None if the header is missing or unparsable.
23+
"""
24+
if not value:
25+
return None
26+
27+
try:
28+
return timedelta(seconds=int(value))
29+
except ValueError:
30+
pass
31+
32+
try:
33+
retry_date = parsedate_to_datetime(value)
34+
# `parsedate_to_datetime` may return a naive datetime when the input has no timezone info.
35+
# Treat such values as UTC — HTTP-dates are GMT per RFC 7231.
36+
if retry_date.tzinfo is None:
37+
retry_date = retry_date.replace(tzinfo=timezone.utc)
38+
39+
delay = retry_date - datetime.now(timezone.utc)
40+
if delay.total_seconds() > 0:
41+
return delay
42+
logger.debug(f'Retry-After HTTP-date {value!r} is in the past; ignoring.')
43+
except (ValueError, TypeError):
44+
pass
45+
46+
return None

src/crawlee/_utils/log.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
6+
class LoggerOnce:
7+
"""Emits each log message at most once, keyed by an explicit string.
8+
9+
Useful for diagnostic warnings that would otherwise spam the log when the same condition recurs (per-request
10+
misconfiguration warnings, repeated fallback paths, etc.). Deduplication scope follows the lifetime of the
11+
instance — a module-level instance gives process-wide dedup; an attribute on a class gives per-instance dedup.
12+
"""
13+
14+
def __init__(self, logger: logging.Logger) -> None:
15+
self._logger = logger
16+
self._seen: set[str] = set()
17+
18+
def log(self, message: str, *, key: str, level: int = logging.INFO) -> None:
19+
"""Log `message` at `level` the first time `key` is seen on this instance; later calls are no-ops.
20+
21+
Args:
22+
message: The message to log.
23+
key: Deduplication key. Two calls with the same key emit at most once.
24+
level: Standard `logging` level (e.g. `logging.WARNING`). Defaults to `logging.INFO`.
25+
"""
26+
if key in self._seen:
27+
return
28+
self._seen.add(key)
29+
self._logger.log(level, message)

src/crawlee/crawlers/_abstract_http/_abstract_http_crawler.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,12 @@ async def _handle_status_code_response(
304304
"""
305305
status_code = context.http_response.status_code
306306
if self._retry_on_blocked:
307-
self._raise_for_session_blocked_status_code(context.session, status_code)
307+
self._raise_for_session_blocked_status_code(
308+
context.session,
309+
status_code,
310+
request_url=context.request.url,
311+
retry_after_header=context.http_response.headers.get('retry-after'),
312+
)
308313
self._raise_for_error_status_code(status_code)
309314
yield context
310315

src/crawlee/crawlers/_basic/_basic_crawler.py

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Inspiration: https://github.com/apify/crawlee/blob/v3.7.3/packages/basic-crawler/src/internals/basic-crawler.ts
2+
23
from __future__ import annotations
34

45
import asyncio
@@ -13,6 +14,7 @@
1314
from contextlib import AsyncExitStack, suppress
1415
from datetime import timedelta
1516
from functools import partial
17+
from http import HTTPStatus
1618
from io import StringIO
1719
from pathlib import Path
1820
from typing import TYPE_CHECKING, Any, Generic, Literal, ParamSpec, cast
@@ -43,6 +45,8 @@
4345
)
4446
from crawlee._utils.docs import docs_group
4547
from crawlee._utils.file import atomic_write, export_csv_to_stream, export_json_to_stream
48+
from crawlee._utils.http import parse_retry_after_header
49+
from crawlee._utils.log import LoggerOnce
4650
from crawlee._utils.recurring_task import RecurringTask
4751
from crawlee._utils.robots import RobotsTxtFile
4852
from crawlee._utils.urls import UNSUPPORTED_SCHEME_MESSAGE, convert_to_absolute_url, filter_url, is_url_absolute
@@ -61,6 +65,7 @@
6165
)
6266
from crawlee.events._types import Event, EventCrawlerStatusData
6367
from crawlee.http_clients import ImpitHttpClient
68+
from crawlee.request_loaders import ThrottlingRequestManager
6469
from crawlee.router import Router
6570
from crawlee.sessions import SessionPool
6671
from crawlee.statistics import Statistics, StatisticsState
@@ -491,18 +496,18 @@ async def persist_state_factory() -> KeyValueStore:
491496
run_task_function=self.__run_task_function,
492497
)
493498
self._crawler_state_rec_task = RecurringTask(
494-
func=self._crawler_state_task, delay=status_message_logging_interval
499+
func=self._crawler_state_task,
500+
delay=status_message_logging_interval,
495501
)
496502
self._previous_crawler_state: TStatisticsState | None = None
497503

498504
# State flags
499505
self._keep_alive = keep_alive
500506
self._running = False
501507
self._has_finished_before = False
502-
503508
self._failed = False
504-
505509
self._unexpected_stop = False
510+
self._logger_once = LoggerOnce(self._logger)
506511

507512
@property
508513
def log(self) -> logging.Logger:
@@ -697,19 +702,22 @@ async def run(
697702

698703
self._running = True
699704

705+
if self._respect_robots_txt_file and not isinstance(self._request_manager, ThrottlingRequestManager):
706+
self._logger.warning(
707+
'The `respect_robots_txt_file` option is enabled, but the crawler is not using '
708+
'`ThrottlingRequestManager`. Crawl-delay directives from robots.txt will not be enforced. To enable '
709+
'crawl-delay support, configure the crawler to use `ThrottlingRequestManager` as the request manager.'
710+
)
711+
700712
if self._has_finished_before:
701713
await self._statistics.reset()
702714

703715
if self._use_session_pool:
704716
await self._session_pool.reset_store()
705717

706-
request_manager = await self.get_request_manager()
707-
if purge_request_queue and isinstance(request_manager, RequestQueue):
708-
await request_manager.drop()
709-
self._request_manager = await RequestQueue.open(
710-
storage_client=self._service_locator.get_storage_client(),
711-
configuration=self._service_locator.get_configuration(),
712-
)
718+
if purge_request_queue:
719+
request_manager = await self.get_request_manager()
720+
await request_manager.purge()
713721

714722
if requests is not None:
715723
await self.add_requests(requests)
@@ -1535,16 +1543,51 @@ def _raise_for_error_status_code(self, status_code: int) -> None:
15351543
if is_status_code_server_error(status_code) and not is_ignored_status:
15361544
raise HttpStatusCodeError('Error status code returned', status_code)
15371545

1538-
def _raise_for_session_blocked_status_code(self, session: Session | None, status_code: int) -> None:
1546+
def _raise_for_session_blocked_status_code(
1547+
self,
1548+
session: Session | None,
1549+
status_code: int,
1550+
*,
1551+
request_url: str,
1552+
retry_after_header: str | None = None,
1553+
) -> None:
15391554
"""Raise an exception if the given status code indicates the session is blocked.
15401555
1556+
If the status code is 429 (Too Many Requests), the domain is recorded as rate-limited in the
1557+
`ThrottlingRequestManager` for per-domain backoff.
1558+
15411559
Args:
1542-
session: The session used for the request. If None, no check is performed.
1560+
session: The session used for the request. If `None`, no check is performed.
15431561
status_code: The HTTP status code to check.
1562+
request_url: The request URL, used for per-domain rate limit tracking.
1563+
retry_after_header: The value of the `Retry-After` response header, if present.
15441564
15451565
Raises:
15461566
SessionError: If the status code indicates the session is blocked.
15471567
"""
1568+
if status_code == HTTPStatus.TOO_MANY_REQUESTS:
1569+
if isinstance(self._request_manager, ThrottlingRequestManager):
1570+
retry_after = parse_retry_after_header(retry_after_header)
1571+
if not self._request_manager.record_domain_delay(request_url, retry_after=retry_after):
1572+
domain = (URL(request_url).host or '').lower()
1573+
if domain:
1574+
self._logger_once.log(
1575+
f'Received an HTTP 429 (Too Many Requests) response from domain "{domain}", but it is '
1576+
f'not in the `ThrottlingRequestManager.domains` list. Per-domain backoff will not be '
1577+
f'applied for this domain. Add it to `domains=` to enable throttling.',
1578+
key=f'unconfigured_throttle_domain:{domain}',
1579+
level=logging.WARNING,
1580+
)
1581+
else:
1582+
self._logger_once.log(
1583+
'Received an HTTP 429 (Too Many Requests) response, but the crawler is not using '
1584+
'`ThrottlingRequestManager`. Per-domain backoff and `Retry-After` headers will not be honored. '
1585+
'To enable per-domain rate limiting, configure the crawler to use `ThrottlingRequestManager` '
1586+
'as the request manager.',
1587+
key='no_throttling_manager_on_429',
1588+
level=logging.WARNING,
1589+
)
1590+
15481591
if session is not None and session.is_blocked_status_code(
15491592
status_code=status_code,
15501593
ignore_http_error_status_codes=self._ignore_http_error_status_codes,
@@ -1575,7 +1618,15 @@ async def _is_allowed_based_on_robots_txt_file(self, url: str) -> bool:
15751618
if not self._respect_robots_txt_file:
15761619
return True
15771620
robots_txt_file = await self._get_robots_txt_file_for_url(url)
1578-
return not robots_txt_file or robots_txt_file.is_allowed(url)
1621+
if not robots_txt_file:
1622+
return True
1623+
1624+
if isinstance(self._request_manager, ThrottlingRequestManager):
1625+
crawl_delay = robots_txt_file.get_crawl_delay()
1626+
if crawl_delay is not None:
1627+
self._request_manager.set_crawl_delay(url, crawl_delay)
1628+
1629+
return robots_txt_file.is_allowed(url)
15791630

15801631
async def _get_robots_txt_file_for_url(self, url: str) -> RobotsTxtFile | None:
15811632
"""Get the RobotsTxtFile for a given URL.

0 commit comments

Comments
 (0)