Skip to content

Commit 2c8b243

Browse files
Merge pull request #61 from StepenkoAnatoli/fix/respect-rate-limit
A rate limit is an instruction to wait, and the collector now reads it
2 parents 463df67 + e872b12 commit 2c8b243

7 files changed

Lines changed: 130 additions & 7 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ each line — is in the ADR named beside it and in
426426
| A finding a reviewer has **considered and overruled** is recorded, not repeated forever: `[single-witness: …]` in an unknown's Evidence cell and `[render-reviewed: …]` in an evidence row's Finding cell turn a true-but-permanent warning into a pass **that prints the reason**. A reason under 30 characters is named (`single-witness-unreasoned`) rather than obeyed, and a note left on an unknown that has since been corroborated is reported (`single-witness-stale`) so the acknowledgement cannot rot. The render note lives on the ROW because `verifyLedger` hashes the whole capture file — a capture is evidence and stays byte-immutable, a judgement about it is corpus prose | [ADR-0038](adr/0038-a-judgement-the-gate-can-read.md) |
427427
| The collector lock is exclusive **on a local filesystem, and not promised on a network one**: Node states "the exclusive flag might not work with network file systems", a condition ADR-0020 never carried. Measured 2026-09-22 - 24 racers, 1 winner, 23 EEXIST on win32 - so the guarantee holds where it was tested and nothing detects where it is standing. Evidence: `docs/decisions/2026-09-22-wx-lock/` | ADR-0020 |
428428
| The SEARCH side is **selectable at dispatch**: collect.yml takes a search_transport input (auto | serpapi | firecrawl-cli) and collect-remote.mjs a matching --search-transport, passed as RESEARCH_KIT_SEARCH_TRANSPORT. ADR-0027 built the ladder and the collector never exposed it, so a dispatched run always took whatever auto-detect picked and two providers could not be compared on one topic through the kit at all. auto becomes the EMPTY STRING, falsy in the ladder, so the default is the previous behaviour exactly | ADR-0027 |
429+
| A **rate limit is an instruction to wait, not a failure**: `rateLimitWaitMs` (lib/firecrawl.mjs) reads the delay the vendor states in its own error text, and `collectOne` sleeps and retries, bounded at two. Firecrawl free tier allows 10 requests a minute and the collector did not throttle, so a run started inside a busy minute shed six of eight fetches and recorded them as ordinary failures. The vendor number is used rather than an invented backoff, for the reason the kit researched for Retry-After: when a server says when to come back, that is the number. Non-rate-limit errors are NOT retried - a 404 does not become a 200 if you wait | ADR-0005 |
429430
| A run reports **what landed separately from what it cost**: `collected` is captures on disk, `spent` is budget consumed (collected + failed). They were one number under the label `collected`, so a run that fetched 2 pages and lost 6 to a Firecrawl rate limit reported `collected 8` while its artifact carried 2. The free tier is 10 requests per minute and the collector does not throttle, so a paired run started inside the same minute sheds most of its fetches | ADR-0005 |
430431
| An entrypoint **refuses a flag it does not know** rather than ignoring it: `refuseUnknownFlags` in `lib/core.mjs`, called immediately after `parseFlags` in all eleven bins. `parseFlags` knows no flag names by design, so an entrypoint that skips the check gets silence — the flag is parsed, never read, and the command proceeds to its default. On 2026-09-22 that default was **a real collection**: `research.mjs --totally-made-up-flag` spent 26 credits against this repository's corpus, and `audit.mjs` wrote twelve files. The same survey found a dead `--max-scrapes` in two workflows, where the page bound people believed it set was really `plan.maxScrapes` — real in `collect.yml`, and **absent** in `live-collection.yml`, whose `max_pages` was validated and then ignored | ADR-0020 |
431432
| A capture that **says it failed to render** is reported, though it arrived whole: `completeness` grades the transport and cannot see a page built by script that arrives complete and empty. The rule matches sentences a page PRINTS, never a guess at whether its text looks substantial — emptiness itself was measured and found mechanically undetectable, in both directions ([ADR-0037](adr/0037-an-empty-capture-is-not-mechanically-detectable.md)) | [ADR-0037](adr/0037-an-empty-capture-is-not-mechanically-detectable.md) |

