Skip to content

Wrapped timeout errors drop the original error code → never auto-retried ("Request timeout after Ns" surfaces instead of hitting the transport-retry path) #8527

Description

@riffky87

What happened?

Environment

  • @qwen-code/qwen-code 0.21.3, macOS (darwin), interactive CLI
  • Auth type openai (OpenAI-compatible endpoint), streaming with thinking enabled

Observed behavior
Requests intermittently fail with:
[API Error: Request timeout after 63s. Try reducing input length or increasing timeout in config. Increase timeout in config: contentGenerator.timeout
The failure surfaces to the user with no auto-retry; hit repeatedly over several days. Note: contentGenerator.timeout was 600000 (10 min) while the failure fired at ~63s — the underlying failure is an upstream/SDK-level timeout or connection interruption, and the wrapping also hides its true origin.

What did you expect to happen?

A client-side timeout/transport interruption before any content is streamed should be auto-retried like other transport errors (ECONNRESET, ETIMEDOUT, UND_ERR_*), which the stream transport-retry path already handles (2 retries, only before the first content chunk — correct safety semantics).

Client information

Qwen Code: 0.21.3 (239de83)
Runtime: Node.js v22.23.1 / npm 11.6.4
LSP: disabled
OS: darwin arm64 (25.5.0)
Auth: Token Plan
Base URL: https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
Model: qwen3.8-max-preview
Fast Model: qwen3.8-max-preview
Session ID: 02b9e15c-0ed1-44af-82c7-867aadfbf8cd
Sandbox: no sandbox
Proxy: no proxy
Memory Usage: 613.5 MB

Login information

login with api key (Personal Token Plan)

Anything else we need to know?

Root cause (verified against the 0.21.3 bundle)

  1. The OpenAI-compatible content generator detects the underlying timeout (isTimeoutError: message contains "timeout"/"timed out", code ETIMEDOUT, etc.) and throws a new plain Error("Request timeout after ${n}s…") — the original error's
    .code/.cause is dropped.
  2. The retry layer (shouldRetryOnError / stream transport retry in geminiChat, classification in retryErrorClassification.ts) retries only: HTTP 429/5xx; numeric rate-limit codes (isRateLimitError incl. retryErrorCodes — numeric only); and
    classifyRetryError(...).kind === "transport", which requires a transport code from RETRYABLE_STREAM_TRANSPORT_CODES (ECONNRESET, ETIMEDOUT, UND_ERR_BODY_TIMEOUT, UND_ERR_CONNECT_TIMEOUT, UND_ERR_HEADERS_TIMEOUT, UND_ERR_SOCKET).
  3. The wrapped Error has no .code and no transport token in its message → classified non-retryable → surfaces. The error class that most needs retrying is made un-retryable by the wrapping.

Suggested fix

  • Preserve the original error when wrapping: throw new Error(msg, { cause: error }) and/or copy error.code, so getTransportCode sees ETIMEDOUT/UND_ERR_*.
  • Or classify isTimeoutError errors as retryable transport explicitly (safe — the stream path already gates on "no content yielded yet").
  • Diagnosability bonus: include the underlying code/message in the wrapped error and diagnostics. The current tip ("increase contentGenerator.timeout") is misleading when the configured timeout is 10 min and the failure fires at 63s.

Workaround
Manual Ctrl+Y retry. No config workaround exists (retryErrorCodes matches numeric provider codes only).

Metadata

Metadata

Assignees

No one assigned

    Labels

    category/coreCore engine and logicpriority/P2Medium - Moderately impactful, noticeable problemscope/content-generationAI content generationtype/bugSomething isn't working as expected

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions