Skip to content

feat: implement per-domain request throttling (ThrottlingRequestManager) - #3741

Merged
janbuchar merged 33 commits into
apify:v4from
harryautomazione:v4
Aug 10, 2026
Merged

feat: implement per-domain request throttling (ThrottlingRequestManager)#3741
janbuchar merged 33 commits into
apify:v4from
harryautomazione:v4

Conversation

@harryautomazione

Copy link
Copy Markdown
Contributor

Summary

This PR implements per-domain request rate-limiting and delay logic at the RequestManager layer. It ports the design from Python's Crawlee (PR #1762) to the TypeScript v4 branch, addressing the architectural feedback from PR #3737.

Context & Rationale

Previously, receiving an HTTP 429 status code triggered a SessionError which retired and rotated the proxy/session immediately, leading to high IP churn and fast session depletion.
By moving the throttling to the request manager layer:

  • The crawler now respects Retry-After headers and applies exponential backoff delays precisely to the affected domains.
  • Active sessions/proxies are preserved during transient rate limits, preventing session burning.
  • The wrapper is completely opt-in and backward-compatible.

Key Changes

  • ThrottlingRequestManager (packages/core/src/storages/throttling_request_manager.ts): Implemented a new storage wrapper that implements IRequestManager. It routes domain-specific requests to sub-managers, manages backoff delays, and runs a wake/sleep loop in fetchNextRequest to avoid busy waiting.
  • Crawler Interception:
    • HttpCrawler & BrowserCrawler: Intercepted HTTP 429 status codes, extracted retry-after, registered the delay with ThrottlingRequestManager, and forced a standard retry (avoiding session retirement).
    • BasicCrawler: Integrated with RobotsTxtFile to register the crawl-delay when respectRobotsTxtFile is active.
  • RobotsTxtFile (packages/utils/src/internals/robots.ts): Exposed getCrawlDelay from robots-parser.

Verification Results

  • Unit Tests: Created a full test suite under test/core/storages/throttling_request_manager.test.ts covering routing, exponential backoff, headers parsing, and crawl-delay settings (5/5 passed).
  • Integration Tests: Added tests in test/core/crawlers/http_crawler.test.ts verifying that HTTP 429 delays are respected and session is not retired (17/17 passed).
  • Build: Successfully built the entire workspace with all package declarations compilation checks passed.

`requestManager` was missing from `optionsShape`, so `ow.object.exactShape`
rejected the documented option outright; nothing covered it. Adds the
regression test the drive-by fix in this branch never got.
…azily

Sub-queues live under a stable `throttled-<domain>` alias and outlive the
process, but the map tracking them was only filled on insert - so a restart
saw none, reported the crawl finished, and stranded everything a previous run
had left throttled. Also fixes `purge` and `persistState` skipping them.
`fetchNextRequest` slept until the backoff expired (up to `maxDelayMs`),
holding a task slot the autoscaler reads as spare capacity - so it scaled up
toward `maxConcurrency` and released the lot at once, and `stop()` could not
complete. It now returns `null` and `isEmpty()` reports throttled requests as
unavailable, so the task loop idles instead. Drops the single-waiter wake
primitive, which could only ever wake one of several concurrent callers.
The hand-rolled `addRequestsBatched` drained its whole input up front, ignored
`maxNewRequests`, never reported `requestsOverLimit`, dropped the in-flight
batch count `isFinished()` depends on, and left its background promise
unhandled so a backend error killed the process. It now chunks lazily, routes
each chunk by domain, and hands the slices to the target managers' own
`addRequestsBatched`. Drops the public `addRequests`, which only existed to
support the duck-typed fallback and is not part of `IRequestManager`.
A URL list's contents are unknown until the owning manager expands it, so
those requests always land in the inner manager and silently escape
throttling. Say so once instead of pretending they were routed.
…onse

