Skip to content

AppServer错误码体系

沿解析、请求拒绝、业务映射、运行中通知和客户端解码追踪 App Server 错误,解释数值 code、结构化 data、Turn 终态与重试责任。

基于rust-v0.150.0
CodexRustAppServerJSON-RPC

AppServer错误码体系 ​

客户端调用 turn/start 得到成功响应,随后却收到 error,最后 turn/completed 的状态是 failed。 另一条请求直接返回 -32600,却不影响正在运行的 Turn。还有一次连接已满,连错误响应也没有收到。 要解释这些现象,需要把请求是否被接纳、工作是否执行成功、错误是否送达分开追踪。

本文面向了解 Rust Result、serde 和异步消息的读者。 AppServer架构总览 提供组件边界, MessageProcessor读循环 解释消息接入, 服务端请求与客户端响应 说明需要客户端回答的反向请求。 这里以错误产生点为主线,连接到出站队列、Turn 状态和客户端分层结果;不展开所有业务 API, 也不把 exec-server 的另一套 RPC 实现混入同一张错误码表。

Request ID 配对一次 RPC;Thread ID 标识会话;Turn ID 标识会话中的一轮工作。 一次 Turn 可发起多次模型请求,也可产生多条通知,因而不能用一个 RPC 的返回值代表全部执行结果。 读完应能判断一份错误属于哪个返回面、找到实际 mapper、解释 data 的形状, 并确定是修正参数、等待当前重试、重新读取状态,还是诊断连接与响应解码问题。

1. 错误返回面 ​

1.1 RPC 返回 ​

源码文件:codex-rs/app-server-protocol/src/rpc.rs

相关函数/类型:JSONRPCError / JSONRPCErrorError(L74–L88,摘录)

rust
// 作者注:error 是与原 request id 配对的返回;data 可以缺省,并非固定的 TurnError。
/// A response to a request that indicates an error occurred.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct JSONRPCError {
    pub error: JSONRPCErrorError,
    pub id: RequestId,
}

#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct JSONRPCErrorError {
    pub code: i64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub data: Option<serde_json::Value>,
    pub message: String,
}

JSONRPCError 的 id 对应原请求,内部 JSONRPCErrorError 才包含数值 code。 data 是任意 JSON 值,可完全缺省,不能假设它总能反序列化为一个业务结构。 同文件的 RequestId 只定义字符串和 i64;这套 envelope 也不发送或要求 jsonrpc: "2.0" 字段。 因此先读实际封装,再套用外部 JSON-RPC 术语才不会误判。

生成的 schema/json/JSONRPCErrorError.json 与 Rust 定义一致:只要求 code 和 message, data 的 schema 为 true,没有统一业务形状。不同 method 给 data 放不同内容,是下面多个 mapper 的显式选择。

1.2 工作通知 ​

源码文件:codex-rs/app-server-protocol/src/protocol/v2/notification.rs

相关函数/类型:ErrorNotification(L49–L59,摘录)

rust
// 作者注:运行中通知按 threadId/turnId 关联,willRetry 由服务端产生路径设置。
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ErrorNotification {
    pub error: TurnError,
    // Set to true if the error is transient and the app-server process will automatically retry.
    // If true, this will not interrupt a turn.
    pub will_retry: bool,
    pub thread_id: String,
    pub turn_id: String,
}

ErrorNotification 随 ServerNotification::Error 发出,对应 wire method error。 它携带 Thread/Turn ID 和 willRetry,不通过 RPC request ID 结束一个请求等待者。 同名 error 在顶层 envelope 和 notification 的 params 中,是两个不同协议位置。

源码文件:codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs

相关函数/类型:TurnError(L388–L397,摘录)

rust
// 作者注:语义错误不携带 JSON-RPC 数值 code,附加字段可为 null。
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
#[error("{message}")]
pub struct TurnError {
    pub message: String,
    pub codex_error_info: Option<CodexErrorInfo>,
    #[serde(default)]
    pub additional_details: Option<String>,
}

TurnError 使用 codexErrorInfo 描述语义,数值 RPC code 不在这个结构里。 两个 Option 字段没有 skip_serializing_if,可以呈现为 JSON null; 不能照搬 JSONRPCErrorError.data 的缺省规则。 客户端还会把传输失败、RPC 拒绝和成功响应的解码失败包装成 TypedRequestError,后文再跟到它的产生点。

下图区分错误负载之间的组合与转换。图中的箭头不表示所有 Core 错误都会被转换成同一份 RPC 返回。

看到的位置配对依据可以说明什么
顶层 {id, error}RPC request ID本次调用返回失败
method: error 的 paramsThread/Turn ID正在执行的工作遇到暂时或终止性错误
turn/completed 中的 turn.errorTurn ID该轮对外公布的最终状态及错误
Rust TypedRequestError客户端调用及 method客户端在哪一层拿不到期望结果

1.3 公共数值 ​

源码文件:codex-rs/app-server/src/error_code.rs

相关函数/类型:INVALID_REQUEST_ERROR_CODE / INPUT_TOO_LARGE_ERROR_CODE(L3–L32,摘录)

rust
// 作者注:数值 code 是公共请求分类;input_too_large 则是 data 内的字符串子码。
pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
pub(crate) const METHOD_NOT_FOUND_ERROR_CODE: i64 = -32601;
pub const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603;
pub(crate) const OVERLOADED_ERROR_CODE: i64 = -32001;
pub const INPUT_TOO_LARGE_ERROR_CODE: &str = "input_too_large";

pub(crate) fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
    error(INVALID_REQUEST_ERROR_CODE, message)
}

pub(crate) fn method_not_found(message: impl Into<String>) -> JSONRPCErrorError {
    error(METHOD_NOT_FOUND_ERROR_CODE, message)
}

pub(crate) fn invalid_params(message: impl Into<String>) -> JSONRPCErrorError {
    error(INVALID_PARAMS_ERROR_CODE, message)
}

pub(crate) fn internal_error(message: impl Into<String>) -> JSONRPCErrorError {
    error(INTERNAL_ERROR_CODE, message)
}

fn error(code: i64, message: impl Into<String>) -> JSONRPCErrorError {
    JSONRPCErrorError {
        code,
        message: message.into(),
        data: None,
    }
}

这是 App Server 公共辅助函数中的实际数值集合。辅助函数只填 code/message,data 留空, 业务 processor 再按需要补充。transport crate 也有独立的 internal/overloaded 常量,必须一起追踪。

code辅助名称当前实现中的代表来源
-32600invalid requesttyped 解码、未初始化、实验能力未启用、许多业务拒绝
-32601method not found已知 Thread Section 方法的 store 能力不可用
-32602invalid params被移除的权限字段、输入超限、部分业务参数校验
-32603internal error未细分的底层失败、部分 Core 提交失败、响应序列化失败
-32001overloaded接收队列满时对新 request 的拒绝

这张表描述产生位置,不把相同名称当成自动生效的全局类型映射。 例如线程不存在不一定用独立自定义数字,HTTP 401 也不是固定放进 RPC code。 input_too_large 是字符串子码,不是第六个数值错误。

2. 入站拒绝 ​

2.1 消息解析 ​

源码文件:codex-rs/app-server-transport/src/transport/mod.rs

相关函数/类型:forward_incoming_message(L202–L217,摘录)

rust
// 作者注:无法解码时只记日志并允许读循环继续,没有构造 -32700 返回。
async fn forward_incoming_message(
    transport_event_tx: &mpsc::Sender<TransportEvent>,
    writer: &mpsc::Sender<QueuedOutgoingMessage>,
    connection_id: ConnectionId,
    payload: &str,
) -> bool {
    match serde_json::from_str::<JSONRPCMessage>(payload) {
        Ok(message) => {
            enqueue_incoming_message(transport_event_tx, writer, connection_id, message).await
        }
        Err(err) => {
            error!("Failed to deserialize JSONRPCMessage: {err}");
            true
        }
    }
}

常规文本 transport 先反序列化为 JSONRPCMessage。失败时记录 Failed to deserialize JSONRPCMessage,返回 true 让读循环继续;这里没有构造 -32700。 stdio 读循环在收到 false 时才停止转发,并在 EOF/读取错误后发送 ConnectionClosed。 所以一条坏 JSON 后没有收到错误回复,并不自动等于进程已经退出。

还要注意 untagged envelope 的匹配:必须确实得到 Request 变体,后续才走请求错误路径。 不能假定任意含有 id 文本的对象都能拿到可配对的失败返回。 嵌入式 typed 入口直接接收 ClientRequest,跳过这一步文本解码。

源码文件:codex-rs/app-server/src/message_processor.rs

相关函数/类型:deserialize_client_request / reject_removed_permission_profile(L104–L109、L116–L133,摘录)

rust
// 作者注:旧字段检查先执行,随后 typed 反序列化失败统一为 invalid_request。
fn deserialize_client_request(request: JSONRPCRequest) -> Result<ClientRequest, JSONRPCErrorError> {
    reject_obsolete_request_fields(&request)?;

    ClientRequest::try_from(request)
        .map_err(|err| invalid_request(format!("Invalid request: {err}")))
}

// ...

fn reject_removed_permission_profile(request: &JSONRPCRequest) -> Result<(), JSONRPCErrorError> {
    if matches!(
        request.method.as_str(),
        "thread/start" | "thread/resume" | "thread/fork" | "turn/start"
    ) && request
        .params
        .as_ref()
        .and_then(serde_json::Value::as_object)
        .is_some_and(|params| params.contains_key("permissionProfile"))
    {
        let method = request.method.as_str();
        return Err(invalid_params(format!(
            "`permissionProfile` is no longer supported for `{method}`; use `permissions` with a named profile id instead"
        )));
    }

    Ok(())
}

typed 转换之前有一个明确的例外:四个方法携带已移除的 permissionProfile 字段时,先返回 -32602。 检查依据是字段是否存在,值为 null 也不会被当作“没有传”。这避免未知字段兼容策略悄悄吞掉旧的权限设置。 一般的 typed 转换错误则统一经过 invalid_request,数值为 -32600。

源码文件:codex-rs/app-server-protocol/src/protocol/common.rs

相关函数/类型:TryFrom<JSONRPCRequest> for ClientRequest(L270–L288,摘录)

rust
// 作者注:method 与 params 重新进入 serde 生成的 ClientRequest,没有独立 unknown-method 数值映射。
impl TryFrom<JSONRPCRequest> for ClientRequest {
    type Error = serde_json::Error;

    fn try_from(request: JSONRPCRequest) -> Result<Self, Self::Error> {
        let JSONRPCRequest {
            id: request_id,
            method,
            params,
            trace: _,
        } = request;
        let mut request = serde_json::Map::new();
        request.insert("id".to_string(), serde_json::to_value(request_id)?);
        request.insert("method".to_string(), serde_json::Value::String(method));
        if let Some(params) = params {
            request.insert("params".to_string(), params);
        }
        serde_json::from_value(serde_json::Value::Object(request))
    }
}

宏生成的转换重新组装 id/method/params,交给 serde 解码 ClientRequest。 未知 method、缺失必需参数和错误字段类型都可能在此失败;外层没有把它们再拆成 “未知方法 -32601、参数类型错误 -32602”两套路径。 要增加精细分类,应修改实际转换/包装逻辑,单改常量名称不会改变行为。

2.2 连接接纳 ​

源码文件:codex-rs/app-server/src/message_processor.rs

相关函数/类型:dispatch_initialized_client_request(L887–L895,局部节选)

rust
// 作者注:握手状态与实验能力都属于请求接纳条件,失败不进入业务 processor。
// ...
if !session.initialized() {
    return Err(invalid_request("Not initialized"));
}

if let Some(reason) = codex_request.experimental_reason()
    && !session.experimental_api_enabled()
{
    return Err(invalid_request(experimental_required_message(reason)));
}
// ...

正确解码的非初始化请求仍需通过会话状态和实验 API gate。 未初始化返回 Not initialized,未开启所需能力也返回 -32600。 初始化自己还检查重复初始化与 clientInfo.name 是否能成为 HTTP header 值,位置在 request_processors/initialize_processor.rs;这些是接纳约束,不是模型执行失败。

下面聚焦非 initialize 的业务 Request,其他消息类型的满队列处理在下一段展开。

  • 队列拒绝发生在业务处理之前。
  • 旧字段检查先于普通 typed 解码,后者又先于连接 gate。
  • 同一请求若同时违反多条条件,先执行的失败分支决定实际返回,不是事后汇总全部错误。

