fix: stop reporting the CLI's own timeout as BACKEND_UNAVAILABLE - #18
Open
StefanoGuerrini wants to merge 1 commit into
Open
fix: stop reporting the CLI's own timeout as BACKEND_UNAVAILABLE#18StefanoGuerrini wants to merge 1 commit into
StefanoGuerrini wants to merge 1 commit into
Conversation
`zenrows fetch` aborted at 90s — exactly the gateway's own request
budget — so any request that used the full budget was a race between our
abort and the API's real error envelope, and the abort usually won. Every
such failure surfaced as:
"code": "BACKEND_UNAVAILABLE",
"message": "Could not reach the Zenrows API.",
"likely_cause": "Network error or timeout: This operation was aborted"
Both claims were false. The API had been reached and was about to answer
with a specific, actionable error, so the operator was sent to check
connectivity instead of reading the answer that already existed.
Two independent defects, both fixed here:
- The client timeout equalled the server budget. The default is now
120s, deliberately above the gateway's 90s ceiling, so the API always
gets to answer for itself. `zenrows fetch` gains `--timeout <ms>`
(milliseconds, mirroring `batch wait --timeout`) to raise it further;
a non-numeric or non-positive value is rejected as INVALID_USAGE
rather than silently falling back to the default.
- Every thrown error mapped to BACKEND_UNAVAILABLE. A client-side
give-up is now REQUEST_TIMEOUT, carrying the elapsed time so the 90s
boundary is visible, and pointing at `--timeout` / dropping
`--wait-for` instead of at the network. BACKEND_UNAVAILABLE is left
for genuine transport failures only, and now also reports elapsed
time.
Our own timer flag, not `err.name === "AbortError"`, is what separates
the two: it cannot be confused with an abort from anywhere else.
The trace-debug skill's failure → action map gains both codes, so an
agent reading a trace is steered the same way.
Refs ACT-1605
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vy8dYdUhLJoS6EHw5jn9mg
StefanoGuerrini
marked this pull request as ready for review
September 2, 2026 13:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes ACT-1605.
What the ticket asked for
zenrows fetchaborted at 90s — exactly the gateway's own request budget — so anyrequest that used the full budget was a coin flip between our abort and the API's real
error envelope, and the abort usually won. Every such failure surfaced as:
{ "code": "BACKEND_UNAVAILABLE", "message": "Could not reach the Zenrows API.", "likely_cause": "Network error or timeout: This operation was aborted" }Both claims were false. The API had been reached and was about to answer with a
specific, actionable error, so the operator was sent to check connectivity instead of
reading the answer that already existed.
The change
Two independent defects, both fixed:
1. The client timeout equalled the server budget. The default is now
120_000ms,deliberately above the gateway's 90s ceiling, so the API always gets to answer for
itself.
zenrows fetchgains--timeout <ms>to raise it further. Both numbers arenamed constants in
src/core/http.ts(DEFAULT_TIMEOUT_MS,SERVER_BUDGET_MS) so therelationship between them is stated in one place rather than implied by two literals.
2. Any thrown error mapped to
BACKEND_UNAVAILABLE. A client-side give-up is nowREQUEST_TIMEOUT, carrying the elapsed time so the 90s boundary is visible, and pointingat
--timeout/ dropping--wait-forrather than at the network.BACKEND_UNAVAILABLEis left for genuine transport failures only, and now also reports elapsed time.
What the reported case looks like after the change:
{ "ok": false, "error": { "code": "REQUEST_TIMEOUT", "message": "The CLI stopped waiting after 120s. The Zenrows API did not respond in time.", "likely_cause": "The request was aborted client-side after 120s. The API was reached — this is not a connectivity problem. The request also passed the API's own 90s budget, so the target is very likely rendering slowly or a wait condition never matched.", "next_action": "Retry with a longer client timeout (`--timeout 180000`). If the target needs a long render, drop `--wait-for` so the request finishes inside the API's budget and the API can return its own error instead.", "suggested_commands": [ "zenrows fetch https://www.aircanada.com/cargo/tracking?awbnb=014-80200256 --timeout 180000" ] } }The
trace-debugskill's failure → action map gains both codes, so an agent reading atrace is steered the same way a human is.
Verification
The ticket's Fix validation checklist is the check.
npm run typecheckandnpm testare both clean (192 tests, up from 183).
tests/fetch-timeout.test.ts)the default client timeout is above the API's own request budget--timeoutadded tofetch--timeout accepts milliseconds and defaults when absent,--timeout rejects a non-numeric or non-positive value instead of silently ignoring it,runFetch threads --timeout through to the HTTP clientAbortErrormapped to a distinct code, separate from transport failuresa client-side timeout is REQUEST_TIMEOUT, never BACKEND_UNAVAILABLE,a genuine transport failure is still BACKEND_UNAVAILABLE, with the elapsed timeBACKEND_UNAVAILABLEa client-side timeout is REQUEST_TIMEOUT, never BACKEND_UNAVAILABLEREQUEST_TIMEOUT names the elapsed time and how to raise the timeoutThe abort test drives the real code path: a
fetchstub that never answers and rejectsonly when the caller's own
AbortControllerfires, which is what undici does in thereported case. Each new assertion was confirmed non-vacuous by mutating the fix and
watching the specific test fail (dropping the
timedOutbranch fails 3; setting thedefault back to 90s fails 1).
Convention provenance
--timeoutin milliseconds, and the flag name — the repo's own convention:batch wait --timeout <ms>already takes ms, as doesbrowser wait-for-navigation --timeout-ms.--timeoutvalue loudly instead of falling back to the default —the repo's convention, from
src/cli/command.ts'sUNKNOWN_FLAGhandling: "an agentthat mistypes or hallucinates a flag must fail here, not get a green result on a
request the CLI never actually honored." Same reasoning, applied to a bad value.
code/message/likely_cause/next_action/suggested_commands), and anext_actionthat does not advise a useless retry —docs/contributing.md("Every error must be agent-actionable") plus the existingDOMAIN_FORBIDDEN/RESP007handlers in the same file.REQUEST_TIMEOUTin a newtests/fetch-timeout.test.tsrather than growingtests/http.test.ts— the repo's per-concern test file convention(
fetch-conflicts,extract-outputs,redact,nudge).err.name === "AbortError"— my judgment, not a stated convention. The ticket proposed theAbortErrorcheck; the flag is a strictly narrower implementation of the same intent(it cannot be confused with an abort originating anywhere else) and needs no
cross-runtime assumption about the error's
name. Easy to swap back if you'd rathermatch the ticket's wording literally.
Deliberately not in this PR
--timeoutonzenrows extract.extractgoes through the samerunFetch, so itpicks up the 120s default and the
REQUEST_TIMEOUTmapping automatically; the ticketscoped the new flag to
fetch, so I did not widen the surface. The plumbing(
FetchOptions.timeoutMs) is already there if you want it.src/core/browser-api.ts:83,src/core/batch-api.ts:123,src/core/usage.ts:77andsrc/core/agent-account.ts:176all map their own abort toBACKEND_UNAVAILABLEthesame way. The ticket's Root cause section named
src/core/http.tsspecifically, sothose are untouched — worth a follow-up ticket, and
requestTimeout()is exported soit can be reused verbatim.
wait_forturning a specificmandatory xhr route not foundinto a generic499 CTX0001). The ticket puts thatout of scope, on COR-491.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Vy8dYdUhLJoS6EHw5jn9mg
Generated by Claude Code