Every already-in-flight request comes back 429, so counting each one made the
exponent track concurrency (8 parallel requests jumped straight to 2^7). The
counter now advances once per event and decays on its own after a quiet
window, which also removes the `errorMessages`/`retryCount` success guess -
that read as success for skipped requests and silently flattened the curve.
A plain `Error` charged the 429 to `maxRequestRetries` and to the session's
error score, so a healthy but rate-limited domain still failed its requests
and still burned proxies - the two things the throttling manager exists to
avoid. Throwing `RequestThrottledError` instead reclaims the request without
recording a failure, and the manager paces the retry behind the backoff.
…opped

The startup warning read a tandem's unresolved inner manager, so it fired for
correctly configured crawlers whenever `run()` got no URLs - while staying
silent when a `ThrottlingRequestManager` was configured without the crawled
domain in its `domains` list. `setCrawlDelay` now reports whether it took
effect and the warning follows that, naming the domain at fault.
The `'x' in y && typeof (y as any).x === 'function'` dance was repeated at both
call sites, and the browser one hand-rolled a case-insensitive header scan plus
an array unwrap that neither Playwright nor Puppeteer can produce. Also records
the rate limit before the error-status throw, so opting 429 into
`additionalHttpErrorStatusCodes` no longer skips the backoff.
`String(parseInt(v)) === v.trim()` rejected valid zero-padded values like `05`
and accepted `-5`, which set a delay in the past - reporting the domain as
throttled while applying no backoff at all. Also drops a try/catch around
`Date.parse`, which does not throw.
A 526-line public class shipped with no doc comment at all, so its defaults,
its opt-in nature and the meaning of `recordDomainDelay`'s return value were
undiscoverable. Also removes a field only the constructor read.
A new public request manager was absent from the request-loaders guide that
indexes them, and it silently changes what a 429 does - which until now the
session-management guide was the only place to describe.
`pnpm api:check` runs in CI and the new exports were never recorded, so the
check failed deterministically. Also narrows the constructor's `config`
parameter to private - it was leaking into the public surface unintentionally.
A throttled 429 costs no retry, so a domain that never recovered kept the crawl
alive forever - `run()` simply never resolved, while the statistics reported
zero requests throughout. Each domain now carries a stall clock, reset whenever
one of its requests is handled, and the crawler aborts with a
`PersistentRateLimitError` once a domain that still has queued work has been
rate-limiting for `maxDomainStallSecs`. Those requests are deliberately left in
their queue, so re-running without purging storages resumes them if the limit
lifts.

Also renames the manager's delay options to seconds, matching the rest of the
crawler options, and replaces the `Partial<ThrottlingRequestManager>` casts in
`BasicCrawler` with a `SupportsDomainThrottling` guard. That drops the tandem's
two forwarded methods: a tandem-wrapped throttler no longer throttles, but the
existing warnings say so rather than leaving it silent.
`ThrottlingRequestManager.addRequestsBatched` was a near-verbatim copy of
`RequestQueue`'s: same budget-derived chunk size, same peek/next dance, same
over-limit drain. The copy had already fallen behind - the transaction handling
`RequestQueue` gained never reached it - so the loop now lives in
`drainRequestBatches` and both callers supply only what actually differs, namely
how a chunk is added and how the input is normalized.

Fixes a latent bug in the process: `RequestQueue` ran its background batches in
an `async` promise executor, which swallows throws. A failing background batch
therefore left `waitForAllRequestsToBeAdded` pending forever and never
decremented `inProgressRequestBatchCount`, so the queue reported itself unfinished
indefinitely - now it rejects and settles.
…es the manager

The test asserted that no warning was logged and that `setCrawlDelay` still
returned `true` - both of which hold when the domain is merely configured. It
passed with `applyCrawlDelay` deleted outright. Now it checks the recorded delay
and that dispatch is paced by it.
A 429 from a domain a `ThrottlingRequestManager` covers is treated as a rate
limit before `blockedStatusCodes` is consulted, so the list only governs the
domains it does not cover. Worth saying plainly: removing 429 from the list is
the obvious-looking way to stop sessions being retired when adopting throttling,
and it neither helps nor is needed.
- `parseRetryAfterHeader` was public API exported from a storages module despite
  being a plain HTTP header parser, so it moves to `http.ts` alongside its test.
