Skip to content

Commit 0b93f4e

Browse files
fix(crawl): wait out rate-limited status polls and page results only once the crawl ends (#570)
* fix(crawl): wait out rate-limited status polls and page results only once the crawl ends * fix(crawl): reuse the terminal poll as the first result page, honor HTTP-date Retry-After, assert the wait --------- Co-authored-by: erikengervall <5392475+erikengervall@users.noreply.github.com>
1 parent d13b1c4 commit 0b93f4e

2 files changed

Lines changed: 136 additions & 9 deletions

File tree

‎src/server.ts‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1897,7 +1897,16 @@ For a programming question, add \`categories: ["developer"]\`; its hits return i
18971897
`/v2/crawl/${encodeURIComponent(jobId)}`,
18981898
originHeaders(origin)
18991899
);
1900-
const body = (res?.data ?? {}) as any;
1900+
return collectCrawlPages(client, jobId, origin, (res?.data ?? {}) as any);
1901+
}
1902+
1903+
/** A crawl status body with its remaining result pages appended. */
1904+
async function collectCrawlPages(
1905+
client: FirecrawlApp,
1906+
jobId: string,
1907+
origin: string,
1908+
body: any
1909+
): Promise<Record<string, unknown>> {
19011910
const initialDocs = Array.isArray(body.data) ? body.data : [];
19021911

19031912
if (!body.next) {
@@ -1954,18 +1963,38 @@ For a programming question, add \`categories: ["developer"]\`; its hits return i
19541963
): Promise<Record<string, unknown>> {
19551964
const startedAt = Date.now();
19561965
for (;;) {
1957-
const status = await getCrawlStatusWithOrigin(client, jobId, origin);
1958-
if (
1959-
['completed', 'failed', 'cancelled'].includes(String(status.status ?? ''))
1960-
) {
1961-
return status;
1966+
let waitMs = Math.max(1000, pollInterval * 1000);
1967+
try {
1968+
// Poll the first page only; result pages are read once the crawl ends.
1969+
const res = await (client as any).http.get(
1970+
`/v2/crawl/${encodeURIComponent(jobId)}`,
1971+
originHeaders(origin)
1972+
);
1973+
if (
1974+
['completed', 'failed', 'cancelled'].includes(String(res?.data?.status ?? ''))
1975+
) {
1976+
return await collectCrawlPages(client, jobId, origin, res.data);
1977+
}
1978+
} catch (error) {
1979+
const response = (error as { response?: { status?: number; headers?: any; data?: any } })
1980+
?.response;
1981+
if (response?.status !== 429) throw error;
1982+
// A rate-limited status read says nothing about the crawl: wait it out.
1983+
const retryAfter = String(
1984+
response.headers?.['retry-after'] ?? response.data?.retry_after_seconds ?? ''
1985+
).trim();
1986+
const retryAfterMs = /^\d+$/.test(retryAfter)
1987+
? Number(retryAfter) * 1000
1988+
: Date.parse(retryAfter) - Date.now();
1989+
waitMs = Math.min(
1990+
60_000,
1991+
Math.max(waitMs, Number.isFinite(retryAfterMs) ? retryAfterMs : 10_000)
1992+
);
19621993
}
19631994
if (timeout != null && Date.now() - startedAt > timeout * 1000) {
19641995
throw new Error(`Crawl job ${jobId} did not complete within ${timeout}s`);
19651996
}
1966-
await new Promise((resolve) =>
1967-
setTimeout(resolve, Math.max(1000, pollInterval * 1000))
1968-
);
1997+
await new Promise((resolve) => setTimeout(resolve, waitMs));
19691998
}
19701999
}
19712000

‎tests/mcp-crawl-polling.test.mjs‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import assert from 'node:assert/strict';
2+
import { createServer } from 'node:http';
3+
import test from 'node:test';
4+
import {
5+
getFreePort,
6+
parseSseJson,
7+
spawnServer,
8+
stopChild,
9+
waitForHealth,
10+
} from './helpers/exchange-mcp.mjs';
11+
12+
const CRAWL_ID = 'crawl-1';
13+
14+
async function startCrawlApi() {
15+
const requests = [];
16+
let statusReads = 0;
17+
const server = createServer(async (req, res) => {
18+
for await (const _chunk of req);
19+
requests.push({ url: req.url, at: Date.now() });
20+
const base = `http://127.0.0.1:${server.address().port}`;
21+
const json = (status, payload, headers = {}) => {
22+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
23+
res.end(JSON.stringify(payload));
24+
};
25+
if (req.method === 'POST' && req.url === '/v2/crawl') {
26+
return json(200, { success: true, id: CRAWL_ID });
27+
}
28+
if (req.url === `/v2/crawl/${CRAWL_ID}?skip=1`) {
29+
return json(200, { success: true, data: [{ markdown: 'page two' }], next: null });
30+
}
31+
if (req.url === `/v2/crawl/${CRAWL_ID}`) {
32+
statusReads += 1;
33+
if (statusReads === 2) {
34+
return json(429, { success: false, error: 'Rate limit exceeded.', retry_after_seconds: 1 }, { 'retry-after': '1' });
35+
}
36+
const done = statusReads >= 3;
37+
return json(200, {
38+
success: true,
39+
status: done ? 'completed' : 'scraping',
40+
completed: done ? 2 : 1,
41+
total: 2,
42+
data: [{ markdown: 'page one' }],
43+
next: `${base}/v2/crawl/${CRAWL_ID}?skip=1`,
44+
});
45+
}
46+
json(404, { success: false, error: `Unhandled ${req.method} ${req.url}` });
47+
});
48+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
49+
return {
50+
requests,
51+
url: `http://127.0.0.1:${server.address().port}`,
52+
close: () => new Promise((resolve) => server.close(resolve)),
53+
};
54+
}
55+
56+
test('crawl waits out a rate-limited status read and pages results only once it ends', async (t) => {
57+
const api = await startCrawlApi();
58+
t.after(() => api.close());
59+
const port = await getFreePort();
60+
const child = spawnServer({
61+
CLOUD_SERVICE: 'true',
62+
HTTP_STREAMABLE_SERVER: 'true',
63+
FASTMCP_ENDPOINT: '/v2/mcp',
64+
FIRECRAWL_API_KEY: '',
65+
FIRECRAWL_OAUTH_TOKEN: '',
66+
FIRECRAWL_API_URL: api.url,
67+
FIRECRAWL_MCP_SEARCH_ENABLED: 'false',
68+
PORT: String(port),
69+
});
70+
t.after(() => stopChild(child));
71+
await waitForHealth(port, child);
72+
73+
const response = await fetch(`http://127.0.0.1:${port}/v2/mcp`, {
74+
body: JSON.stringify({
75+
id: 1,
76+
jsonrpc: '2.0',
77+
method: 'tools/call',
78+
params: { name: 'firecrawl_crawl', arguments: { url: 'https://example.com' } },
79+
}),
80+
headers: {
81+
accept: 'application/json, text/event-stream',
82+
'content-type': 'application/json',
83+
'x-api-key': 'fc-hosted-test',
84+
},
85+
method: 'POST',
86+
signal: AbortSignal.timeout(20_000),
87+
});
88+
assert.equal(response.status, 200);
89+
const message = parseSseJson(await response.text());
90+
assert.notEqual(message.result?.isError, true, JSON.stringify(message));
91+
const result = JSON.parse(message.result.content[0].text);
92+
assert.equal(result.status, 'completed');
93+
assert.deepEqual(result.data.map((doc) => doc.markdown), ['page one', 'page two']);
94+
const statusReads = api.requests.filter(({ url }) => url === `/v2/crawl/${CRAWL_ID}`);
95+
assert.equal(statusReads.length, 3);
96+
assert.ok(statusReads[2].at - statusReads[1].at >= 1000, 'the read after a 429 waits for Retry-After');
97+
assert.equal(api.requests.filter(({ url }) => url.endsWith('?skip=1')).length, 1);
98+
});

0 commit comments

Comments
 (0)