Skip to content

fix: stop reporting the CLI's own timeout as BACKEND_UNAVAILABLE - #18

Open
StefanoGuerrini wants to merge 1 commit into
mainfrom
claude/cli-backend-timeout-masking-2zq2fb
Open

fix: stop reporting the CLI's own timeout as BACKEND_UNAVAILABLE#18
StefanoGuerrini wants to merge 1 commit into
mainfrom
claude/cli-backend-timeout-masking-2zq2fb

Conversation

@StefanoGuerrini

Copy link
Copy Markdown

Fixes ACT-1605.

What the ticket asked for

zenrows fetch aborted at 90s — exactly the gateway's own request budget — so any
request 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_000 ms,
deliberately above the gateway's 90s ceiling, so the API always gets to answer for
itself. zenrows fetch gains --timeout <ms> to raise it further. Both numbers are
named constants in src/core/http.ts (DEFAULT_TIMEOUT_MS, SERVER_BUDGET_MS) so the
relationship 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 now
REQUEST_TIMEOUT, carrying the elapsed time so the 90s boundary is visible, and pointing
at --timeout / dropping --wait-for rather than at the network. BACKEND_UNAVAILABLE
is 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-debug skill's failure → action map gains both codes, so an agent reading a
trace is steered the same way a human is.

Verification

The ticket's Fix validation checklist is the check. npm run typecheck and npm test
are both clean (192 tests, up from 183).

Ticket line Encoded by (tests/fetch-timeout.test.ts)
Client timeout raised above the server budget the default client timeout is above the API's own request budget
--timeout added to fetch --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 client
AbortError mapped to a distinct code, separate from transport failures a client-side timeout is REQUEST_TIMEOUT, never BACKEND_UNAVAILABLE, a genuine transport failure is still BACKEND_UNAVAILABLE, with the elapsed time
Test covering a simulated abort asserting it is not BACKEND_UNAVAILABLE a client-side timeout is REQUEST_TIMEOUT, never BACKEND_UNAVAILABLE
Elapsed time included so the 90s boundary is visible REQUEST_TIMEOUT names the elapsed time and how to raise the timeout

The abort test drives the real code path: a fetch stub that never answers and rejects
only when the caller's own AbortController fires, which is what undici does in the
reported case. Each new assertion was confirmed non-vacuous by mutating the fix and
watching the specific test fail (dropping the timedOut branch fails 3; setting the
default back to 90s fails 1).

Convention provenance

  • --timeout in milliseconds, and the flag name — the repo's own convention:
    batch wait --timeout <ms> already takes ms, as does browser wait-for-navigation --timeout-ms.
  • Rejecting a bad --timeout value loudly instead of falling back to the default
    the repo's convention, from src/cli/command.ts's UNKNOWN_FLAG handling: "an agent
    that 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.
  • Error shape (code / message / likely_cause / next_action /
    suggested_commands), and a next_action that does not advise a useless retry

    docs/contributing.md ("Every error must be agent-actionable") plus the existing
    DOMAIN_FORBIDDEN / RESP007 handlers in the same file.
  • Testing REQUEST_TIMEOUT in a new tests/fetch-timeout.test.ts rather than growing
    tests/http.test.ts
    — the repo's per-concern test file convention
    (fetch-conflicts, extract-outputs, redact, nudge).
  • Detecting the timeout with our own timer flag rather than err.name === "AbortError" — my judgment, not a stated convention. The ticket proposed the
    AbortError check; 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 rather
    match the ticket's wording literally.

Deliberately not in this PR

  • --timeout on zenrows extract. extract goes through the same runFetch, so it
    picks up the 120s default and the REQUEST_TIMEOUT mapping automatically; the ticket
    scoped the new flag to fetch, so I did not widen the surface. The plumbing
    (FetchOptions.timeoutMs) is already there if you want it.
  • The same masking bug in the sibling HTTP clients. src/core/browser-api.ts:83,
    src/core/batch-api.ts:123, src/core/usage.ts:77 and
    src/core/agent-account.ts:176 all map their own abort to BACKEND_UNAVAILABLE the
    same way. The ticket's Root cause section named src/core/http.ts specifically, so
    those are untouched — worth a follow-up ticket, and requestTimeout() is exported so
    it can be reused verbatim.
  • The server-side information loss (wait_for turning a specific
    mandatory xhr route not found into a generic 499 CTX0001). The ticket puts that
    out of scope, on COR-491.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vy8dYdUhLJoS6EHw5jn9mg


Generated by Claude Code

`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
@linear

linear Bot commented Sep 2, 2026

Copy link
Copy Markdown

ACT-1605

@StefanoGuerrini
StefanoGuerrini marked this pull request as ready for review September 2, 2026 13:01
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.

2 participants