2.3 过载返回 ​

源码文件:codex-rs/app-server-transport/src/transport/mod.rs

相关函数/类型:enqueue_incoming_message(L219–L258,摘录)

rust
// 作者注:请求满队列时尝试回过载;非 request 消息则等待容量,避免丢失反向调用答复。
async fn enqueue_incoming_message(
    transport_event_tx: &mpsc::Sender<TransportEvent>,
    writer: &mpsc::Sender<QueuedOutgoingMessage>,
    connection_id: ConnectionId,
    message: JSONRPCMessage,
) -> bool {
    let event = TransportEvent::IncomingMessage {
        connection_id,
        message,
    };
    match transport_event_tx.try_send(event) {
        Ok(()) => true,
        Err(mpsc::error::TrySendError::Closed(_)) => false,
        Err(mpsc::error::TrySendError::Full(TransportEvent::IncomingMessage {
            connection_id,
            message: JSONRPCMessage::Request(request),
        })) => {
            let overload_error = OutgoingMessage::Error(OutgoingError {
                id: request.id,
                error: JSONRPCErrorError {
                    code: OVERLOADED_ERROR_CODE,
                    message: "Server overloaded; retry later.".to_string(),
                    data: None,
                },
            });
            match writer.try_send(QueuedOutgoingMessage::new(overload_error)) {
                Ok(()) => true,
                Err(mpsc::error::TrySendError::Closed(_)) => false,
                // 作者注:错误本身也可能因出站队列满而丢弃,此处不会无限等待。
                Err(mpsc::error::TrySendError::Full(_overload_error)) => {
                    warn!(
                        "dropping overload response for connection {:?}: outbound queue is full",
                        connection_id
                    );
                    true
                }
            }
        }
        Err(mpsc::error::TrySendError::Full(event)) => transport_event_tx.send(event).await.is_ok(),
    }
}

请求满队列时,transport 取原 request.id 构造 -32001,并非进入 processor 后才产生限流。 出站错误使用 try_send:如果 writer 也满,只记录丢弃并继续,客户端未必收到这份拒绝。 这里的 true 表示读循环可以继续,不是消息成功送达。

非 Request 的消息走最后一条分支,等待入站队列容量。它们可能是反向请求的 Response 或 Error, 若也一概丢弃,会让服务端已经注册的等待者无法得到回答。 这项差异由消息方向决定,不能给所有入站对象套同一过载策略。

源码文件:codex-rs/app-server-transport/src/transport/mod.rs

相关函数/类型:enqueue_incoming_request_returns_overload_error_when_queue_is_full(L438–L453,局部节选)

rust
// 作者注:夹具先用容量 1 的入站队列存一条通知,再验证新请求收到同 ID 的过载错误。
// ...
let overload = writer_rx
    .recv()
    .await
    .expect("request should receive overload error");
let overload_json =
    serde_json::to_value(overload.message).expect("serialize overload error");
assert_eq!(
    overload_json,
    json!({
        "id": 7,
        "error": {
            "code": OVERLOADED_ERROR_CODE,
            "message": "Server overloaded; retry later."
        }
    })
);
// ...

测试以一条通知占满容量为 1 的入站队列,再提交 config/read 请求 ID 7。 它验证原通知仍留在队列、错误的 ID 仍为 7、数值为 -32001。 另一个 enqueue_incoming_request_does_not_block_when_writer_queue_is_full 同时填满两队列,用 100 ms 超时约束断言函数不阻塞;这验证的是丢弃后的继续策略,不能证明拒绝响应送达。

2.4 能力缺席 ​

源码文件:codex-rs/app-server/src/request_processors/thread_sections.rs

相关函数/类型:ensure_thread_sections_supported / thread_section_store_error(L164–L173、L214–L234,摘录)

rust
// 作者注:已知方法也能因 store 不支持而返回 -32601;同一业务的参数与存储失败另作映射。
fn ensure_thread_sections_supported(
    &self,
    operation: &'static str,
) -> Result<(), JSONRPCErrorError> {
    if self.thread_store.supports_thread_sections() {
        Ok(())
    } else {
        Err(unsupported_thread_section_operation(operation))
    }
}

// ...

fn unsupported_thread_section_operation(operation: &'static str) -> JSONRPCErrorError {
    method_not_found(format!("{operation} is unavailable without sqlite state"))
}

fn thread_section_store_error(
    operation: &'static str,
    error: ThreadStoreError,
) -> JSONRPCErrorError {
    match error {
        ThreadStoreError::Unsupported { .. } => unsupported_thread_section_operation(operation),
        ThreadStoreError::InvalidRequest { message } => invalid_params(message),
        error @ (ThreadStoreError::ThreadNotFound { .. }
        | ThreadStoreError::Conflict { .. }
        | ThreadStoreError::Internal { .. }) => {
            let action = operation
                .strip_prefix("threadSection/")
                .unwrap_or(operation);
            internal_error(format!("failed to {action} thread section: {error}"))
        }
    }
}

Thread Section 方法已经存在于协议中,但 store 不支持这项能力时仍使用 method_not_found。 同一 mapper 对 InvalidRequest 用 -32602,对存储冲突等其他错误用 -32603。 因此 -32601 不能只被解释为客户端拼错方法名,是否具备后端能力也是实际检查项。

源码文件:codex-rs/app-server/tests/suite/v2/request_validation.rs

相关函数/类型:legacy_permission_profile_requests_fail_closed(L79–L110,局部节选)

rust
// 作者注:四个方法都拒绝旧字段;最后检查线程列表仍为空,验证拒绝发生在创建之前。
// ...
for (method, params) in requests {
    let request_id = mcp.send_raw_request(method, Some(params)).await?;
    let actual: JSONRPCError = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
    )
    .await??;
    let expected = JSONRPCError {
        id: RequestId::Integer(request_id),
        error: JSONRPCErrorError {
            code: INVALID_PARAMS_ERROR_CODE,
            data: None,
            message: format!(
                "`permissionProfile` is no longer supported for `{method}`; use `permissions` with a named profile id instead"
            ),
        },
    };
    assert_eq!(actual, expected, "unexpected response for {method}");
}

let request_id = mcp
    .send_thread_loaded_list_request(ThreadLoadedListParams::default())
    .await?;
let actual: ThreadLoadedListResponse =
    timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
    actual,
    ThreadLoadedListResponse {
        data: Vec::new(),
        next_cursor: None,
    }
);
// ...

这项测试的请求表覆盖 thread/start、thread/resume、thread/fork、turn/start。 即使参数里还有不存在的 thread,旧字段检查也先拒绝;随后 loaded list 为空,说明没有靠兼容解码 意外创建一条会话。它同时说明 source 顺序与对外结果应一起核对,不能只测常量值。

3. 拒绝详情 ​

3.1 输入长度 ​

源码文件:codex-rs/app-server/src/request_processors/turn_processor.rs

相关函数/类型:input_too_large_error / validate_v2_input_limit(L436–L454,摘录)

rust
// 作者注:跨全部输入项累加,再以严格大于判断超限,结构化详情保存上限与实际计数。
pub(super) fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError {
    let mut error = invalid_params(format!(
        "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters."
    ));
    error.data = Some(serde_json::json!({
        "input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
        "max_chars": MAX_USER_INPUT_TEXT_CHARS,
        "actual_chars": actual_chars,
    }));
    error
}

pub(super) fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> {
    let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum();
    if actual_chars > MAX_USER_INPUT_TEXT_CHARS {
        return Err(Self::input_too_large_error(actual_chars));
    }
    Ok(())
}

MAX_USER_INPUT_TEXT_CHARS 在 codex-rs/protocol/src/user_input.rs 定义为 1 << 20。 actual_chars 是所有输入项累计值,只有严格大于上限才拒绝。 拒绝返回 -32602,同时保留 input_error_code/max_chars/actual_chars,客户端可以据此提示用户缩短输入。 这里的限制不是模型上下文 token 预算。

源码文件:codex-rs/app-server-protocol/src/protocol/v2/turn.rs

相关函数/类型:UserInput::text_char_count(L374–L386,摘录)

rust
// 作者注:只统计 Text 的 Unicode scalar 数量,其他输入变体在此计为零。
impl UserInput {
    pub fn text_char_count(&self) -> usize {
        match self {
            UserInput::Text { text, .. } => text.chars().count(),
            UserInput::Image { .. }
            | UserInput::LocalImage { .. }
            | UserInput::Audio { .. }
            | UserInput::LocalAudio { .. }
            | UserInput::Skill { .. }
            | UserInput::Mention { .. } => 0,
        }
    }
}

Rust 的 chars() 统计 Unicode scalar value,不是 UTF-8 字节数,也不是用户感知的组合字素数。 图像、音频、Skill、Mention 在此函数中计零,它不衡量整个请求体的字节大小。 多个 Text 项各自不超限,累计后仍可能失败,这正是下面的测试输入。

源码文件:codex-rs/app-server/tests/suite/v2/turn_start.rs

相关函数/类型:turn_start_rejects_combined_oversized_text_input(L1431–L1456、L1458–L1476,局部节选)

rust
// 作者注:两段各自未超限但合计超过上限,测试同时检查 data 与观察窗口内没有 turn/started。
// ...
let first = "x".repeat(MAX_USER_INPUT_TEXT_CHARS / 2);
let second = "y".repeat(MAX_USER_INPUT_TEXT_CHARS / 2 + 1);
let actual_chars = first.chars().count() + second.chars().count();

let turn_req = mcp
    .send_turn_start_request(TurnStartParams {
        thread_id: thread.id,
        client_user_message_id: None,
        input: vec![
            V2UserInput::Text {
                text: first,
                text_elements: Vec::new(),
            },
            V2UserInput::Text {
                text: second,
                text_elements: Vec::new(),
            },
        ],
        ..Default::default()
    })
    .await?;
let err: JSONRPCError = timeout(
    DEFAULT_READ_TIMEOUT,
    mcp.read_stream_until_error_message(RequestId::Integer(turn_req)),
)
.await??;

// ...

assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE);
assert_eq!(
    err.error.message,
    format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.")
);
let data = err.error.data.expect("expected structured error data");
assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE);
assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS);
assert_eq!(data["actual_chars"], actual_chars);

let turn_started = tokio::time::timeout(
    std::time::Duration::from_millis(250),
    mcp.read_stream_until_notification_message("turn/started"),
)
.await;
assert!(
    turn_started.is_err(),
    "did not expect a turn/started notification for rejected input"
);
// ...

测试用两段文本组成 上限 + 1,断言错误子码、上限、实际计数,并在 250 ms 观察窗口内没有 turn/started。 源码中 validate_v2_input_limit 位于提交 Core 输入之前,为“不启动该次工作”的结论提供另一侧证据。 相邻的 turn_start_accepts_text_at_limit_with_mention_item 则验证正好等于上限且另带 Mention 的正向边界。

3.2 Core 接纳 ​

源码文件:codex-rs/app-server/src/request_processors/turn_processor.rs

相关函数/类型:turn_start_inner(L534–L563、L580–L594,局部节选)

rust
// 作者注:Core 返回 Err 与 NotSubmitted 在这个入口均变为 internal_error;成功响应只表示接纳。
// ...
let submission = thread
    .start_or_steer_turn(
        TurnInputRequest::new(TurnInput::UserInput {
            content: mapped_items,
            client_id: client_user_message_id,
        })
        .with_thread_settings(thread_settings)
        .on_start(TurnStartOptions {
            final_output_json_schema: params.output_schema,
            ..Default::default()
        })
        .with_additional_context(additional_context)
        .with_responses_metadata(params.responsesapi_client_metadata)
        .with_trace(self.request_trace_context(&request_id).await),
    )
    .await
    .map_err(|err| {
        let error = internal_error(format!("failed to submit turn input: {err}"));
        self.track_error_response(&request_id, &error, /*error_type*/ None);
        error
    })?;
let (turn_id, started) = match submission {
    TurnInputSubmission::Started { turn_id } => (turn_id, true),
    TurnInputSubmission::Steered { turn_id } => (turn_id, false),
    TurnInputSubmission::NotSubmitted { reason } => {
        let error = internal_error(format!("failed to submit turn input: {reason:?}"));
        self.track_error_response(&request_id, &error, /*error_type*/ None);
        return Err(error);
    }
};

