Skip to content

feat(fetch): let scrape and extract retry calls the API did not serve, opt-in with ZENROWS_FETCH_RETRIES - #80

Merged
gonzalo-zenrows merged 1 commit into
mainfrom
feat/fetch-retry-jitter
Nov 6, 2026
Merged

gonzalo-zenrows merged 1 commit into
mainfrom
feat/fetch-retry-jitter

Conversation

@gonzalo-zenrows

@gonzalo-zenrows gonzalo-zenrows commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Merge after #114, which adds the changelog section this PR's changelog lines use.

This PR adds an opt-in variable, ZENROWS_FETCH_RETRIES, for scrape and extract. The default is 0, so every tool call behaves as today: one request, the same output, the same errors.

With ZENROWS_FETCH_RETRIES set

  1. scrape and extract retry, up to that many times, a 429, 502, 503 or 504, and a connection that failed before the request was sent (refused, name resolution, unreachable network or host, connect timeout). Every other status or error is returned at once.
  2. Each wait is a Retry-After in whole seconds (clamped to 250 ms - 30 s), or else full jitter between 250 ms and min(30 s, 1 s x 2^n).
  3. A retry starts only if its wait ends within 50 s of the first request, because MCP clients cancel a tool call at about 60 s. A running attempt is not cut short, so a retry that starts near 50 s can still run past the client's timeout.
  4. Over stdio, a cancelled or timed-out call stops waiting and sends no more retries.
  5. Each retry writes one line to stderr. The value is read once at start; an invalid one stops the server.
  6. The hosted server retries only if its operator sets the variable. Crawl, batch and prepare tools are unchanged.

Tests

  1. With the variable unset, a 503 through a real MCP client is sent once for scrape and for extract, and logs nothing.
  2. One test per rule: the shared Retry-After and jitter vectors, which statuses and errors retry, the 50 s rule, cancel, the stderr line, and an invalid value stopping both entry points.
  3. build, check:server-json, typecheck, lint and npm test pass; prettier --check passes on the touched files.

BEGIN_COMMIT_OVERRIDE
feat(fetch): new ZENROWS_FETCH_RETRIES option lets scrape and extract retry calls the API did not serve; off by default
END_COMMIT_OVERRIDE

@gonzalo-zenrows

Copy link
Copy Markdown
Contributor Author

@gonzalo-zenrows
gonzalo-zenrows force-pushed the feat/fetch-retry-jitter branch from 4cb72cf to ae3292a Compare October 19, 2026 21:03
@gonzalo-zenrows
gonzalo-zenrows marked this pull request as ready for review October 20, 2026 08:11
@gonzalo-zenrows
gonzalo-zenrows force-pushed the feat/fetch-retry-jitter branch from ae3292a to be080cd Compare October 20, 2026 08:14

@jonathan-zenrows jonathan-zenrows 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.

On stdio this is fine. On the hosted server, cancellation never reaches the retry loop.

src/http.ts:398-405 creates a new transport and server for each POST (stateless, enableJsonResponse). A client's notifications/cancelled arrives in a different POST, so it lands on another Protocol that doesn't own the request, and the handler's extra.signal never fires. On top of that, the web transport in SDK 1.32.1 doesn't read req.signal, so a client disconnect doesn't abort anything either.

Repro: a client with a 60 s tool timeout calls scrape against the hosted server while the API answers 503. The client gives up at 60 s, and the server keeps sleeping and retrying until it reaches 90 s, holding a slot and API concurrency for a client that's no longer there. The tests don't catch it because tests/fetch-retry-tool.test.ts:30 uses InMemoryTransport.

Fix in http.ts:

c.req.raw.signal.addEventListener("abort", () => transport.close(), { once: true });

Protocol._onclose aborts the controllers of every in-flight handler. Add a test over the HTTP transport. Until this lands, the README/PR text about "no request after cancel" only holds for stdio.

Related: 90 s is above the ~60 s timeout of the usual clients, and scrape/extract have no per-attempt timeout (that's from before this PR). For hosted, a budget of ~55 s would make more sense.

Small: prettier --check fails on tests/extract.test.ts. Also, an AggregateError with ETIMEDOUT (happy-eyeballs) isn't retried. That's conservative, just so it's on record.

It conflicts with main, so it needs a rebase.

@gonzalo-zenrows gonzalo-zenrows changed the title feat(fetch): retry not-served scrape and extract calls so a busy API does not fail the tool call feat(fetch): let scrape and extract retry calls the API did not serve, opt-in with ZENROWS_FETCH_RETRIES Nov 2, 2026
…, opt-in with ZENROWS_FETCH_RETRIES

A 429, 502, 503 or 504, or a connection that failed before the request was
sent, means the API did not serve the call, so sending it again is safe.
ZENROWS_FETCH_RETRIES sets how many retries scrape and extract may make. The
default 0 sends each call once, as today. Waits use Retry-After or full
jitter, and no retry starts unless its wait ends within 50 s of the first
request, since MCP clients cancel a tool call at about 60 s.
@gonzalo-zenrows
gonzalo-zenrows force-pushed the feat/fetch-retry-jitter branch from 2c9d823 to 3582c30 Compare November 3, 2026 15:02
@gonzalo-zenrows
gonzalo-zenrows merged commit 18e5684 into main Nov 6, 2026
3 checks passed
@gonzalo-zenrows
gonzalo-zenrows deleted the feat/fetch-retry-jitter branch November 6, 2026 14:35
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.

3 participants