‎research-kit/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ node research-kit/bin/selftest.mjs # all of it
395395
node research-kit/bin/selftest.mjs gate hook # just these files
396396
```
397397

398-
856 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
398+
860 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
399399
the assertions settled (ADR-0021), and each test is raced against a watchdog
400400
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).
401401

‎research-kit/lib/collect.mjs‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
import fs from 'node:fs';
1111
import {
1212
PATHS, HEADERS, resolve, today, sha256, titleFromUrl, hostOf, urlDigest, writeText, readText,
13+
sleepSync,
1314
} from './core.mjs';
15+
import { rateLimitWaitMs } from './firecrawl.mjs';
1416
import {
1517
captureEntry, cacheDecision, rememberCapture, appendRow, upsertRow, nextId,
1618
readCaptures, parseTable,
@@ -95,6 +97,8 @@ export function collectOne(root, url, {
9597
// Which search provider ranked this URL, when a search is why it is being fetched.
9698
discoveredBy = '',
9799
dryRun = false,
100+
maxRateLimitRetries = 2,
101+
log = () => {},
98102
} = {}) {
99103
// A dry run decides and writes nothing, so it needs no exclusive section.
100104
if (dryRun) {
@@ -122,11 +126,23 @@ export function collectOne(root, url, {
122126
return { status: 'cached', url, entry: decision.entry, reason: `fresh capture from ${decision.entry.retrieved}`, spent: 0 };
123127
}
124128

129+
// A rate limit is not a failure, it is an instruction to wait. Bounded, because a
130+
// vendor that keeps refusing is a different problem from a busy minute, and a
131+
// collector that retried forever would hold this lock forever.
125132
let result;
126-
try {
127-
result = runScrape(url);
128-
} catch (err) {
129-
result = { ok: false, url, error: err.message };
133+
let waits = 0;
134+
for (let attempt = 0; ; attempt += 1) {
135+
try {
136+
result = runScrape(url);
137+
} catch (err) {
138+
result = { ok: false, url, error: err.message };
139+
}
140+
if (result?.ok || attempt >= maxRateLimitRetries) break;
141+
const wait = rateLimitWaitMs(result?.error);
142+
if (wait === null) break;
143+
waits += 1;
144+
log(` waiting ${Math.round(wait / 1000)}s - ${url} hit the vendor rate limit`);
145+
sleepSync(wait);
130146
}
131147

132148
if (!result?.ok) {
@@ -136,7 +152,7 @@ export function collectOne(root, url, {
136152
discoveredBy,
137153
cmd: result?.cmd ?? '', error: result?.error ?? 'unknown failure',
138154
});
139-
return { status: 'failed', url, entry: null, reason: result?.error ?? 'unknown failure', spent: 1 };
155+
return { status: 'failed', url, entry: null, reason: result?.error ?? 'unknown failure', spent: 1, waits };
140156
}
141157

142158
const entry = writeRaw(root, result, { date });
@@ -167,7 +183,7 @@ export function collectOne(root, url, {
167183

168184
upsertRow(root, PATHS.sources, HEADERS.sources, [entry.url, type, result.title || titleFromUrl(entry.url), date, usedFor]);
169185

170-
return { status: 'collected', url: entry.url, entry, row: { id, finding }, reason: decision.reason, spent: 1 };
186+
return { status: 'collected', url: entry.url, entry, row: { id, finding }, reason: decision.reason, spent: 1, waits };
171187
});
172188
}
173189

‎research-kit/lib/core.mjs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,14 @@ export function refuseUnknownFlags(flags, known, { help = '', exit = 2, note = n
294294
if (help) process.stdout.write(help);
295295
process.exit(exit);
296296
}
297+
298+
/**
299+
* Block for `ms`. The fetch adapters are synchronous by contract - they reach the vendor
300+
* through `spawnSync` - so a rate-limit wait cannot be awaited without changing that
301+
* contract everywhere. `Atomics.wait` on a private buffer is the sanctioned way to do
302+
* this; a spin loop would burn a core for the length of the wait.
303+
*/
304+
export function sleepSync(ms) {
305+
if (!Number.isFinite(ms) || ms <= 0) return;
306+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
307+
}

‎research-kit/lib/firecrawl.mjs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,3 +424,27 @@ export function cliCompatibility(version = cliVersion()) {
424424
}
425425

426426
export default { name, scrape, search, map, command, status, runScrape };
427+
428+
/**
429+
* How long the vendor asked us to wait, in milliseconds, or `null` if this is not a rate
430+
* limit at all.
431+
*
432+
* WHY THE VENDOR'S NUMBER AND NOT OUR OWN. Firecrawl states the delay in the error text -
433+
* "please retry after 13s" - and it knows when its own window resets. A fixed backoff we
434+
* invented would either wait too long on every failure or too little on the one that
435+
* mattered. This is the same principle the kit researched for `Retry-After` on 2026-09-22:
436+
* when a server tells you when to come back, that is the number to use.
437+
*
438+
* Found because a run lost six of eight fetches to "Consumed (req/min): 11, Remaining
439+
* (req/min): 0" and reported them as ordinary failures. The free tier is ten requests a
440+
* minute; the collector did not throttle and did not retry.
441+
*/
442+
export function rateLimitWaitMs(errorText) {
443+
const text = String(errorText ?? '');
444+
if (!/rate limit exceeded/i.test(text)) return null;
445+
const retry = text.match(/retry after (\d+)\s*s/i);
446+
if (retry) return (Number(retry[1]) + 1) * 1000;
447+
// A rate limit with no stated delay still deserves a wait, and one minute clears a
448+
// per-minute window by construction.
449+
return 60_000;
450+
}

‎research-kit/lib/research-run.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ ${compatibility.remedy}`);
242242
// Empty for a plan URL - nobody's ranking chose it, a person wrote it down.
243243
discoveredBy: target.rankedBy ?? '',
244244
dryRun,
245+
log,
245246
});
246247
if (outcome.status === 'collected') spent += 1;
247248
if (outcome.status === 'cached') cached += 1;