// ...

self.outgoing
    .record_request_turn_id(&request_id, &turn_id)
    .await;
let turn = Turn {
    id: turn_id,
    items: vec![],
    items_view: TurnItemsView::NotLoaded,
    error: None,
    status: TurnStatus::InProgress,
    started_at: None,
    completed_at: None,
    duration_ms: None,
};

Ok(TurnStartResponse { turn })
// ...

在 turn/start 入口,Core 调用返回的 Err 被包装为 -32603;NotSubmitted 也走 internal error。 Started 和 Steered 都可以变成成功响应,返回的 Turn 初始标记为 InProgress。 因此 turn/start 的成功只表示输入被开始或接入当前工作,并不等待整个 Turn 成功。

源码还说明了一个容易漏掉的局部差异:即使两个方法最终操作同一种 Core 输入,App Server 的 mapper 也可能不同。不要从共享 enum 的说明推导两个 API 必然返回相同数字和详情。

源码文件:codex-rs/app-server/src/request_processors/turn_processor.rs

相关函数/类型:turn_steer_inner(L957–L1033,局部节选)

rust
// 作者注:steer 的 NotSubmitted 另行映射为 invalid_request;只有不可 steer 类型附带 TurnError 详情。
// ...
let turn_id = match submission {
    SteerSubmission::Steered { turn_id } => turn_id,
    SteerSubmission::NotSubmitted { reason } => {
        let (message, data, error_type) = match reason {
            NotSubmittedReason::NoActiveTurn | NotSubmittedReason::NotIdle => (
                "no active turn to steer".to_string(),
                None,
                Some(AnalyticsJsonRpcError::TurnSteer(
                    TurnSteerRequestError::NoActiveTurn,
                )),
            ),
            NotSubmittedReason::ExpectedTurnMismatch { expected, actual } => (
                format!("expected active turn id `{expected}` but found `{actual}`"),
                None,
                Some(AnalyticsJsonRpcError::TurnSteer(
                    TurnSteerRequestError::ExpectedTurnMismatch,
                )),
            ),
            // 作者注:这个特殊拒绝为客户端提供结构化的 review/compact 类型。
            NotSubmittedReason::ActiveTurnNotSteerable { turn_kind } => {
                let (message, turn_steer_error) = match turn_kind {
                    codex_protocol::protocol::NonSteerableTurnKind::Review => (
                        "cannot steer a review turn".to_string(),
                        TurnSteerRequestError::NonSteerableReview,
                    ),
                    codex_protocol::protocol::NonSteerableTurnKind::Compact => (
                        "cannot steer a compact turn".to_string(),
                        TurnSteerRequestError::NonSteerableCompact,
                    ),
                };
                let error = TurnError {
                    message: message.clone(),
                    codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
                        turn_kind: turn_kind.into(),
                    }),
                    additional_details: None,
                };
                // 作者注:序列化详情失败会保留数值错误和消息,data 降为 None。
                let data = match serde_json::to_value(error) {
                    Ok(data) => Some(data),
                    Err(error) => {
                        tracing::error!(
                            ?error,
                            "failed to serialize active-turn-not-steerable turn error"
                        );
                        None
                    }
                };
                (
                    message,
                    data,
                    Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)),
                )
            }
            NotSubmittedReason::EmptyInput => (
                "input must not be empty".to_string(),
                None,
                Some(AnalyticsJsonRpcError::Input(InputError::Empty)),
            ),
            NotSubmittedReason::ActiveTurnOutputSchemaMismatch => (
                "active turn uses a different output schema".to_string(),
                None,
                None,
            ),
            NotSubmittedReason::PendingTriggerTurn | NotSubmittedReason::PlanMode => (
                "no active turn to steer".to_string(),
                None,
                Some(AnalyticsJsonRpcError::TurnSteer(
                    TurnSteerRequestError::NoActiveTurn,
                )),
            ),
        };
        let mut error = invalid_request(message);
        error.data = data;
        self.track_error_response(request_id, &error, error_type);
        return Err(error);
    }
};
Ok(TurnSteerResponse { turn_id })
// ...

turn/steer 的 NotSubmitted 最终统一经过 invalid_request,即 -32600。 但 ActiveTurnNotSteerable 会额外把 TurnError 序列化到 data,带出 review/compact 这类工作类型; 其他拒绝通常只有消息。序列化详情失败时,code 和消息仍返回,data 降为 None。

steer 拒绝原因这里生成的详情对客户端的意义
没有活动 Turn、NotIdle、待触发消息或 PlanMode无结构化 data重新判断会话当前是否有可接收的工作
expected ID 与实际 ID 不同消息包含二者先同步活动 Turn,避免把输入指向旧轮次
Review/Compact 不可 steerTurnError,含 activeTurnNotSteerable.turnKind当前工作类型不支持追加输入
空输入、输出 schema 不一致无结构化 data修正本次请求的内容或约束

turn_steer_requires_active_turn 在已创建但尚无活动 Turn 的会话中发送 steer,断言 -32600; 它还检查拒绝事件没有 accepted turn ID。此拒绝没有把某个既有 Turn 自动置为 Failed。 这些结论只描述这一 mapper,不能推广为所有 NotSubmitted 的统一 wire 契约。

4. 业务映射 ​

4.1 线程与文件 ​

源码文件:codex-rs/app-server/src/request_processors/turn_processor.rs

相关函数/类型:load_thread(L308–L323,摘录)

rust
// 作者注:线程 ID 解析失败或加载失败在这个辅助函数中统一归为 invalid_request。
async fn load_thread(
    &self,
    thread_id: &str,
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
    // Resolve the core conversation handle from a v2 thread id string.
    let thread_id = ThreadId::from_string(thread_id)
        .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;

    let thread = self
        .thread_manager
        .get_thread(thread_id)
        .await
        .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?;

    Ok((thread_id, thread))
}

这个辅助函数在多个 Turn API 前置执行:ID 解析失败和 ThreadManager 加载失败都变为 -32600。 它把后者的原错误隐藏在统一的 thread not found 消息后。 turn_start_inner 先 load thread 再检查输入长度,因此同时存在错误 thread 与过长输入时,应先观察前者的失败。

源码文件:codex-rs/app-server/src/request_processors/fs_processor.rs

相关函数/类型:FsRequestProcessor::read_file / map_fs_error(L64–L77、L215–L221,摘录)

rust
// 作者注:I/O 的 InvalidInput 与其他错误分开;NotFound 在本 mapper 内属于 internal_error。
pub(crate) async fn read_file(
    &self,
    params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
    let path = PathUri::from_abs_path(&params.path);
    let bytes = self
        .file_system()?
        .read_file(&path, Default::default(), /*sandbox*/ None)
        .await
        .map_err(map_fs_error)?;
    Ok(FsReadFileResponse {
        data_base64: STANDARD.encode(bytes),
    })
}

// ...

fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
    if err.kind() == io::ErrorKind::InvalidInput {
        invalid_request(err.to_string())
    } else {
        internal_error(err.to_string())
    }
}

文件系统调用走另一套 mapper:只有 io::ErrorKind::InvalidInput 变为 -32600, NotFound、PermissionDenied 等其余种类在此都归 -32603。 “资源不存在”在不同业务入口可能出现不同数值;这不是客户端只看 code 就能恢复成一张全局错误枚举的接口。 本地文件系统本身未配置时,file_system() 也会直接返回 internal error。

源码文件:codex-rs/app-server/src/request_processors/fs_processor.rs

相关函数/类型:FsRequestProcessor::write_file(L79–L94,摘录)

rust
// 作者注:base64 解码在写文件之前,失败直接返回请求错误。
pub(crate) async fn write_file(
    &self,
    params: FsWriteFileParams,
) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
    let bytes = STANDARD.decode(params.data_base64).map_err(|err| {
        invalid_request(format!(
            "fs/writeFile requires valid base64 dataBase64: {err}"
        ))
    })?;
    let path = PathUri::from_abs_path(&params.path);
    self.file_system()?
        .write_file(&path, bytes, Default::default(), /*sandbox*/ None)
        .await
        .map_err(map_fs_error)?;
    Ok(FsWriteFileResponse {})
}

base64 格式错误是写文件之前的直接拒绝,数值却使用 invalid_request,即 -32600。 这再次说明 invalid_params 并非所有参数问题的统一处理器。 fs_write_file_rejects_invalid_base64 输入 %%% 验证消息前缀; fs_methods_reject_relative_paths 使用相对路径,验证失败来自 AbsolutePathBuf 的 typed 解码。 后者还没有进入 map_fs_error,排查层次不同。

4.2 配置写入 ​

源码文件:codex-rs/app-server-protocol/src/protocol/v2/config.rs

相关函数/类型:ConfigWriteErrorCode(L349–L360,摘录)

rust
// 作者注:该子码枚举以 camelCase 序列化,不是另一组 JSON-RPC 数字。
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ConfigWriteErrorCode {
    ConfigLayerReadonly,
    ConfigRequirementReadonly,
    ConfigVersionConflict,
    ConfigValidationError,
    ConfigPathNotFound,
    ConfigSchemaUnknownKey,
    UserLayerNotFound,
}

这些名字是可表达的配置子码,serde 将其序列化为 configVersionConflict 等 camelCase 字符串。 它们进入 data.config_write_error_code,不是顶层 code 的替代品。 协议声明一种子码,也不等于所有版本、所有配置路径都会实际构造它;仍需搜索产生点。

源码文件:codex-rs/app-server/src/config_manager_service.rs

相关函数/类型:ConfigManagerError(L45–L80,摘录)

rust
// 作者注:Write 携带可给客户端判断的语义子码;其他变体保留内部错误链。
#[derive(Debug, Error)]
pub(crate) enum ConfigManagerError {
    #[error("{message}")]
    Write {
        code: ConfigWriteErrorCode,
        message: String,
    },

    #[error("{context}: {source}")]
    Io {
        context: &'static str,
        #[source]
        source: std::io::Error,
    },

    #[error("{context}: {source}")]
    Json {
        context: &'static str,
        #[source]
        source: serde_json::Error,
    },

    #[error("{context}: {source}")]
    Toml {
        context: &'static str,
        #[source]
        source: toml::de::Error,
    },

    #[error("{context}: {source}")]
    Anyhow {
        context: &'static str,
        #[source]
        source: anyhow::Error,
    },
}

Write 携带业务子码,其余变体保存 I/O、JSON、TOML 或通用错误链。 write_error_code() 只在 Write 时返回 Some,是后面 mapper 判断能否提供结构化配置详情的依据。 不能把任何配置加载失败都假设为 configValidationError。

源码文件:codex-rs/app-server/src/config_manager_service.rs

相关函数/类型:ConfigManager::apply_edits(L247–L258,局部节选)

rust
// 作者注:expected_version 不匹配时在应用编辑之前返回,要求客户端重新读取并协调修改。
// ...
if let Some(expected) = expected_version.as_deref()
    && expected != user_layer.version
{
    return Err(ConfigManagerError::write(
        ConfigWriteErrorCode::ConfigVersionConflict,
        "Configuration was modified since last read. Fetch latest version and retry.",
    ));
}

let mut user_config = user_layer.config.clone();
let mut parsed_segments = Vec::new();
let mut config_edits = Vec::new();
// ...

版本冲突在编辑应用之前返回 Write(ConfigVersionConflict)。 客户端需要重新读取最新版本,结合自己的修改重新决定写入内容;用旧 expected_version 原样重发不会解除冲突。 这是从具体失败条件推导出的恢复动作,不是对所有 -32600 的统一“可重试”保证。

源码文件:codex-rs/app-server/src/request_processors/config_processor.rs

相关函数/类型:map_error / config_write_error(L761–L775,摘录)

rust
// 作者注:Write 子码装入 data,其余 ConfigManagerError 映射为 internal_error。
pub(super) fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
    if let Some(code) = err.write_error_code() {
        return config_write_error(code, err.to_string());
    }

    internal_error(err.to_string())
}

fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) -> JSONRPCErrorError {
    let mut error = invalid_request(message);
    error.data = Some(json!({
        "config_write_error_code": code,
    }));
    error
}

配置 Write 都以 -32600 返回,再以子码细分;没有 Write 子码时,mapper 返回 -32603。 例如消息文字都提到 configuration,实际分层也可能不同。 客户端应先检查这个 method 对 data 的约定,再读取 config_write_error_code。

