Skip to content

Commit 505f824

Browse files
ikreymertw4lmistydemeo
authored
Rate Limit Docs + more flexible custom rules (#1080)
Follow-up to #1015: - Replace --rateLimitOn200MatchText with --rateLimitOnMatch to allow specifying either a '<regex> or '<regex>:<status_code>' rules to flag page as rate limited by regex and optionally by status code - Add docs for rate limit detection in user-guide/rate-limits.md --------- Co-authored-by: Tessa Walsh <tessa@bitarchivist.net> Co-authored-by: Misty De Méo <mistydemeo@gmail.com>
1 parent 698852f commit 505f824

8 files changed

Lines changed: 123 additions & 25 deletions

File tree

docs/docs/user-guide/cli-options.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,14 +357,17 @@ Options:
357357
encountered but not queued to repor
358358
ts/skippedPages.jsonl
359359
[boolean] [default: false]
360-
--rateLimitOn200MatchText Consider page rate limited given the
361-
following matches by status code an
362-
d text
363-
[array] [default: ["src=\"/_Incapsula_Resource?"]]
364360
--rateLimitStatusCodes Consider responses with these status
365361
codes to be treated as rate-limited
366362
/blocked responses
367363
[array] [default: [403,429,503]]
364+
--rateLimitOnMatch One or more rules in the format <reg
365+
ex> or <regex>:<status code> matched
366+
against page text to determine if a
367+
page is rate limited. If a status i
368+
s provided, only matches if the resp
369+
onse has the specified status
370+
[array] [default: ["src=\"/_Incapsula_Resource?:200"]]
368371
--rateLimitTimeout Time in seconds to track rate limite
369372
d count for before resetting
370373
[number] [default: 300]

docs/docs/user-guide/dedupe.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ By default, the `indexer` operation is additive only, meaning that crawl data is
8888
To remove all crawls that aren't part of `--sourceUrl` (either single WACZ or a JSON specifying multiple WACZs),
8989
also add the `--remove` flag. This will purge all data that is not being added.
9090

91-
For a complete list of indexer CLI flags, see [indexer CLI flags](cli-options/#indexer).
91+
For a complete list of indexer CLI flags, see [indexer CLI flags](cli-options.md#indexer).
9292

9393
### Committing finished crawls to the index
9494

@@ -181,4 +181,4 @@ See the [WACZ dependency section of the developer documentation](../develop/dedu
181181
If both page deduplication and [skipped page reporting](./reports.md) are enabled, pages that are skipped due to page deduplication are logged with the `duplicate` reason.
182182

183183
## Deduplication system architecture
184-
See the [developer docs for dedupe](../develop/dedupe.md) for more advanced information of the architecture of the dedupe system.
184+
See the [developer docs for dedupe](../develop/dedupe.md) for more advanced information of the architecture of the dedupe system.

docs/docs/user-guide/outputs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Additionally, the collection may include:
2424
- An `indexes/` directory containing merged [CDXJ](https://specs.webrecorder.net/cdxj/0.1.0/) index files for the crawl, if the `--generateCDX` or `--generateWACZ` arguments are provided. If the combined size of the CDXJ files in the `warc-cdx/` directory is over 50 KB, the resulting final CDXJ file will be gzipped.
2525
- A single combined gzipped [WARC](https://www.iso.org/standard/68004.html) file for the crawl, if the `--combineWARC` argument is provided.
2626
- A `crawls/` directory including YAML files describing the crawl state, if the `--saveState` argument is provided with a value of "always", or if the crawl is interrupted and `--saveState` is not set to "never". These files can be used to restart a crawl from its saved state.
27-
- A `reports/` directory containing various reports generated during the crawl. See [Reports](reports) for more info.
27+
- A `reports/` directory containing various reports generated during the crawl. See [Reports](reports.md) for more info.
2828
-
2929

3030
## Profile Outputs
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Rate Limit / CAPTCHA Page Detection
2+
3+
When crawling, it is often possible to encounter rate limits, CAPTCHAs, or auth pages that are shown instead of valid content that the crawler is intending to capture.
4+
These 'auth-walled' pages are often implemented for good reason, and bypassing such limits is outside of the scope of the crawler.
5+
6+
However, the crawler can *detect* such pages and avoid archiving 'bad'/undesirable data.
7+
8+
The crawler includes several options to help with this.
9+
10+
When pages are flagged as rate limited, the pages are still loaded in the browser, but the content is not archived.
11+
Instead, the pages may be queued to be retried at a later time or skipped altogether.
12+
13+
## Flagging Rate Limited / Auth-walled pages
14+
15+
The simplest mechanism for detection is by checking the status code. By default, the crawler now skips all pages that have a 403, 429, or 503 status code. This list can be customized with the `--rateLimitStatusCodes` setting, which accepts a list of status codes to always skip and retry.
16+
17+
The crawler also includes a custom option `--rateLimitOnMatch`, which can take additional values in the form of `<regex>` or `<regex>:<status>`, e.g. `--rateLimitOnMatch <regex-1> --rateLimitOnMatch <regex-2>:<status>`. This setting allows for custom detection of CAPTCHAs and rate-limited pages based on page text content (HTML, JS, etc...), and optionally status code, so even 200 pages can be flagged as rate limited.
18+
19+
## Rate Limit Counters
20+
21+
The crawler also keeps track of how many pages have been flagged as limited during the duration of the crawl,
22+
by combination of status code and direct fetch/browser-based indicator. These stats are stored in Redis as part of the crawl stats.
23+
24+
The crawler also keeps track of how many pages have been rate limited *within the last N seconds*, set by the `--rateLimitTimeout` and defaulting to 300 seconds (5 minutes).
25+
26+
If a site provides a `Retry-After` header, for example with a 429 response, the header value is used instead of the global default set in `--rateLimitTimeout`.
27+
28+
## Rate Limit Retry Count
29+
30+
By default, rate limited pages are retried indefinitely, allowing the crawl to possibly complete but at a much slower pace. If the pages are blocked due to auth requirements it may be possible to add a browser profile and restart the crawl, allowing for previously flagged pages to be captured successfully.
31+
32+
However, it may be desirable to set a total number of retries for pages flagged as rate limited, which can be done
33+
by setting `--rateLimitMaxRetries` to a value >= 0, where 0 implies no retries at all.
34+
35+
## Interrupting Crawl on Rate Limit Threshold
36+
37+
By default, the crawler will continue, skipping rate limited pages and retrying them indefinitely, to match existing behavior.
38+
39+
If the `--rateLimitInterruptCount M` flag is set, the crawler will exit with a rate limit exit code (exit code 21) after M rate limited pages within the N seconds, configured via `--rateLimitTimeout`.
40+
41+
This can allow a job runner or other system that starts and monitors the crawler container to implement an exponential backoff system if the crawler repeatedly exits due to being rate limited.
42+
The Browsertrix application provides this through built-in exponential backoff available in Kubernetes, where the system
43+
will restart the crawler in increasing intervals if continuously rate limited, up to once every 5 minutes.
44+
45+
Additionally, if direct fetch reaches this threshold, further direct fetches will also be skipped until the timeout expires.
46+
47+
If any page loads successfully, the rate limit counter is cleared.
48+
49+
## Caveats and Multi-Domain Crawling
50+
51+
It should be noted that the rate limits are applied globally and not per seed or per domain, and it may not always be desirable to interrupt a crawl containing many seeds across different domains when a rate limit is reached.
52+
The rate limit interrupt threshold is disabled by default for this reason.
53+
54+
For multi-domain crawling the threshold could be set to a large number so that if multiple domains are flagged as rate limited the crawl is still interrupted, while for a single domain crawl the number may be set lower.
55+
It may make sense to set `--rateLimitMaxRetries` to a fixed value so that larger crawls do eventually finish with certain pages skipped as rate limited.
56+
57+
Finally, depending on the reason for the 'auth-wall' on each domain, the crawler may or may not be able to complete the crawl on each domain.
58+
- For 429 errors and 503 errors, waiting and retrying may be successful.
59+
- For 403 errors, logging in with a browser profile may provide additional access.
60+
- Other sites and custom CAPTCHA pages may require additional permission to be allowed to crawl the sites.
61+
62+
If facing repeated errors, requesting permission may be the best approach to successfully crawl a domain.

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ nav:
5555
- user-guide/browser-profiles.md
5656
- user-guide/dedupe.md
5757
- user-guide/proxies.md
58+
- user-guide/rate-limits.md
5859
- user-guide/behaviors.md
5960
- user-guide/qa.md
6061
- user-guide/reports.md

src/util/argParser.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import {
1919
DEFAULT_MAX_RETRIES,
2020
BxFunctionBindings,
2121
DEFAULT_CRAWL_ID_TEMPLATE,
22-
RATE_LIMIT_MATCH_200,
22+
DEFAULT_RATE_LIMIT_RULES,
2323
RATE_LIMIT_TTL_SECS,
24+
RateLimitRule,
2425
ADD_REDIRECTED_SEEDS_OPTS,
2526
} from "./constants.js";
2627
import { interpolateFilename } from "./storage.js";
@@ -61,7 +62,7 @@ export type CrawlerArgs = ReturnType<typeof parseArgs> & {
6162

6263
warcInfo?: Record<string, string>;
6364

64-
rateLimitOn200MatchText: string[];
65+
rateLimitCustomRules?: RateLimitRule[];
6566
rateLimitStatusCodes: number[];
6667
};
6768

@@ -765,18 +766,18 @@ class ArgParser {
765766
default: false,
766767
},
767768

768-
rateLimitOn200MatchText: {
769+
rateLimitStatusCodes: {
769770
describe:
770-
"Consider page rate limited given the following matches by status code and text",
771+
"Consider responses with these status codes to be treated as rate-limited/blocked responses",
771772
type: "array",
772-
default: RATE_LIMIT_MATCH_200,
773+
default: [403, 429, 503],
773774
},
774775

775-
rateLimitStatusCodes: {
776+
rateLimitOnMatch: {
776777
describe:
777-
"Consider responses with these status codes to be treated as rate-limited/blocked responses",
778+
"One or more rules in the format <regex> or <regex>:<status code> matched against page text to determine if a page is rate limited. If a status is provided, only matches if the response has the specified status",
778779
type: "array",
779-
default: [403, 429, 503],
780+
default: DEFAULT_RATE_LIMIT_RULES,
780781
},
781782

782783
rateLimitTimeout: {
@@ -975,6 +976,24 @@ class ArgParser {
975976
logger.info("Updating profile on successful crawl");
976977
}
977978

979+
if (argv.rateLimitOnMatch) {
980+
const rules: RateLimitRule[] = [];
981+
for (const rule of argv.rateLimitOnMatch) {
982+
// match any status by default
983+
let status = 0;
984+
let regex = "";
985+
if (rule.match(/:[\d]+$/)) {
986+
const parts = rule.split(":");
987+
regex = parts[0];
988+
status = parseInt(parts[1]) ?? 0;
989+
} else {
990+
regex = rule;
991+
}
992+
rules.push({ regex: new RegExp(regex), status });
993+
argv.rateLimitCustomRules = rules;
994+
}
995+
}
996+
978997
return true;
979998
}
980999
}

src/util/constants.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,5 +143,10 @@ export const STATUS_UNKNOWN_ERROR = 999;
143143
// Rate Limit Constants
144144
export const RATE_LIMIT_TTL_SECS = 300;
145145

146-
// default text matches to consider rate limit on 200
147-
export const RATE_LIMIT_MATCH_200 = [`src="/_Incapsula_Resource?`];
146+
export type RateLimitRule = {
147+
regex: RegExp;
148+
status: number;
149+
};
150+
151+
// default text matches to consider rate limit
152+
export const DEFAULT_RATE_LIMIT_RULES = ['src="/_Incapsula_Resource?:200'];

src/util/recorder.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ScopedSeed } from "./seeds.js";
2929
import EventEmitter from "events";
3030
import {
3131
DEFAULT_MAX_RETRIES,
32+
RateLimitRule,
3233
SkippedReason,
3334
STATUS_IS_HTML_NO_DIRECT_FETCH,
3435
STATUS_UNKNOWN_ERROR,
@@ -182,7 +183,7 @@ export class Recorder extends EventEmitter {
182183

183184
stopping = false;
184185

185-
rateLimitOn200MatchText: string[] = [];
186+
rateLimitCustomRules: RateLimitRule[] = [];
186187
rateLimitStatusCodes: number[] = [];
187188

188189
constructor({
@@ -198,7 +199,7 @@ export class Recorder extends EventEmitter {
198199
this.workerid = workerid;
199200
this.crawler = crawler;
200201
this.crawlState = crawler.crawlState;
201-
this.rateLimitOn200MatchText = crawler.params.rateLimitOn200MatchText;
202+
this.rateLimitCustomRules = crawler.params.rateLimitCustomRules ?? [];
202203
this.rateLimitStatusCodes = crawler.params.rateLimitStatusCodes;
203204

204205
this.shouldSaveStorage = !!crawler.params.saveStorage;
@@ -1306,12 +1307,19 @@ export class Recorder extends EventEmitter {
13061307

13071308
string = payload.toString();
13081309

1309-
if (status === 200) {
1310-
for (const match of this.rateLimitOn200MatchText) {
1311-
if (string.indexOf(match) > 0) {
1312-
this.markRateLimited(status, reqresp.getHeader("Retry-After"));
1313-
return false;
1314-
}
1310+
for (const rule of this.rateLimitCustomRules) {
1311+
if (
1312+
(rule.status === 0 || rule.status === status) &&
1313+
string.search(rule.regex) >= 0
1314+
) {
1315+
const retryAfter = reqresp.getHeader("Retry-After");
1316+
logger.debug(
1317+
"Rate Limited By Custom Rule",
1318+
{ rule: rule.regex.toString(), retryAfter },
1319+
"recorder",
1320+
);
1321+
this.markRateLimited(status, retryAfter);
1322+
return false;
13151323
}
13161324
}
13171325

0 commit comments

Comments
 (0)