‎research-kit/test/collect.test.mjs‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { PATHS, resolve, readText, writeJson, today } from '../lib/core.mjs';
66
import { readCorpus, parseTable } from '../lib/corpus.mjs';
77
import { HEADERS } from '../lib/core.mjs';
88
import { collectOne, writeRaw, captureName, bodyHashOf } from '../lib/collect.mjs';
9+
import { rateLimitWaitMs } from '../lib/firecrawl.mjs';
910
import { verifyLedger } from '../lib/provenance.mjs';
1011
import { runResearch, readPlan, rankCandidate, selectCandidates, DEPTH_SCRAPES, usageSummary } from '../lib/research-run.mjs';
1112

@@ -261,3 +262,72 @@ test('usage is written after a run that spent, and read back as a summary', () =
261262
assert.equal(usage.scrapes, 1);
262263
assert.equal(usage.evidence, 1);
263264
});
265+
266+
// ---------------------------------------------------------------- rate limits
267+
268+
test('rateLimitWaitMs reads the vendor number, and refuses to invent one', () => {
269+
// The real error text, verbatim from run 35715485734.
270+
const real = 'Rate limit exceeded. Consumed (req/min): 11, Remaining (req/min): 0. '
271+
+ 'Upgrade your plan at https://firecrawl.dev/pricing for increased rate limits or '
272+
+ 'please retry after 13s, resets at Tue Sep 22 2026 10:21:38 GMT+0000';
273+
assert.equal(rateLimitWaitMs(real), 14_000, 'the stated 13s plus a second of margin');
274+
275+
// A rate limit with no stated delay still waits; one minute clears a per-minute window.
276+
assert.equal(rateLimitWaitMs('Rate limit exceeded'), 60_000);
277+
278+
// Everything that is NOT a rate limit must return null, so ordinary failures are not
279+
// retried. A 404 does not become a 404 if you wait.
280+
assert.equal(rateLimitWaitMs('404 not found'), null);
281+
assert.equal(rateLimitWaitMs(''), null);
282+
assert.equal(rateLimitWaitMs(undefined), null);
283+
});
284+
285+
test('collectOne retries a rate limit and keeps the page it eventually gets', () => {
286+
// Six of eight fetches were lost this way on 2026-09-22, reported as plain failures.
287+
const dir = makeProject();
288+
let calls = 0;
289+
const outcome = collectOne(dir, 'https://example.invalid/limited', {
290+
corpus: readCorpus(dir),
291+
runScrape: () => {
292+
calls += 1;
293+
if (calls === 1) {
294+
return { ok: false, url: 'https://example.invalid/limited', error: 'Rate limit exceeded. please retry after 0s, resets at now' };
295+
}
296+
return { ok: true, url: 'https://example.invalid/limited', title: 'Limited', markdown: `# Limited\n\n${'body text here. '.repeat(40)}`, statusCode: 200, transport: 'stub', completeness: 'full' };
297+
},
298+
});
299+
300+
assert.equal(calls, 2, 'the rate limit must be retried, not reported');
301+
assert.equal(outcome.status, 'collected');
302+
assert.equal(outcome.waits, 1, 'the wait is counted so a slow run explains itself');
303+
});
304+
305+
test('collectOne does NOT retry an ordinary failure', () => {
306+
// The other half. Retrying a 404 spends budget to be told the same thing again.
307+
const dir = makeProject();
308+
let calls = 0;
309+
const outcome = collectOne(dir, 'https://example.invalid/gone', {
310+
corpus: readCorpus(dir),
311+
runScrape: () => { calls += 1; return { ok: false, url: 'https://example.invalid/gone', error: '404 not found' }; },
312+
});
313+
314+
assert.equal(calls, 1, 'a non-rate-limit failure must be reported immediately');
315+
assert.equal(outcome.status, 'failed');
316+
assert.equal(outcome.waits, 0);
317+
});
318+
319+
test('collectOne gives up after a bounded number of rate-limit retries', () => {
320+
// A vendor that keeps refusing is a different problem from a busy minute, and the
321+
// retry runs inside the collector lock - forever here would be forever for everyone.
322+
const dir = makeProject();
323+
let calls = 0;
324+
const outcome = collectOne(dir, 'https://example.invalid/always', {
325+
corpus: readCorpus(dir),
326+
maxRateLimitRetries: 2,
327+
runScrape: () => { calls += 1; return { ok: false, url: 'https://example.invalid/always', error: 'Rate limit exceeded. please retry after 0s' }; },
328+
});
329+
330+
assert.equal(calls, 3, 'the first attempt plus two retries');
331+
assert.equal(outcome.status, 'failed');
332+
assert.match(outcome.reason, /Rate limit exceeded/);
333+
});

0 commit comments

Comments
 (0)