源码文件:codex-rs/app-server/tests/suite/v2/config_rpc.rs

相关函数/类型:config_value_write_rejects_version_conflict(L1574–L1597,局部节选)

rust
// 作者注:夹具已有 gpt-old 配置,客户端使用 stale 版本写入,测试断言返回 configVersionConflict。
// ...
let write_id = mcp
    .send_config_value_write_request(ConfigValueWriteParams {
        file_path: Some(codex_home.path().join("config.toml").display().to_string()),
        key_path: "model".to_string(),
        value: json!("gpt-new"),
        merge_strategy: MergeStrategy::Replace,
        expected_version: Some("sha256:stale".to_string()),
    })
    .await?;

let err: JSONRPCError = timeout(
    DEFAULT_READ_TIMEOUT,
    mcp.read_stream_until_error_message(RequestId::Integer(write_id)),
)
.await??;
let code = err
    .error
    .data
    .as_ref()
    .and_then(|d| d.get("config_write_error_code"))
    .and_then(|v| v.as_str());
assert_eq!(code, Some("configVersionConflict"));

Ok(())
// ...

测试在已有配置上携带 sha256:stale 请求写新 model,最终只断言配置子码为 configVersionConflict。 它证明子码经过真实 RPC 路径送达;“未走到写入”还需要结合前面版本检查在编辑之前的源码顺序。 这样才能区分测试已直接断言的结果与从实现顺序得到的结论。

4.3 认证与配置 ​

源码文件:codex-rs/app-server/src/request_processors/config_errors.rs

相关函数/类型:cloud_config_bundle_load_error / config_load_error(L3–L35,摘录)

