Skip to content

[1/3] Error cause in core and network - #289

Merged
eric8810 merged 6 commits into
masterfrom
cause/1-error-cause-in-core-and-network
Oct 20, 2026
Merged

eric8810 merged 6 commits into
masterfrom
cause/1-error-cause-in-core-and-network

Conversation

@cunninghamcard-bit

@cunninghamcard-bit cunninghamcard-bit commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Requested by li · project thread

Summary

RFC-0047(#286)实现的第 1 步:错误原因的核心类型,以及网络层把底层错误作为 ApiCallError.cause 保留下来。网络连不上时,调用方能沿 source() 一直看到 Connection refused (os error 111),而不是只有 “error sending request”。传输层(fetch.rs 和 Vertex 的证书传输)先去掉 URL(query 可能带 API key),所以原因链和消息里都没有 URL:传输错误的消息从 error sending request for url (…) 变成 error sending request,其他消息不变。下载错误(aimux-core/src/download.rs,用自己的 reqwest 客户端)在 #290 处理。

基于 master e115e723(#268 已合并),用 #268 的生成器生成 DTO;下面概览的行号是开 PR 时的 master 42208472。后面还有两步:#290 六个字符串变体带 cause,#293 ops 信封和各语言绑定。

网络层错误体系概览

一次请求从 provider 到传输层再回来,错误在这几层产生,行号是 master 42208472(#268 合并后):

post_json_to_api                                aimux-provider-utils/src/post_to_api.rs:17
└ execute_exchange                              post_to_api.rs:144
  ├ send_one_request                            aimux-provider-utils/src/http.rs:361
  │ ├ Fetch::fetch                              ReqwestFetch / SigV4Fetch / PinnedFetch / provider 自带的 Fetch 装饰器
  │ │ └ send → map_reqwest_error                aimux-provider-utils/src/fetch.rs:420 / 445   reqwest::Error → FetchError { Connect | Timeout | Aborted | Io | Other }
  │ ├ fetch_error_to_ai_mux_error               aimux-provider-utils/src/handle_fetch_error.rs:29   → Aborted,或无状态码的 ApiCall
  │ └ 重定向                                    http.rs:429 → redirect_error(http.rs:400)
  ├ 非 2xx → failed_response_handler            post_to_api.rs:167
  │ └ handler 自己失败 → ApiCall “Failed to process error response: …”      post_to_api.rs:180
  └ 2xx → successful_response_handler
    ├ 读 body:read_response_with_size_limit    aimux-provider-utils/src/read_response_with_size_limit.rs:78
    ├ JSON:create_json_response_handler        aimux-provider-utils/src/response_handler.rs:136   “Invalid JSON response: …”
    ├ 校验失败:invalid_response_api_call       response_handler.rs:194
    ├ SSE:create_event_source_response_handler response_handler.rs:358   传输中断 409、分帧错误 423
    └ handler 自己失败 → ApiCall “Failed to process successful response: …” post_to_api.rs:197
WebSocket(OpenAI Realtime 等)
└ connect / send / recv → ws_error              aimux-provider-utils/src/ws.rs:157 / 618 / 760
重试
└ retry_with_exponential_backoff                aimux-core/src/retry.rs   只看 AiMuxError::is_retryable(error.rs:691)和响应头

这些位置都把底层错误 to_string() 进消息,错误本身丢掉:FetchError 的载荷是 String,reqwest 的原因链只剩第一层文字,而且文字里带着完整 URL。AI SDK 在同样的位置都把原错误作为 APICallError.cause。分类只看结构化字段(status_code、is_retryable、response_headers),本 PR 不改分类。

本 PR 的改动

核心类型,aimux-core/src/error.rs:

ApiCallError
└ + cause: Option<ErrorCause>                   序列化为可选的 "cause",没有时不输出;.caused_by(..)
  └ impl Error::source()                        返回 cause
ErrorCause = AiMux(Box<AiMuxError>) | Source(SourceError)
├ ErrorCause::source(&dyn Error)                是 AiMuxError 就整个保留,否则抄下整条 source() 链
└ "x-aimux-codec": "ordered-untagged"           生成器先试 AiMuxError(有 name),再试 SourceError
SourceError { message, cause: Option<Box<SourceError>> }
├ SourceError::new(&error)                      Display 文字 + 递归的 source()
└ SourceError::with_cause(message, &error)      自己的描述 + 底层错误
RetryError::source()                            最后一次尝试(AI SDK RetryError.lastError)
ToolCallRepair                                  cause 标 #[source]

网络层,aimux-provider-utils:

map_reqwest_error                               fetch.rs
└ error.without_url() → FetchError::Connect / Io / Other(SourceError::new(&error))
  FetchError 的 #[error] 改为 transparent,Display 文字不变
fetch_error_to_ai_mux_error                     handle_fetch_error.rs
└ ApiCallError { cause: Some(ErrorCause::source(&fetch_error)), .. }
execute_exchange                                post_to_api.rs
├ “Failed to process error response”           .caused_by(handler 的错误)
└ “Failed to process successful response”      .caused_by(handler 的错误)
read_response_with_size_limit                   读 body 失败 → cause
create_json_response_handler                    serde 错误 → cause
invalid_response_api_call(&error, ..)           参数从消息字符串改为错误本身:文字是消息,错误是 cause
create_event_source_response_handler            SSE 传输中断、分帧错误 → cause
ws_error(url, msg, source)                      WebSocket 连接、发送、接收错误 → cause;rustls 配置错误保留原因
redirect_error(.., cause)                       非法 Location、URL 解析失败 → cause
SigV4Fetch / ReqwestFetch / PinnedFetch         客户端构造、签名、取凭证失败 → SourceError::with_cause

provider 里自带 Fetch 的三处同样改为 SourceError:Azure 的 Bearer token、openai-compatible 的 User-Agent、Vertex 的证书传输(它也加了 without_url())。Jev 和 decision 支持代码按新签名把错误本身传给 invalid_response_api_call。Bedrock 的 event stream、Stability 和 xAI 的 JSON 解析在本 PR 就把原错误作为 ApiCallError.cause,这一步单独合并时网络层也是完整的。

调用方看到的(fetch.rs 新测试里连一个不监听的端口,实际打印的 source() 链):

API call error: error sending request          ← Display,已去掉 URL
└ error sending request                        ← ApiCallError
  └ cause: error sending request               ← reqwest::Error,已去掉 URL
    └ cause: client error (Connect)
      └ cause: tcp connect error
        └ cause: Connection refused (os error 111)

DTO:ErrorCause、SourceError 由 #268 的生成器从 Rust 类型生成到每种语言,没有手写。Swift 的结构体不能直接存自己类型的字段,生成器对这种字段(SourceError.cause)改为存在 WireIndirect 里,对外仍是 public var cause: SourceError?(tools/aimux-codegen/src/emit/swift.rs,带测试)。contract-tests/dto/AiMuxError/004.json 和 AiMuxErrorInput/004.json 是带传输链的 ApiCall 向量(输出和输入两个方向),取自 ops 错误黄金样例 aimux-api-call.json;005.json 是 cause 为嵌套 AI_JSONParseError 的 ApiCall,取自 post_to_api.rs 的新测试。都登记在 index.json 和 sources.json,向量总数从 226 变成 230。各语言把 cause 还原成原生的 cause 在第 3 步。

文档:CHANGELOG Breaking(含传输错误消息去掉 URL、invalid_response_api_call 的签名变化),docs/ai-sdk-request-pipeline.md 里 “本轮不加 cause” 改为现在的做法和差异(外部错误存成可序列化的链、消息仍含原文字、URL 去掉),docs/rfc-0039-conformance.md 的向量数改为 230、类型文件数改为 300。

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change
  • New provider
  • New / updated binding
  • Docs / RFC

Breaking:FetchError 的载荷从 String 改为 SourceError;invalid_response_api_call 第一个参数改为错误本身;传输错误的消息不再带 URL。JSON 只多一个可选字段。

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace --all-targets -- -D warnings passes
  • cargo test --workspace passes (cassette playback — no network/keys)
  • Added or updated cassette tests / contract fixtures for behavior changes
  • Updated relevant docs (README.md, docs/, rfc/, bindings/README.md)
  • Commit messages follow Conventional Commits

新测试:fetch.rs 真的连一个绑定着但不监听的端口(测试期间一直持有,端口不会被别人复用;显式代理配置加 no_proxy,环境代理不影响),检查原因链到 OS 错误、链里没有 URL 里的 key、ApiCallError.cause 就是这条链;post_to_api.rs 检查 2xx handler 失败时 cause 是整个嵌套的 aimux 错误;error.rs 检查 cause 的 source() 链、JSON 往返、嵌套 aimux 错误保留名字、Retry 的 source 是最后一次尝试。

Notes for reviewers

@cunninghamcard-bit

Copy link
Copy Markdown
Contributor Author

更正:描述里"消息文字不变"不准确。map_reqwest_error 现在先调 without_url(),传输错误的消息从 error sending request for url (…) 变成 error sending request。这条我会补进 CHANGELOG 的 Breaking。

URL 剥离只覆盖 aimux-provider-utils/src/fetch.rs 和 aimux-providers/src/vertex/auth_certificate.rs。aimux-core/src/download.rs 用自己的 reqwest 客户端,下载错误的消息里仍带 URL。描述里"URL 从原因链里去掉"这句会改成只说传输层。

本 PR 新增了 AiMuxError/004.json 和 AiMuxErrorInput/004.json 两个向量,总数从 226 变成 228。docs/rfc-0039-conformance.md 的 :261 和 :305 两处我会改成 228,描述里也会补上 AiMuxErrorInput/004.json。

Copy link
Copy Markdown
Contributor Author

上面三点已在 bebb111 处理:CHANGELOG Breaking 写明传输错误消息从 error sending request for url (…) 变成 error sending request;描述改为只说传输层去掉 URL,下载错误在 #290 处理;docs/rfc-0039-conformance.md 两处改为 228,描述补上 AiMuxErrorInput/004.json。


Generated by Claude Code

@cunninghamcard-bit
cunninghamcard-bit changed the base branch from v4/15-generate-binding-dtos-from-rust-types to master October 20, 2026 07:23
cunninghamcard-bit added a commit that referenced this pull request Oct 20, 2026
…entation

Line numbers now refer to master 4220847 (#268 merged). The text follows
what #289-#293 implement: TypeValidation is the cause of schema failures,
transport messages drop the URL, and section 12 names the three PRs.
The open questions in section 10 are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
cunninghamcard-bit added a commit that referenced this pull request Oct 20, 2026
…s in #276

The findings from implementing #289-#293 (TypeValidation, transport
messages without the URL, the call-site count, the download error type)
move to issue #276. Only the baseline and line numbers stay updated to
master 4220847.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
cunninghamcard-bit added a commit that referenced this pull request Oct 20, 2026
The RFC is still open, so what #289-#293 found belongs in its text:
TypeValidation as the cause of schema failures, transport and download
messages without the URL, the 117 call sites, and reqwest::Error as the
download error. Baseline and line numbers stay at master 4220847.
Section 10's open questions are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
@cunninghamcard-bit
cunninghamcard-bit marked this pull request as ready for review October 20, 2026 10:41
cunninghamcard-bit added a commit that referenced this pull request Oct 20, 2026
Describes how aimux's error system differs from the AI SDK's, traces
today's error paths from the public entry functions to the lines that
drop the underlying error, and designs `cause` across the core, the
transport layer, the ops envelope, the generated DTOs and every binding.
The open questions in section 10 stay open. Line numbers refer to
master 4220847, and the text includes what implementing #289, #290
and #293 found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
@cunninghamcard-bit
cunninghamcard-bit force-pushed the cause/1-error-cause-in-core-and-network branch 2 times, most recently from a04f514 to 7d78192 Compare October 20, 2026 10:51

@eric8810 eric8810 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.

Review

未发现阻塞项。ErrorCause/SourceError、ApiCallError.cause、URL 去除、重试语义和 DTO 生成都成立,真实连接测试也覆盖了 source 链和 query 不泄漏。

建议后续修的点(不阻塞):

  • aimux-provider-utils/src/fetch.rs 的连接失败测试先 bind 再 drop 端口,存在端口被复用和系统代理干扰的偶发失败风险;建议改成确定性 mock,或保持 listener 绑定。
  • contract-tests/dto 只有 SourceError 形状的 cause 向量,缺少嵌套 AiMux cause 的跨语言向量。
  • docs/rfc-0039-conformance.md 的 schema/types 文件数仍是 297,新增后应为 300;CHANGELOG 漏了 invalid_response_api_call 的签名变化。
  • Bedrock event stream、Stability/xAI 的 JSON 解析 cause 在 #290 才补;如果 #289 单独合并,第一步的覆盖不完整。
  • 分支基于 42208472,合并前建议 rebase 到当前 master。

cunninghamcard-bit and others added 6 commits October 20, 2026 14:26
ErrorCause is a nested AiMuxError or a SourceError chain copied from an
external error's source() chain. It is what source() returns and
serializes as an optional `cause`. ApiCallError gains cause and
caused_by(..); RetryError's source is its last attempt.

Part 1 of RFC-0047.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
FetchError carries a SourceError built from reqwest's error after
without_url(), and fetch, redirects, body reads, the JSON and SSE
handlers and WebSocket keep the original error as the cause. Transport
messages lose the URL: 'error sending request for url (...)' is now
'error sending request'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
A Swift struct cannot store itself inline, so SourceError.cause did not
compile. The field keeps its public name and type and is stored in a
WireIndirect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
ErrorCause and SourceError reach every binding through the generator.
AiMuxError/004 and AiMuxErrorInput/004 carry an ApiCall cause chain
and are registered in index.json and sources.json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
CHANGELOG Breaking, the request pipeline's cause row, and 228 DTO
vectors in the conformance doc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
cunninghamcard-bit added a commit that referenced this pull request Oct 20, 2026
Describes how aimux's error system differs from the AI SDK's, traces
today's error paths from the public entry functions to the lines that
drop the underlying error, and designs `cause` across the core, the
transport layer, the ops envelope, the generated DTOs and every binding.
The open questions in section 10 stay open. Line numbers refer to
master 4220847, and the text includes what implementing #289, #290
and #293 found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C
@cunninghamcard-bit
cunninghamcard-bit force-pushed the cause/1-error-cause-in-core-and-network branch from 7d78192 to bec5828 Compare October 20, 2026 14:43

Copy link
Copy Markdown
Contributor Author

回复 @eric8810 的 review(左列是 eric 的原话):

eric 的意见 处理 位置
「连接失败测试先 bind 再 drop 端口,存在端口被复用和系统代理干扰的偶发失败风险」 改为用 socket2 绑定一个不监听的 socket,测试期间一直持有,端口不会被复用;ReqwestFetch 显式配置代理并 no_proxy: 127.0.0.1,环境里的代理变量不再生效 b034155 aimux-provider-utils/src/fetch.rs 测试 a_refused_connection_keeps_its_chain_without_the_url,dev-dependency socket2
「contract-tests/dto 只有 SourceError 形状的 cause 向量,缺少嵌套 AiMux cause 的跨语言向量」 新增 AiMuxError/005.json、AiMuxErrorInput/005.json:ApiCall 的 cause 是嵌套的 AI_JSONParseError;新测试检查 2xx handler 失败时的 cause 就是这个形状。#290 的向量顺延为 006–009 00492d8 contract-tests/dto,b0341551 post_to_api.rs 测试 a_failed_success_handler_keeps_its_error_as_the_cause
「schema/types 文件数仍是 297,新增后应为 300;CHANGELOG 漏了 invalid_response_api_call 的签名变化」 改为 300,向量数改为 230;CHANGELOG 补上签名变化 bec5828 docs/rfc-0039-conformance.md、CHANGELOG.md
「Bedrock event stream、Stability/xAI 的 JSON 解析 cause 在 #290 才补」 这三处移到本 PR,#289 单独合并时网络层也完整 ae5858f aimux-providers/src/bedrock/model.rs、stability.rs、xai/mod.rs
「分支基于 42208472,合并前建议 rebase 到当前 master」 整条栈 rebase 到 master e115e723(含 #284 #285 #288 #291);#291 删掉的 vertex/model.rs 不再改,Google 模型那边的 cause 照旧 本 PR 6 个提交

本地:fmt、clippy -D warnings、cargo test --workspace、生成器 --check、provider 边界检查、DTO 向量(Rust、Python、Node、Java)都通过,每一层单独 cargo check 通过。


Generated by Claude Code

@eric8810
eric8810 merged commit 24977c4 into master Oct 20, 2026
22 checks passed
eric8810 pushed a commit that referenced this pull request Oct 21, 2026
* docs(rfc): RFC-0047 error system and error cause

Describes how aimux's error system differs from the AI SDK's, traces
today's error paths from the public entry functions to the lines that
drop the underlying error, and designs `cause` across the core, the
transport layer, the ops envelope, the generated DTOs and every binding.
The open questions in section 10 stay open. Line numbers refer to
master 4220847, and the text includes what implementing #289, #290
and #293 found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C

* docs(rfc): answer the RFC-0047 review

Retry's lastError as every binding's native cause, the Node and Java
cause classes, an accurate security section with a size and redaction
question, the full list of breaking changes, Swift's WireIndirect, and
how RFC-0044, RFC-0048 and #349 meet this RFC. Q2 and Q3 are marked
adopted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2GJxE3fH7rQvAwXLg2f1C

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
cunninghamcard-bit added a commit that referenced this pull request Oct 24, 2026
Brings in the error-cause series (#289, #290, #293). Conflicts: ops
tests take master's `InvalidParams { .. }` without `unprojected()`;
envelope.rs keeps `BindingError::result_serialization` without a clock;
binding READMEs and Node error.ts keep the cause text without
retryAfterMs. Bindings regenerated with aimux-codegen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5j9hYv7hDeYgMvvRu2F9T
cunninghamcard-bit added a commit that referenced this pull request Oct 24, 2026
…h-the-ai-sdk

master's error-cause change (#289/#290/#293) made InvalidResponseData a struct variant; closed_before_finishing now builds it with AiMuxError::invalid_response_data.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
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