- Adds `innerManager`, so a caller that hands the wrapper its queue can still get
  the queue back, matching crawlee-python's `inner`.
- `domains` now rejects empty strings instead of quietly dropping them, which
  turned a typo into a silently unthrottled domain.
- Makes the two remaining time-sensitive tests deterministic: the backoff decay
  test slept out 20ms windows and flaked under load, and the purge test never
  checked the sub-queue was non-empty to begin with.
Reading the `Retry-After` header cast the response to a shape asserting the
method exists and then optional-called it anyway. `BaseResponse` describes what
the crawler relies on, so the method belongs there - optional, because only the
Playwright and Puppeteer responses are guaranteed to carry it.
`isEmpty` answers what the next fetch would return, not how much work is left, so
a loader that withholds requests for a while - as `ThrottlingRequestManager` does
for a rate-limited domain - reports empty while requests are still queued.

@janbuchar janbuchar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but I changed the original a lot @harryautomazione, can you check it? @barjin, please, can you provide a fresh pair of eyes?

@janbuchar
janbuchar requested a review from barjin August 9, 2026 19:28

@barjin barjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you both! I don't have anything to add (other than the existing issues). Approving 👍

@janbuchar
janbuchar merged commit 69d0629 into apify:v4 Aug 10, 2026
8 checks passed
janbuchar added a commit that referenced this pull request Aug 12, 2026
- closes #3998

Unnamed storages (default and aliased) are now purged on start, named
ones are not — crawlee-python's rule. The fs backend sweeps the storage
directories, so leftovers from a previous process are caught too.

Two adjacent bugs, one commit each:
- `createDatasetBackend()` / `({})` didn't open the default storage,
though `StorageIdentifier` says they do
- the `__default__` sentinel leaked into the directory name, so default
storages lived in `storage/datasets/__default__` rather than `default`

`ThrottlingRequestManager` (#3741) sub-queues are alias-keyed, so they
now only survive a restart with `purgeOnStart` off — as its docs already
said.
B4nan pushed a commit that referenced this pull request Aug 12, 2026
…er) (#3741)

Co-authored-by: Jan Buchar <jan@buchar.dev>
B4nan pushed a commit that referenced this pull request Aug 12, 2026
- closes #3998

Unnamed storages (default and aliased) are now purged on start, named
ones are not — crawlee-python's rule. The fs backend sweeps the storage
directories, so leftovers from a previous process are caught too.

Two adjacent bugs, one commit each:
- `createDatasetBackend()` / `({})` didn't open the default storage,
though `StorageIdentifier` says they do
- the `__default__` sentinel leaked into the directory name, so default
storages lived in `storage/datasets/__default__` rather than `default`

`ThrottlingRequestManager` (#3741) sub-queues are alias-keyed, so they
now only survive a restart with `purgeOnStart` off — as its docs already
said.
B4nan pushed a commit that referenced this pull request Aug 18, 2026
…er) (#3741)

Co-authored-by: Jan Buchar <jan@buchar.dev>
B4nan pushed a commit that referenced this pull request Aug 18, 2026
- closes #3998

Unnamed storages (default and aliased) are now purged on start, named
ones are not — crawlee-python's rule. The fs backend sweeps the storage
directories, so leftovers from a previous process are caught too.

Two adjacent bugs, one commit each:
- `createDatasetBackend()` / `({})` didn't open the default storage,
though `StorageIdentifier` says they do
- the `__default__` sentinel leaked into the directory name, so default
storages lived in `storage/datasets/__default__` rather than `default`

`ThrottlingRequestManager` (#3741) sub-queues are alias-keyed, so they
now only survive a restart with `purgeOnStart` off — as its docs already
said.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve sameDomainDelaySecs implementation

4 participants