rust
// 作者注:沿 source 链找 CloudConfigBundleLoadError,只有 Auth 子码附加 relogin 动作。
fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBundleLoadError> {
    let mut current: Option<&(dyn std::error::Error + 'static)> = err
        .get_ref()
        .map(|source| source as &(dyn std::error::Error + 'static));
    while let Some(source) = current {
        if let Some(cloud_error) = source.downcast_ref::<CloudConfigBundleLoadError>() {
            return Some(cloud_error);
        }
        current = source.source();
    }
    None
}

pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
    let data = cloud_config_bundle_load_error(err).map(|cloud_error| {
        let mut data = serde_json::json!({
            "reason": "cloudConfigBundle",
            "errorCode": format!("{:?}", cloud_error.code()),
            "detail": cloud_error.to_string(),
        });
        if let Some(status_code) = cloud_error.status_code() {
            data["statusCode"] = serde_json::json!(status_code);
        }
        if cloud_error.code() == CloudConfigBundleLoadErrorCode::Auth {
            data["action"] = serde_json::json!("relogin");
        }
        data
    });

    let mut error = invalid_request(format!("failed to load configuration: {err}"));
    error.data = data;
    error
}

云配置错误可能被包装进 io::Error,这里沿着 source() 链向下查找具体 CloudConfigBundleLoadError。 找到后填入 reason/errorCode/detail,有上游状态时再放 statusCode;只有 Auth 才添加 action: relogin。 errorCode 来自 Debug 名称,例如 Auth,与上一节的 camelCase 配置写子码不是同一编码规则。

源码文件:codex-rs/app-server/src/request_processors/thread_processor_tests.rs

相关函数/类型:config_load_error_marks_cloud_config_bundle_failures_for_relogin(L509–L533,摘录)

rust
// 作者注:底层被包在 io::Error 中,但分类由具体源错误决定,保留 401 与 relogin。
fn config_load_error_marks_cloud_config_bundle_failures_for_relogin() {
    let err = std::io::Error::other(CloudConfigBundleLoadError::new(
        CloudConfigBundleLoadErrorCode::Auth,
        Some(401),
        "Your authentication session could not be refreshed automatically. Please log out and sign in again.",
    ));

    let error = config_load_error(&err);

    assert_eq!(
        error.data,
        Some(json!({
            "reason": "cloudConfigBundle",
            "errorCode": "Auth",
            "action": "relogin",
            "statusCode": 401,
            "detail": "Your authentication session could not be refreshed automatically. Please log out and sign in again.",
        }))
    );
    assert!(
        error.message.contains("failed to load configuration"),
        "unexpected error message: {}",
        error.message
    );
}

测试以 io::Error::other 包装认证失败,保留 HTTP 401 并产生 relogin。 同组测试还检查非云配置错误没有 data,RequestFailed 与 InvalidBundle 不携带 relogin。 因此不能只看外层 I/O kind 或“load configuration”字样就要求用户重新登录。

源码文件:codex-rs/app-server/src/request_processors/account_processor.rs

相关函数/类型:configured_auth_owned_by_host_error / login_chatgpt_device_code_start_error(L369–L379、L597–L604,摘录)

rust
// 作者注:账户 RPC 的所有权拒绝与 device-code 起始失败各有局部映射,不能统一等价为 HTTP 401。
fn external_auth_active_error(&self) -> JSONRPCErrorError {
    invalid_request(
        "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it.",
    )
}

fn configured_auth_owned_by_host_error(&self) -> JSONRPCErrorError {
    invalid_request(
        "Configured external authentication is owned by the app-server host and cannot be changed through account RPCs.",
    )
}

// ...

fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError {
    let is_not_found = err.kind() == std::io::ErrorKind::NotFound;
    if is_not_found {
        invalid_request(err.to_string())
    } else {
        internal_error(format!("failed to request device code: {err}"))
    }
}

账户 RPC 还有自己的拒绝:host 拥有的认证不能通过账户 RPC 更改;device-code 起始错误中 NotFound 被映射为 -32600,其余错误映射为 -32603。 相比上一节 FS 的 NotFound → -32603,这两处短函数直接证明了映射是业务上下文决定的。

源码文件:codex-rs/app-server-transport/src/transport/websocket.rs

相关函数/类型:websocket_upgrade_handler(L105–L127,摘录)

rust
// 作者注:HTTP Upgrade 认证失败直接返回 HTTP response,此时没有进入 JSON-RPC 会话。
async fn websocket_upgrade_handler(
    websocket: WebSocketUpgrade,
    ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
    State(state): State<WebSocketListenerState>,
    headers: HeaderMap,
) -> impl IntoResponse {
    if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) {
        warn!(
            %peer_addr,
            message = err.message(),
            "rejecting websocket client during upgrade"
        );
        return (err.status_code(), err.message()).into_response();
    }
    info!(%peer_addr, "websocket client connected");
    websocket
        .on_upgrade(move |stream| async move {
            let (websocket_writer, websocket_reader) = stream.split();
            run_websocket_connection(websocket_writer, websocket_reader, state.transport_event_tx)
                .await;
        })
        .into_response()
}

WebSocket Upgrade 认证失败则还停留在 HTTP 层,直接返回状态和消息,尚未建立 JSON-RPC 连接。 transport/auth.rs 的 unauthorized 使用 HTTP 401。把这种响应当作一个 JSON-RPC envelope 解析, 会得到第二个本地解码错误,掩盖原始认证失败。

状态出现的位置产生者应如何读取
Upgrade 的 HTTP 状态transport 认证先诊断连接建立
data.statusCode云配置 mapper结合 reason/errorCode/action
codexErrorInfo 变体中的 httpStatusCodeCore 到 V2 的语义映射结合具体变体,不能当作 RPC code
顶层 error.codeApp Server mapper判断当前请求分类,并继续读取可选 data

5. 运行错误 ​

5.1 Core 转译 ​

被接纳的 Turn 开始之后,新的失败通常由 Core 事件报告,不会重新改写已经返回的 turn/start 响应。 这条路径首先要经过 Core 的语义错误,而不是直接挑一个 JSON-RPC 数字。

源码文件:codex-rs/protocol/src/error.rs

相关函数/类型:CodexErr(L70–L73,摘录)

rust
// 作者注:details 保存语义及诊断负载,retry_delay 单独存储;当前 CodexErr 是结构体。
pub struct CodexErr {
    details: CodexErrorDetails,
    retry_delay: Option<Duration>,
}

CodexErr 当前是包装结构,真正变体位于 CodexErrorDetails,后端提供的重试等待独立保存在 retry_delay。 这里不展开所有诊断负载,先跟随负责对外分类的完整映射。

源码文件:codex-rs/protocol/src/error.rs

相关函数/类型:CodexErr::to_codex_protocol_error / to_error_event(L423–L468,摘录)

rust
// 作者注:多个内部原因可以折叠为同一个公开类型;默认 Other 分支必须一起阅读。
pub fn to_codex_protocol_error(&self) -> CodexErrorInfo {
    match &self.details {
        CodexErrorDetails::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
        CodexErrorDetails::SessionBudgetExceeded => CodexErrorInfo::SessionBudgetExceeded,
        CodexErrorDetails::UsageLimitReached(_)
        | CodexErrorDetails::QuotaExceeded
        | CodexErrorDetails::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
        CodexErrorDetails::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
        CodexErrorDetails::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy,
        CodexErrorDetails::MisalignmentPolicyViolation { .. } => {
            CodexErrorInfo::MisalignmentPolicyViolation
        }
        CodexErrorDetails::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts {
            http_status_code: self.http_status_code_value(),
        },
        CodexErrorDetails::ConnectionFailed(_) => CodexErrorInfo::HttpConnectionFailed {
            http_status_code: self.http_status_code_value(),
        },
        CodexErrorDetails::ResponseStreamFailed(_) => {
            CodexErrorInfo::ResponseStreamConnectionFailed {
                http_status_code: self.http_status_code_value(),
            }
        }
        CodexErrorDetails::RefreshTokenFailed(_) => CodexErrorInfo::Unauthorized,
        CodexErrorDetails::SessionConfiguredNotFirstEvent
        | CodexErrorDetails::InternalServerError
        | CodexErrorDetails::InternalAgentDied => CodexErrorInfo::InternalServerError,
        CodexErrorDetails::UnsupportedOperation(_)
        | CodexErrorDetails::ThreadNotFound(_)
        | CodexErrorDetails::AgentLimitReached { .. } => CodexErrorInfo::BadRequest,
        CodexErrorDetails::Sandbox(_) => CodexErrorInfo::SandboxError,
        _ => CodexErrorInfo::Other,
    }
}

pub fn to_error_event(&self, message_prefix: Option<String>) -> ErrorEvent {
    let error_message = self.to_string();
    let message: String = match message_prefix {
        Some(prefix) => format!("{prefix}: {error_message}"),
        None => error_message,
    };
    ErrorEvent {
        message,
        codex_error_info: Some(self.to_codex_protocol_error()),
    }
}

内部的配额、套餐不可用和使用量限制被合并为 UsageLimitExceeded;刷新 token 失败转为 Unauthorized。 连接、流连接和重试耗尽保留可取得的上游 HTTP 状态。未列举的错误落入 Other, 所以不能依照 Rust 变体的英文名字自动推导公开值,例如 InvalidRequest 在这个 match 中没有单独的 BadRequest 分支。

to_error_event 把人类可读消息和分类一起写入 ErrorEvent。 它做的是 Core 协议映射,既没有 request ID,也没有在此生成 JSONRPCErrorError。 前面环境选择、线程查找等请求 mapper 可以直接检查 err.details(),选择完全不同的 RPC 分类。

5.2 公开语义 ​

源码文件:codex-rs/app-server-protocol/src/protocol/v2/shared.rs

相关函数/类型:CodexErrorInfo(L70–L120,摘录)

rust
// 作者注:这个 V2 枚举负责公开 camelCase 语义,HTTP 状态只存在于带该字段的变体。
/// This translation layer make sure that we expose codex error code in camel case.
///
/// When an upstream HTTP status is available (for example, from the Responses API or a provider),
/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum CodexErrorInfo {
    ContextWindowExceeded,
    SessionBudgetExceeded,
    UsageLimitExceeded,
    ServerOverloaded,
    CyberPolicy,
    MisalignmentPolicyViolation,
    HttpConnectionFailed {
        #[serde(rename = "httpStatusCode")]
        #[ts(rename = "httpStatusCode")]
        http_status_code: Option<u16>,
    },
    /// Failed to connect to the response SSE stream.
    ResponseStreamConnectionFailed {
        #[serde(rename = "httpStatusCode")]
        #[ts(rename = "httpStatusCode")]
        http_status_code: Option<u16>,
    },
    InternalServerError,
    Unauthorized,
    BadRequest,
    ThreadRollbackFailed,
    SandboxError,
    /// The response SSE stream disconnected in the middle of a turn before completion.
    ResponseStreamDisconnected {
        #[serde(rename = "httpStatusCode")]
        #[ts(rename = "httpStatusCode")]
        http_status_code: Option<u16>,
    },
    /// Reached the retry limit for responses.
    ResponseTooManyFailedAttempts {
        #[serde(rename = "httpStatusCode")]
        #[ts(rename = "httpStatusCode")]
        http_status_code: Option<u16>,
    },
    /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn
    /// cannot accept same-turn steering, for example `/review` or manual `/compact`.
    ActiveTurnNotSteerable {
        #[serde(rename = "turnKind")]
        #[ts(rename = "turnKind")]
        turn_kind: NonSteerableTurnKind,
    },
    Other,
}

App Server V2 再将 Core 的 CodexErrorInfo 翻译为这份公开类型。 同文件的 From<CoreCodexErrorInfo> 保留对应变体和 HTTP 状态, 生成的 schema/typescript/v2/CodexErrorInfo.ts 反映相同的 wire 形状:无负载变体是字符串, 有负载变体是以 camelCase 变体名为键的对象。

例如 ServerOverloaded 是字符串 serverOverloaded, ResponseStreamDisconnected 则形如 {"responseStreamDisconnected":{"httpStatusCode":null}}。 后者不是 {kind: ..., httpStatusCode: ...} 的统一扁平对象。 httpStatusCode 只存在于相应变体,不能承诺所有上游 HTTP 失败都能从这个字段读回原状态。

语义组公开类型应结合的上下文
上下文/预算/额度contextWindowExceeded、sessionBudgetExceeded、usageLimitExceeded当前轮次预算、模型上下文或账户限制
服务与策略serverOverloaded、cyberPolicy、misalignmentPolicyViolation产生于哪次模型/策略处理,是否终止
连接与流httpConnectionFailed、responseStreamConnectionFailed、responseStreamDisconnected、responseTooManyFailedAttempts对应可选 HTTP 状态、附加诊断与重试阶段
通用执行internalServerError、unauthorized、badRequest、sandboxError、other具体错误生产点与消息
操作拒绝threadRollbackFailed、activeTurnNotSteerable是一个操作被拒绝,还是当前 Turn 失败

5.3 通知分支 ​

源码文件:codex-rs/app-server/src/bespoke_event_handling.rs

相关函数/类型:apply_bespoke_event_handling(L939–L980,局部节选)

rust
// 作者注:回滚失败先消费 pending 请求;不影响 Turn 状态的错误随后被过滤,剩余错误才进入终态记录。
// ...
EventMsg::Error(ev) => {
    thread_watch_manager
        .note_system_error(&conversation_id.to_string())
        .await;

    let message = ev.message.clone();
    let codex_error_info = ev.codex_error_info.clone();
    // If this error belongs to an in-flight `thread/rollback` request, fail that request
    // (and clear pending state) so subsequent rollbacks are unblocked.
    //
    // Don't send a notification for this error.
    if matches!(
        codex_error_info,
        Some(CoreCodexErrorInfo::ThreadRollbackFailed)
    ) {
        return handle_thread_rollback_failed(
            conversation_id,
            message,
            &thread_state,
            &outgoing,
        )
        .await;
    };

    if !ev.affects_turn_status() {
        return;
    }

    let turn_error = TurnError {
        message: ev.message,
        codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
        additional_details: None,
    };
    handle_error_notification(
        conversation_id,
        &event_turn_id,
        turn_error,
        &outgoing,
        &thread_state,
    )
    .await;
}
// ...

这里的判断顺序决定外部行为:先处理挂起的 rollback 失败,再检查错误是否影响 Turn, 最后才进入普通终止性错误通知。 因此捕获到 EventMsg::Error 不等于一定能收到通用 error 通知。 note_system_error 也有自己的 thread watch 消费者,不能将其等同于下面的 turn_summary.last_error。

源码文件:codex-rs/protocol/src/protocol.rs

相关函数/类型:CodexErrorInfo::affects_turn_status(L1815–L1837,摘录)

rust
// 作者注:回滚与不可 steer 拒绝不使当前 Turn 失败,其他列举的类型会影响终态。
impl CodexErrorInfo {
    /// Whether this error should mark the current turn as failed when replaying history.
    pub fn affects_turn_status(&self) -> bool {
        match self {
            Self::ThreadRollbackFailed | Self::ActiveTurnNotSteerable { .. } => false,
            Self::ContextWindowExceeded
            | Self::SessionBudgetExceeded
            | Self::UsageLimitExceeded
            | Self::ServerOverloaded
            | Self::CyberPolicy
            | Self::MisalignmentPolicyViolation
            | Self::HttpConnectionFailed { .. }
            | Self::ResponseStreamConnectionFailed { .. }
            | Self::InternalServerError
            | Self::Unauthorized
            | Self::BadRequest
            | Self::SandboxError
            | Self::ResponseStreamDisconnected { .. }
            | Self::ResponseTooManyFailedAttempts { .. }
            | Self::Other => true,
        }
    }
}

ThreadRollbackFailed 与 ActiveTurnNotSteerable 不影响当前 Turn 终态。 这允许一个运行中的 Review 拒绝用户 steer,而继续完成它本来的工作。 ErrorEvent 没有 codex_error_info 时,is_none_or 按会影响 Turn 处理;缺少分类也不是忽略错误的条件。

源码文件:codex-rs/app-server/src/bespoke_event_handling.rs

相关函数/类型:apply_bespoke_event_handling(L981–L997,局部节选)

rust
// 作者注:StreamError 发送 willRetry=true,不写 turn_summary.last_error。
// ...
EventMsg::StreamError(ev) => {
    // We don't need to update the turn summary store for stream errors as they are intermediate error states for retries,
    // but we notify the client.
    let turn_error = TurnError {
        message: ev.message,
        codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
        additional_details: ev.additional_details,
    };
    outgoing
        .send_server_notification(ServerNotification::Error(ErrorNotification {
            error: turn_error,
            will_retry: true,
            thread_id: conversation_id.to_string(),
            turn_id: event_turn_id.clone(),
        }))
        .await;
}
// ...

StreamError 则被转为 willRetry: true 的通知,保留 additional_details,并不更新 summary 的 last error。 如果客户端一看到任何 error method 就把 Turn 标红为最终失败,会错误地结束仍由 Core 重试的工作。 这个标志描述此次通知的产生路径,不是可无限次重复提交原始用户操作的授权或幂等保证。

6. 终态合成 ​

6.1 错误摘要 ​

源码文件:codex-rs/app-server/src/bespoke_event_handling.rs

相关函数/类型:handle_error / handle_error_notification(L1625–L1650,摘录)

rust
// 作者注:先在 Mutex 下保存错误,再发 willRetry=false;该通知还不是 turn/completed。
async fn handle_error(
    _conversation_id: ThreadId,
    error: TurnError,
    thread_state: &Arc<Mutex<ThreadState>>,
) {
    let mut state = thread_state.lock().await;
    state.turn_summary.last_error = Some(error);
}

async fn handle_error_notification(
    conversation_id: ThreadId,
    event_turn_id: &str,
    error: TurnError,
    outgoing: &ThreadScopedOutgoingMessageSender,
    thread_state: &Arc<Mutex<ThreadState>>,
) {
    handle_error(conversation_id, error.clone(), thread_state).await;
    outgoing
        .send_server_notification(ServerNotification::Error(ErrorNotification {
            error,
            will_retry: false,
            thread_id: conversation_id.to_string(),
            turn_id: event_turn_id.to_string(),
        }))
        .await;
}

普通错误先在 ThreadState 的互斥锁下保存到 turn_summary.last_error,再发送 willRetry: false。 此时客户端已知道当前工作有终止性错误,但 turn/completed 还未产生。 这个“摘要已存错误”和“已发最终完成”的时间差,是理解事件顺序的关键。

源码文件:codex-rs/app-server/src/bespoke_event_handling.rs

相关函数/类型:handle_turn_complete / find_and_remove_turn_summary(L1482–L1518,摘录)

rust
// 作者注:消费并清空本轮 summary,由 last_error 决定最终 Completed 或 Failed。
async fn find_and_remove_turn_summary(
    _conversation_id: ThreadId,
    thread_state: &Arc<Mutex<ThreadState>>,
) -> TurnSummary {
    let mut state = thread_state.lock().await;
    std::mem::take(&mut state.turn_summary)
}

async fn handle_turn_complete(
    conversation_id: ThreadId,
    event_turn_id: String,
    turn_complete_event: TurnCompleteEvent,
    outgoing: &ThreadScopedOutgoingMessageSender,
    thread_state: &Arc<Mutex<ThreadState>>,
) {
    let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await;

    let (status, error, last_agent_message) = match turn_summary.last_error {
        Some(error) => (TurnStatus::Failed, Some(error), None),
        None => (TurnStatus::Completed, None, turn_summary.last_agent_message),
    };

    emit_turn_completed_with_status(
        conversation_id,
        event_turn_id,
        TurnCompletionMetadata {
            status,
            error,
            last_agent_message,
            started_at: turn_summary.started_at,
            completed_at: turn_complete_event.completed_at,
            duration_ms: turn_complete_event.duration_ms,
        },
        outgoing,
    )
    .await;
}

完成处理用 std::mem::take 取走旧 summary 并留下默认值,防止下一轮沿用上一轮错误。 last_error 有值才选 Failed;没有则选 Completed 并保留最后的回答。 这个函数使用摘要中的错误来合成 V2 终态,不能只看到 Core 事件名 TurnComplete 就显示成功。

源码文件:codex-rs/app-server-protocol/src/protocol/thread_history.rs

相关函数/类型:ThreadHistoryBuilder::active_turn_snapshot / handle_error(L272–L277、L1185–L1204,摘录)

rust
// 作者注:历史投影可在 Error 到达时直接显示 Failed;它与生成最终通知的 TurnSummary 是不同状态消费者。
pub fn active_turn_snapshot(&self) -> Option<Turn> {
    self.current_turn
        .as_ref()
        .map(Turn::from)
        .or_else(|| self.turns.last().cloned())
}

// ...

fn handle_error(&mut self, payload: &ErrorEvent) {
    if !payload.affects_turn_status() {
        return;
    }
    let tracking_changes = self.is_tracking_changes();
    let changed_turn = if let Some(turn) = self.current_turn.as_mut() {
        turn.status = TurnStatus::Failed;
        turn.error = Some(V2TurnError {
            message: payload.message.clone(),
            codex_error_info: payload.codex_error_info.clone().map(Into::into),
            additional_details: None,
        });
        tracking_changes.then(|| ThreadHistoryTurnChange::from_pending_turn(turn))
    } else {
        None
    };
    if let Some(changed_turn) = changed_turn {
        self.record_changed_turn(changed_turn);
    }
}

这里必须再区分另一位消费者。ThreadState 还用 current_turn_history 构建读取视图, 其 ThreadHistoryBuilder::handle_error 在错误到达时即可把已有 Turn 投影标为 Failed, 不用等 turn/completed 通知。active_turn_snapshot 也可能返回最近一轮的快照,名称本身不保证任务仍在运行。 因此“读取视图已经 Failed”与“最终通知尚未到达”并不矛盾。

下图只表示 TurnSummary 的保存状态与最终通知组合,不定义读取视图中 status 的生效点。 “保存了错误”这个节点说明 summary 等待终止路径消费,并不声称查询结果仍为 InProgress。 图中只画类别变化与终止,保持同类摘要的输入由后面的表格说明。

输入路径是否写 last_error后续普通完成的含义
StreamError否仍可正常 Completed
影响 Turn 的 Error是转为 Failed
rollback 失败不走普通错误摘要回原挂起请求
不可 steer 的操作拒绝不写普通摘要当前任务仍可继续
中断单独合成 Interrupted不按普通完成的 last_error 决策

6.2 中断与回滚 ​

源码文件:codex-rs/app-server/src/bespoke_event_handling.rs

相关函数/类型:handle_turn_interrupted(L1520–L1543,摘录)

rust
// 作者注:中断单独映射为 Interrupted,并不沿普通错误 summary 生成 Failed。
async fn handle_turn_interrupted(
    conversation_id: ThreadId,
    event_turn_id: String,
    turn_aborted_event: TurnAbortedEvent,
    outgoing: &ThreadScopedOutgoingMessageSender,
    thread_state: &Arc<Mutex<ThreadState>>,
) {
    let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await;

    emit_turn_completed_with_status(
        conversation_id,
        event_turn_id,
        TurnCompletionMetadata {
            status: TurnStatus::Interrupted,
            error: None,
            last_agent_message: None,
            started_at: turn_summary.started_at,
            completed_at: turn_aborted_event.completed_at,
            duration_ms: turn_aborted_event.duration_ms,
        },
        outgoing,
    )
    .await;
}

中断取走 summary 后明确生成 Interrupted,error 与最后回答都为 None。 因此取消不是又一种固定错误数字,客户端应依据最终 Turn status 区分失败与中断。 这也说明不能仅从“是否有 last_error”在任意事件时刻独立推导最终结果,还需要知道走了哪条终止路径。

源码文件:codex-rs/app-server/src/bespoke_event_handling.rs

相关函数/类型:handle_thread_rollback_failed(L1545–L1558,摘录)

rust
// 作者注:take 释放 pending 回滚槽;有挂起请求时回其 ID,没有则不制造普通 Turn 错误通知。
async fn handle_thread_rollback_failed(
    _conversation_id: ThreadId,
    message: String,
    thread_state: &Arc<Mutex<ThreadState>>,
    outgoing: &ThreadScopedOutgoingMessageSender,
) {
    let pending_rollback = thread_state.lock().await.pending_rollbacks.take();

    if let Some(request_id) = pending_rollback {
        outgoing
            .send_error(request_id, invalid_request(message))
            .await;
    }
}

rollback 失败取走 pending_rollbacks,有等待请求时才回该连接请求的 -32600。 取走状态让后续 rollback 不再被旧挂起记录挡住;没有 pending 请求时不会在此制造通用通知。 同一 Core 错误既可能用于完成 RPC,也可能用于 Turn 通知,分流由 App Server 的实际事件消费者决定。

6.3 终态断言 ​

源码文件:codex-rs/app-server/src/bespoke_event_handling.rs

相关函数/类型:test_handle_turn_complete_emits_failed_with_error(L3753–L3762、L3774–L3802,局部节选)

rust
// 作者注:预先写 last_error,再调用完成处理,断言最终状态、错误负载与没有额外消息。
// ...
handle_error(
    conversation_id,
    TurnError {
        message: "bad".to_string(),
        codex_error_info: Some(V2CodexErrorInfo::Other),
        additional_details: None,
    },
    &thread_state,
)
.await;

// ...

handle_turn_complete(
    conversation_id,
    event_turn_id.clone(),
    turn_complete_event(&event_turn_id),
    &outgoing,
    &thread_state,
)
.await;

let msg = recv_broadcast_notification(&mut rx).await?;
match msg {
    ServerNotification::TurnCompleted(n) => {
        assert_eq!(n.turn.id, event_turn_id);
        assert_eq!(n.turn.status, TurnStatus::Failed);
        assert_eq!(
            n.turn.error,
            Some(TurnError {
                message: "bad".to_string(),
                codex_error_info: Some(V2CodexErrorInfo::Other),
                additional_details: None,
            })
        );
        assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT));
        assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS));
    }
    other => bail!("unexpected message: {other:?}"),
}
assert!(rx.try_recv().is_err(), "no extra messages expected");
Ok(())
// ...

测试先调用 handle_error 写入摘要,再调用 handle_turn_complete。 最终断言 TurnStatus::Failed、完整 TurnError、完成时间和耗时,并检查没有额外消息。 它验证摘要到终态的转换,未覆盖网络发送或模型错误如何生成。 test_handle_turn_complete_emits_error_multiple_turns 还检验连续轮次的错误状态不串轮。 历史构建器的 error_then_turn_complete_preserves_failed_status、 turn_complete_with_embedded_error_marks_turn_failed 则分别验证独立 Error 和完成事件内嵌错误的历史重建, 不能用一条通知路径的测试代替另一份投影的验证。

源码文件:codex-rs/app-server/tests/suite/v2/misalignment_policy.rs

相关函数/类型:assert_policy_violation_completes_turn_with_typed_terminal_error(L111–L147,局部节选)

rust
// 作者注:同一 helper 分别处理 SSE 失败和 HTTP 400/403,断言关联到原 Turn 的错误通知与 Failed 终态。
// ...
let error: ErrorNotification = timeout(
    std::time::Duration::from_secs(10),
    app_server.read_notification("error"),
)
.await??;
assert_eq!(
    error,
    ErrorNotification {
        error: TurnError {
            message: MESSAGE.to_string(),
            codex_error_info: Some(CodexErrorInfo::MisalignmentPolicyViolation),
            additional_details: None,
        },
        will_retry: false,
        thread_id: thread.id.clone(),
        turn_id: turn.id.clone(),
    }
);

let completed: TurnCompletedNotification = timeout(
    std::time::Duration::from_secs(10),
    app_server.read_notification("turn/completed"),
)
.await??;
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.id, turn.id);
assert_eq!(completed.turn.status, TurnStatus::Failed);
assert_eq!(
    completed.turn.error,
    Some(TurnError {
        message: MESSAGE.to_string(),
        codex_error_info: Some(CodexErrorInfo::MisalignmentPolicyViolation),
        additional_details: None,
    })
);
response_mock.single_request();
// ...

这组集成测试补上另一半:mock 分别返回 SSE response.failed、HTTP 400、HTTP 403 的策略错误, 真实 turn/start 请求先被接纳,然后读到同一 Thread/Turn 的 willRetry: false,最后是 Failed。 response_mock.single_request() 同时限定该 fixture 没有额外模型请求。 这是受控错误输入下的端到端断言,不能外推成所有 400/403 都具有同一种语义。

7. 重试与撤销 ​

7.1 重试责任 ​

“错误看起来暂时”不等于客户端应该自动重发原始调用。transport、Core 和客户端分别拥有不同的重试责任。

源码文件:codex-rs/protocol/src/error.rs

相关函数/类型:CodexErr::is_retryable(L364–L404,摘录)

rust
// 作者注:可重试性依据内部 details,ServerOverloaded 与 RetryLimit 在这层均为 false。
pub fn is_retryable(&self) -> bool {
    match self.details() {
        CodexErrorDetails::TurnAborted
        | CodexErrorDetails::SessionBudgetExceeded
        | CodexErrorDetails::Interrupted
        | CodexErrorDetails::EnvVar(_)
        | CodexErrorDetails::Fatal(_)
        | CodexErrorDetails::UsageNotIncluded
        | CodexErrorDetails::QuotaExceeded
        | CodexErrorDetails::InvalidImageRequest()
        | CodexErrorDetails::InvalidRequest(_)
        | CodexErrorDetails::ToolCollision(_)
        | CodexErrorDetails::RefreshTokenFailed(_)
        | CodexErrorDetails::UnsupportedOperation(_)
        | CodexErrorDetails::Sandbox(_)
        | CodexErrorDetails::LandlockSandboxExecutableNotProvided
        | CodexErrorDetails::RetryLimit(_)
        | CodexErrorDetails::ContextWindowExceeded
        | CodexErrorDetails::ThreadNotFound(_)
        | CodexErrorDetails::AgentLimitReached { .. }
        | CodexErrorDetails::Spawn
        | CodexErrorDetails::SessionConfiguredNotFirstEvent
        | CodexErrorDetails::UsageLimitReached(_)
        | CodexErrorDetails::ServerOverloaded
        | CodexErrorDetails::CyberPolicy { .. }
        | CodexErrorDetails::MisalignmentPolicyViolation { .. } => false,
        CodexErrorDetails::Stream(..)
        | CodexErrorDetails::Timeout
        | CodexErrorDetails::RequestTimeout
        | CodexErrorDetails::UnexpectedStatus(_)
        | CodexErrorDetails::ResponseStreamFailed(_)
        | CodexErrorDetails::ConnectionFailed(_)
        | CodexErrorDetails::InternalServerError
        | CodexErrorDetails::InternalAgentDied
        | CodexErrorDetails::Io(_)
        | CodexErrorDetails::Json(_)
        | CodexErrorDetails::TokioJoin(_) => true,
        #[cfg(target_os = "linux")]
        CodexErrorDetails::LandlockRuleset(_) | CodexErrorDetails::LandlockPathFd(_) => false,
    }
}

Core 基于 details 判断可重试性,其中 ServerOverloaded、RetryLimit、沙箱拒绝和额度限制均返回 false。 流、部分超时和连接错误等则可重试,但最终是否再发一次请求还要经过预算、provider 和 fallback 逻辑。 Linux 特有的 Landlock 变体也明确不可重试。

前面的 transport -32001 表示新 RPC 未被队列接纳,其消息建议稍后再试; 这里的 CodexErrorDetails::ServerOverloaded 则是 Core 语义错误。 共享“overloaded”这个单词并不使两者成为同一重试策略。

源码文件:codex-rs/core/src/responses_retry.rs

相关函数/类型:handle_retryable_response_stream_error(L102–L128,局部节选)

rust
// 作者注:预算内增加 retry,选择后端 delay 或 backoff;release 可隐藏首次 WebSocket 重连通知。
// ...
if retry_state.retries < max_retries {
    retry_state.retries += 1;
    let retry_count = retry_state.retries;
    let delay = err.retry_delay().unwrap_or_else(|| backoff(retry_count));
    log_retry(request, turn_context, &err, retry_count, max_retries, delay);

    // In release builds, hide the first websocket retry notification to reduce noisy
    // transient reconnect messages. In debug builds, keep full visibility for diagnosis.
    let report_error = retry_count > 1
        || cfg!(debug_assertions)
        || !sess.services.model_client.responses_websocket_enabled();
    if report_error {
        // Surface retry information to any UI/front-end so the user understands what is
        // happening instead of staring at a seemingly frozen screen.
        sess.notify_stream_error(
            turn_context,
            format!("Reconnecting... {retry_count}/{max_retries}"),
            err,
        )
        .await;
    }
    codex_client::record_retry!(retry_count, delay, operation);
    tokio::time::sleep(delay).await;
    return Ok(());
}

Err(err)
// ...

Core 在预算内选择后端提供的 delay 或本地 backoff,然后等待并继续采样。 release 下第一次 WebSocket 重连通知可被隐藏;debug 构建、第二次及以后、非 WebSocket 情况会报告。 所以没有看到 willRetry: true,也不能直接推断从未重试过。 同函数前面还处理受 feature/source/provider 约束的连接重试和传输 fallback, 这里的预算分支不是所有配置下唯一的重试入口。

源码文件:codex-rs/core/src/session/mod.rs

相关函数/类型:Session::notify_stream_error(L4184–L4200,摘录)

rust
// 作者注:重连通知的语义类型由这个生产点构造,诊断字符串留在 additional_details。
pub(crate) async fn notify_stream_error(
    &self,
    turn_context: &TurnContext,
    message: impl Into<String>,
    codex_error: CodexErr,
) {
    let additional_details = codex_error.to_string();
    let codex_error_info = CodexErrorInfo::ResponseStreamDisconnected {
        http_status_code: codex_error.http_status_code_value(),
    };
    let event = EventMsg::StreamError(StreamErrorEvent {
        message: message.into(),
        codex_error_info: Some(codex_error_info),
        additional_details: Some(additional_details),
    });
    self.send_event(turn_context, event).await;
}

重连通知通过 Session::notify_stream_error 构造 ResponseStreamDisconnected, 把原始错误的显示字符串放进 additional_details,再交给上一节的 StreamError 消费分支。 这条生产路径与 CodexErr::to_error_event 不同,所以读者要同时核对“原错误是什么”与“哪个事件被发出”。

源码文件:codex-rs/protocol/src/error_tests.rs

相关函数/类型:retryability_preserves_error_details_distinctions(L36–L73,摘录)

rust
// 作者注:相同 429 HTTP 状态可对应不同 details 和 retry 结果,数值状态不能替代语义判断。
fn retryability_preserves_error_details_distinctions() {
    let errors = [
        (CodexErr::ServerOverloaded, false),
        (
            CodexErr::RetryLimit(RetryLimitReachedError {
                status: StatusCode::TOO_MANY_REQUESTS,
                request_id: None,
            }),
            false,
        ),
        (
            CodexErr::UnexpectedStatus(UnexpectedResponseError {
                status: StatusCode::TOO_MANY_REQUESTS,
                body: String::new(),
                user_message: None,
                url: None,
                cf_ray: None,
                request_id: None,
                identity_authorization_error: None,
                identity_error_code: None,
            }),
            true,
        ),
        (
            CodexErrorDetails::ToolCollision("functions.update_plan".to_string()).into(),
            false,
        ),
        (CodexErr::InternalServerError, true),
    ];

    for (err, expected) in errors {
        assert_eq!(
            err.is_retryable(),
            expected,
            "unexpected retryability for {err:?}"
        );
    }
}

同为 HTTP 429,包成 RetryLimit 时不可重试,包成 UnexpectedStatus 时可重试。 测试还断言 ServerOverloaded 不可重试、InternalServerError 可重试。 它证明判定依赖内部语义类型,不能用 HTTP 状态或公开 RPC code 反推一条无条件重试规则。

7.2 反向请求撤销 ​

源码文件:codex-rs/app-server/src/outgoing_message.rs

相关函数/类型:ThreadScopedOutgoingMessageSender::abort_pending_server_requests(L181–L200,摘录)

rust
// 作者注:Turn 切换产生的错误用于本地 pending callback,data.reason 表达生命周期结束。
pub(crate) async fn send_global_server_notification(&self, notification: ServerNotification) {
    self.outgoing.send_server_notification(notification).await;
}

pub(crate) async fn abort_pending_server_requests(&self) {
    self.outgoing
        .cancel_requests_for_thread(
            self.thread_id,
            Some({
                let mut error = internal_error(
                    "client request resolved because the turn state was changed",
                );
                error.data = Some(serde_json::json!({
                    "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON,
                }));
                error
            }),
        )
        .await
}

这份错误用于取消当前 Thread 的 pending server-request callback。 它是 App Server 内部主动构造的结果,不代表客户端刚从网络返回了一个相同错误。 cancel_requests_for_thread 先从 callback map 移除记录,再向等待者发送 Err(error); 不是为原始用户请求又发一份 RPC 失败。

源码文件:codex-rs/app-server/src/server_request_error.rs

相关函数/类型:is_turn_transition_server_request_error(L3–L12,摘录)

rust
// 作者注:消费者检查 data.reason,没有要求特定数值 code。
pub(crate) const TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON: &str = "turnTransition";

pub(crate) fn is_turn_transition_server_request_error(error: &JSONRPCErrorError) -> bool {
    error
        .data
        .as_ref()
        .and_then(|data| data.get("reason"))
        .and_then(serde_json::Value::as_str)
        == Some(TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON)
}

消费者按 data.reason == turnTransition 识别生命周期切换,不要求特定数值 code。 例如 request-user-input 的消费者遇到该原因直接返回,MCP elicitation 则转换为 Cancel。 具体行为由等待者所处的协议决定,不能把这批内部撤销当作一律重试或一律报系统故障。

源码文件:codex-rs/app-server/src/bespoke_event_handling.rs

相关函数/类型:mcp_server_elicitation_turn_transition_error_maps_to_cancel(L2951–L2968,摘录)

rust
// 作者注:测试故意使用 code=-1,仍按 reason 映射为 Cancel,验证结构化原因才是判断依据。
fn mcp_server_elicitation_turn_transition_error_maps_to_cancel() {
    let error = JSONRPCErrorError {
        code: -1,
        message: "client request resolved because the turn state was changed".to_string(),
        data: Some(serde_json::json!({ "reason": "turnTransition" })),
    };

    let response = mcp_server_elicitation_response_from_client_result(Ok(Err(error)));

    assert_eq!(
        response,
        McpServerElicitationRequestResponse {
            action: McpServerElicitationAction::Cancel,
            content: None,
            meta: None,
        }
    );
}

测试故意使用 code: -1,仍得到 Cancel。这是对“判断依据究竟是什么”的反向验证。 相邻 request_permissions_turn_transition_error_is_ignored 则断言返回 None。 两种消费者都遵守同一个 reason,却有不同的后续动作。

8. 回传链路 ​

8.1 请求收口 ​

前面追踪了错误产生点,现在把它连回一个实际请求。 lib.rs 的 TransportEvent::IncomingMessage 分支找到连接,再把 Request 交给 process_request。 合法的 fs/readFile 经 typed/admission 检查进入下面的业务分派。

源码文件:codex-rs/app-server/src/message_processor.rs

相关函数/类型:handle_initialized_client_request(L1066–L1070,局部节选)

rust
// 作者注:已解码的 fs/readFile 被分派到文件 processor,错误沿 Result 回到统一收口。
// ...
ClientRequest::FsReadFile { params, .. } => self
    .fs_processor
    .read_file(params)
    .await
    .map(|response| Some(response.into())),
// ...

read_file 的 Err 不会经过 .map 变成成功响应,它继续沿 Result 返回到统一收口。 这样可以顺着源码走通:文本接入 → typed 请求 → FS 执行/mapper → 错误队列 → 客户端等待者。

源码文件:codex-rs/app-server/src/message_processor.rs

相关函数/类型:process_request / handle_initialized_client_request(L624–L644、L1656–L1667,局部节选)

rust
// 作者注:外层负责解码/接纳失败;业务 match 在发送 error 后返回 Ok,避免外层再发送同一失败。
// ...
let codex_request = deserialize_client_request(request);
let result = match codex_request {
    Ok(codex_request) => {
        // Websocket callers finalize outbound readiness in lib.rs after mirroring
        // session state into outbound state and sending initialize notifications to
        // this specific connection. Passing `None` avoids marking the connection
        // ready too early from inside the shared request handler.
        self.handle_client_request(
            request_id.clone(),
            codex_request,
            Arc::clone(&session),
            /*outbound_initialized*/ None,
            request_context.clone(),
        )
        .await
    }
    Err(error) => Err(error),
};
if let Err(error) = result {
    self.outgoing.send_error(request_id.clone(), error).await;
}

// ...

match result {
    Ok(Some(response)) => {
        self.outgoing
            .send_response_as(request_id.clone(), response)
            .await;
    }
    Ok(None) => {}
    Err(error) => {
        self.outgoing.send_error(request_id.clone(), error).await;
    }
}
Ok(())
// ...

两个位置负责不同时间的失败。process_request 处理解码与接纳失败; 已初始化请求的异步处理匹配 processor 的结果,发送 response 或 error。 这个收口发送完后返回 Ok(()),避免外层再次发送同一错误。 Ok(None) 表示该分支另行管理响应,不是客户端会收到 JSON null。

8.2 连接与 ID ​

源码文件:codex-rs/app-server/src/outgoing_message.rs

相关函数/类型:OutgoingMessageSender::send_error / send_error_inner(L656–L664、L682–L699,摘录)

rust
// 作者注:取走请求上下文后按 connection_id 定向发送;wire 只携带客户端 request_id。
pub(crate) async fn send_error(
    &self,
    request_id: ConnectionRequestId,
    error: impl Into<JSONRPCErrorError>,
) {
    let request_context = self.take_request_context(&request_id).await;
    self.send_error_inner(request_context, request_id, error.into())
        .await;
}

// ...

async fn send_error_inner(
    &self,
    request_context: Option<RequestContext>,
    request_id: ConnectionRequestId,
    error: JSONRPCErrorError,
) {
    let outgoing_message = OutgoingMessage::Error(OutgoingError {
        id: request_id.request_id,
        error,
    });
    self.send_outgoing_message_to_connection(
        request_context,
        request_id.connection_id,
        outgoing_message,
        "error",
    )
    .await;
}

App Server 内部使用 ConnectionRequestId { connection_id, request_id }, 因此两个客户端都使用 ID 7 时,错误仍能定向到各自连接。 wire 里只保留客户端的 request ID,连接范围由发送通道提供。 take_request_context 取走追踪上下文,却不是阻止任何调用者重复发包的全局 exactly-once 协议。

源码文件:codex-rs/app-server/src/outgoing_message.rs

相关函数/类型:send_outgoing_message_to_connection(L701–L722,摘录)

rust
// 作者注:send 只等待队列接纳;失败记日志,没有客户端收讫确认。
async fn send_outgoing_message_to_connection(
    &self,
    request_context: Option<RequestContext>,
    connection_id: ConnectionId,
    message: OutgoingMessage,
    message_kind: &'static str,
) {
    let send_fut = self.sender.send(OutgoingEnvelope::ToConnection {
        connection_id,
        message,
        write_complete_tx: None,
    });
    let send_result = if let Some(request_context) = request_context {
        send_fut.instrument(request_context.span()).await
    } else {
        send_fut.await
    };

    if let Err(err) = send_result {
        warn!("failed to send {message_kind} to client: {err:?}");
    }
}

send 等待的是 OutgoingEnvelope 队列接纳;这里没有携带 write-complete 确认。 队列关闭只记录 warning,不把新的发送错误再递归返回给同一个请求。 错误成功构造、成功入队、序列化成功、写入 socket、客户端收到,是不同阶段。 transport.rs 的 route_outgoing_envelope 再将全局出站队列里的 ToConnection 分发到对应连接, 连接 writer 接收的是 QueuedOutgoingMessage,不能把这两层队列当作同一个 channel。

下图沿一个已经失败的 FS 请求追到客户端。连接 ID 在服务端路由中使用,客户端用 wire request ID 找等待者。

  • queue 与 writer 均可能失败,图表示成功送达该错误的分支。
  • wire error 结束一个 RPC 等待者;运行中的通知另外送入事件通道。
  • 第一次请求失败不自动让整个客户端关闭;是否断连还要看实际 transport 结果。

8.3 序列化失败 ​

源码文件:codex-rs/app-server-transport/src/transport/mod.rs

相关函数/类型:serialize_outgoing_message / response_serialization_error(L260–L287,摘录)

rust
// 作者注:只有响应的序列化失败可回同 ID 的 internal_error,其他消息失败返回 None。
fn serialize_outgoing_message(outgoing_message: OutgoingMessage) -> Option<String> {
    match serde_json::to_string(&outgoing_message) {
        Ok(json) => Some(json),
        Err(err) => {
            error!("Failed to serialize JSONRPCMessage: {err}");
            let OutgoingMessage::Response(response) = outgoing_message else {
                return None;
            };
            serde_json::to_string(&response_serialization_error(response.id, err))
                .inspect_err(|err| error!("Failed to serialize JSONRPC error: {err}"))
                .ok()
        }
    }
}

fn response_serialization_error(
    request_id: RequestId,
    err: impl std::fmt::Display,
) -> OutgoingMessage {
    OutgoingMessage::Error(OutgoingError {
        id: request_id,
        error: JSONRPCErrorError {
            code: INTERNAL_ERROR_CODE,
            message: format!("failed to serialize response: {err}"),
            data: None,
        },
    })
}

handler 甚至可能已经生成成功响应,才在 JSON 序列化时失败。 这时 writer 对 Response 变体尝试生成同 ID 的 -32603;其他变体失败返回 None, 而降级错误自身再失败也无法送出。 因此 RPC internal error 不能证明此前完全没有副作用,尤其不能据此无条件重发写操作。

源码文件:codex-rs/app-server-transport/src/transport/mod.rs

相关函数/类型:serialize_invalid_typed_response_returns_jsonrpc_error(L357–L392,摘录)

rust
// 作者注:Unix 非 UTF-8 路径使 typed response 无法序列化,测试检查降级错误及同一 ID。
#[cfg(unix)]
#[test]
fn serialize_invalid_typed_response_returns_jsonrpc_error() {
    use std::ffi::OsString;
    use std::os::unix::ffi::OsStringExt;
    use std::path::PathBuf;

    let codex_home =
        AbsolutePathBuf::from_absolute_path(PathBuf::from(OsString::from_vec(vec![
            b'/', b'b', b'a', b'd', 0xff,
        ])))
        .expect("non-UTF-8 Unix paths are valid absolute paths");
    let message = OutgoingMessage::Response(OutgoingResponse {
        id: RequestId::Integer(7),
        result: Box::new(ClientResponsePayload::Initialize(
            codex_app_server_protocol::InitializeResponse {
                user_agent: "codex-test-agent".to_string(),
                codex_home,
                platform_family: "unix".to_string(),
                platform_os: "linux".to_string(),
            },
        )),
    });

    let json = serialize_outgoing_message(message)
        .expect("invalid response should serialize as a JSON-RPC error");
    assert_eq!(
        serde_json::from_str::<serde_json::Value>(&json).expect("message should be valid JSON"),
        json!({
            "id": 7,
            "error": {
                "code": -32603,
                "message": "failed to serialize response: path contains invalid UTF-8 characters",
            }
        })
    );

Unix 上一个包含 0xff 的合法路径无法编码为该 JSON 响应中的字符串,测试利用这个真实边界触发降级。 最终仍是 ID 7,但 result 被替换为 -32603 和序列化失败消息。 该测试有 cfg(unix);不能用它声称 Windows 有相同的路径输入和失败路径。

源码文件:codex-rs/app-server/src/transport.rs

相关函数/类型:send_message_to_connection(L151–L174,局部节选)

rust
// 作者注:可断开连接的 writer 满时会断连;stdio 分支使用 await 施加背压。
// ...
let writer = connection_state.writer.clone();
    let queued_message = QueuedOutgoingMessage {
        message,
        write_complete_tx,
    };
    if connection_state.can_disconnect() {
        match writer.try_send(queued_message) {
            Ok(()) => false,
            Err(mpsc::error::TrySendError::Full(_)) => {
                warn!(
                    "disconnecting slow connection after outbound queue filled: {connection_id:?}"
                );
                disconnect_connection(connections, connection_id)
            }
            Err(mpsc::error::TrySendError::Closed(_)) => {
                disconnect_connection(connections, connection_id)
            }
        }
    } else if writer.send(queued_message).await.is_err() {
        disconnect_connection(connections, connection_id)
    } else {
        false
    }
}
// ...

另一处失败发生在连接 writer 队列已满:可断开的连接被关闭;没有这种断连句柄的 stdio 路径用等待施加背压。 这与前面入站过载错误自身被丢弃是两个不同位置。 客户端只看到断连时,可能无法由该现象判断业务是否执行,恢复动作必须结合方法和已知状态。

9. 客户端分层 ​

9.1 等待者结果 ​

源码文件:codex-rs/app-server-client/src/lib.rs

相关函数/类型:TypedRequestError(L116–L135,摘录)

rust
// 作者注:客户端保留传输、服务器 RPC 错误和成功负载解码三个层次。
/// Layered error for [`InProcessAppServerClient::request_typed`].
///
/// This keeps transport failures, server-side JSON-RPC failures, and response
/// decode failures distinct so callers can decide whether to retry, surface a
/// server error, or treat the response as an internal request/response mismatch.
#[derive(Debug)]
pub enum TypedRequestError {
    Transport {
        method: String,
        source: IoError,
    },
    Server {
        method: String,
        source: JSONRPCErrorError,
    },
    Deserialize {
        method: String,
        source: serde_json::Error,
    },
}

三个变体保留了恢复决策所需的层次:Transport 表示调用通道失败,Server 表示收到 RPC error, Deserialize 表示服务器返回了成功结果,但不符合调用方期待的 Rust 类型。 最后一种也可能是调用方传错泛型 T,并不自动证明服务器业务执行失败。

源码文件:codex-rs/app-server-client/src/remote.rs

相关函数/类型:RemoteAppServerClient::connect_with_stream(L319–L332、L466–L474,局部节选)

rust
// 作者注:收到 RPC error 时结束一个 pending 请求;worker 退出则以 I/O 错误结束所有剩余等待者。
// ...
message = stream.next() => {
    match message {
        Some(Ok(Message::Text(text))) => {
            match serde_json::from_str::<JSONRPCMessage>(&text) {
                Ok(JSONRPCMessage::Response(response)) => {
                    if let Some(response_tx) = pending_requests.remove(&response.id) {
                        let _ = response_tx.send(Ok(Ok(response.result)));
                    }
                }
                Ok(JSONRPCMessage::Error(error)) => {
                    if let Some(response_tx) = pending_requests.remove(&error.id) {
                        let _ = response_tx.send(Ok(Err(error.error)));
                    }
                }

// ...

let (err_kind, err_message) = worker_exit_error.unwrap_or_else(|| {
    (
        ErrorKind::BrokenPipe,
        "remote app-server worker channel is closed".to_string(),
    )
});
for (_, response_tx) in pending_requests {
    let _ = response_tx.send(Err(IoError::new(err_kind, err_message.clone())));
}
// ...

远端 client 收到 error 时先按 ID 从 pending_requests 移除一个等待者,发送 Ok(Err(error)): 外层传输成功,内层调用失败。worker 退出则遍历剩余 pending,以外层 I/O error 结束等待。 没有对应等待者的迟到或未知 ID 响应不会凭空创建一个成功/失败调用。

源码文件:codex-rs/app-server-client/src/remote.rs

相关函数/类型:RemoteAppServerClient::connect_with_stream(L394–L407,局部节选)

rust
// 作者注:客户端收到无法解码的服务端消息会退出 worker,与服务端入站的日志后继续不同。
// ...
Err(err) => {
    let message = format!(
        "remote app server at `{endpoint}` sent invalid JSON-RPC: {err}"
    );
    let _ = deliver_event(
        &event_tx,
        AppServerEvent::Disconnected {
            message: message.clone(),
        },
    );
    worker_exit_error =
        Some((ErrorKind::InvalidData, message));
    break;
}
// ...

客户端收到无法解码的服务端消息时,发出 Disconnected、设置 InvalidData 并退出 worker。 这一处理与服务端对入站坏 JSON 的“记录后继续”不同。 不要因为两侧都调用 serde_json::from_str,就假定恢复行为也对称。

9.2 typed 解码 ​

源码文件:codex-rs/app-server-client/src/remote.rs

相关函数/类型:RemoteAppServerClient::request_typed(L497–L517,摘录)

rust
// 作者注:先拆外层传输 Result,再拆服务器 Result,只有成功结果才按 T 反序列化。
pub async fn request_typed<T>(&self, request: ClientRequest) -> Result<T, TypedRequestError>
where
    T: DeserializeOwned,
{
    let method = request.method_name();
    let response =
        self.request(request)
            .await
            .map_err(|source| TypedRequestError::Transport {
                method: method.to_string(),
                source,
            })?;
    let result = response.map_err(|source| TypedRequestError::Server {
        method: method.to_string(),
        source,
    })?;
    serde_json::from_value(result).map_err(|source| TypedRequestError::Deserialize {
        method: method.to_string(),
        source,
    })
}

按顺序拆两层 Result 之后,才进行 serde_json::from_value::<T>。 前两层失败会短路,保留对应变体和 method;它们不会继续触发成功结果的反序列化。 这里没有通用自动重试循环,客户端上层要结合方法、副作用和结构化详情作决定。

下图以这三个短路点组织处理,节点对应 request_typed 的实际先后顺序。

  • Transport 不提供一个可靠的“业务从未执行”结论。
  • Server 的 data 需要按具体 method/原因读取,不能统一解成 TurnError。
  • Deserialize 已越过成功/失败 envelope 判断,排查重点是版本与类型契约。

源码文件:codex-rs/app-server-client/src/lib.rs

相关函数/类型:typed_request_reports_json_rpc_errors(L1093–L1110,摘录)

rust
// 作者注:即使泛型写了另一个响应类型,服务器拒绝请求时也先返回 Server 层错误。
async fn typed_request_reports_json_rpc_errors() {
    let client = start_test_client(SessionSource::Exec).await;
    let err = client
        .request_typed::<ConfigRequirementsReadResponse>(ClientRequest::ThreadRead {
            request_id: RequestId::Integer(99),
            params: codex_app_server_protocol::ThreadReadParams {
                thread_id: "missing-thread".to_string(),
                include_turns: false,
            },
        })
        .await
        .expect_err("missing thread should return a JSON-RPC error");
    assert!(
        err.to_string().starts_with("thread/read failed:"),
        "expected method-qualified JSON-RPC failure message"
    );
    client.shutdown().await.expect("shutdown should complete");
}

测试刻意用 ConfigRequirementsReadResponse 作为泛型,却发送 ThreadRead 的坏 thread ID。 由于服务器先拒绝请求,结果仍是 method-qualified 的 RPC failure,未进入错误泛型的解码。 这验证了短路层次,不只是错误字符串格式。

源码文件:codex-rs/app-server-client/src/lib.rs

相关函数/类型:typed_request_error_exposes_sources(L1891–L1918,摘录)

rust
// 作者注:Server 变体不提供 std::error::Error source;需要匹配变体读取 code/data。
fn typed_request_error_exposes_sources() {
    let transport = TypedRequestError::Transport {
        method: "config/read".to_string(),
        source: IoError::new(ErrorKind::BrokenPipe, "closed"),
    };
    assert_eq!(std::error::Error::source(&transport).is_some(), true);

    let server = TypedRequestError::Server {
        method: "thread/read".to_string(),
        source: JSONRPCErrorError {
            code: -32603,
            data: Some(serde_json::json!({"detail": "config lock mismatch"})),
            message: "internal".to_string(),
        },
    };
    assert_eq!(std::error::Error::source(&server).is_some(), false);
    assert_eq!(
        server.to_string(),
        "thread/read failed: internal (code -32603), data: {\"detail\":\"config lock mismatch\"}"
    );

    let deserialize = TypedRequestError::Deserialize {
        method: "thread/start".to_string(),
        source: serde_json::from_str::<u32>("\"nope\"")
            .expect_err("invalid integer should return deserialize error"),
    };
    assert_eq!(std::error::Error::source(&deserialize).is_some(), true);
}

Server 变体没有实现标准 Error::source 链,尽管它的字段也叫 source。 调用方应匹配 TypedRequestError::Server { source, .. } 来取得 code/data, 不能仅遍历标准错误链并以“没有 source”推断没有结构化诊断。 Display 会包含 data;是否输出到用户界面或日志还应依据当前内容,不能将字符串解析当作稳定字段接口。

10. 映射变更 ​

假设要为一种新的可恢复业务冲突增加结构化详情,应先找产生点,判断它属于请求拒绝还是已接纳 Turn 的执行失败。 随后沿本文两条链分别核对需要改变的层,而不是只给 error_code.rs 加一个常量。

改动位置需要一起考虑的消费者可沿用的测试方式
processor 的 RPC mappermethod 对应的 data 解析、typed client Server 分支构造冲突输入,检查 ID/code/data 与副作用边界
Core 语义错误to_codex_protocol_error、V2 转译与 schema断言具体语义与可选 HTTP 状态
Error/StreamError 选择last_error、willRetry、最终 Turn status临时错误后能继续;终止错误后得到 Failed
反向请求撤销原因各 callback 的 Cancel/忽略/拒绝处理改变数值 code,保持 reason,检查消费者是否仍按约定处理
响应字段或序列化transport 降级、客户端 Deserialize让业务成功但响应不可编码,检查不会误记为未执行

在 Codex 仓库根目录,可以先运行覆盖传输失败与几项业务拒绝的上游测试:

bash
just test --locked -p codex-app-server-transport --lib \
  -E 'test(transport::tests::)'
just test --locked -p codex-app-server --test all \
  -E 'test(turn_start_rejects_combined_oversized_text_input) | test(config_value_write_rejects_version_conflict) | test(fs_methods_reject_relative_paths) | test(misalignment_policy)'
just test --locked -p codex-protocol --lib \
  -E 'test(retryability_preserves_error_details_distinctions) | test(rollback_failed_error_does_not_affect_turn_status) | test(active_turn_not_steerable_error_does_not_affect_turn_status)'

第一组观察满队列和序列化失败,第二组通过真实 server 请求观察拒绝与 Turn 终态,第三组核对 Core 的语义条件。 socket 测试需要可用的本地网络环境,Unix 路径用例受平台条件限制;这些结果不覆盖线上 provider 或所有传输变体。

回到开头的三个现象,可以分别还原对应路径:成功的 turn/start 只确认输入接纳; 运行时的 error 要继续看 willRetry 和 turn/completed;直接 -32600 要找到当前 method 的 mapper; 没有错误回复则还要检查入站/出站队列与连接,不能跳过传输证据。

继续核对 envelope 与另一套执行协议,可阅读 JSON-RPC封装与错误模型; 需要深入 pending callback 的所有权与清理,则回到 服务端请求与客户端响应。 每次改动错误映射后,都应能解释同一个错误的产生点、关联 ID、状态副作用和最后消费者, 这比记住一张数字表更有助于调试真实调用。