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,摘录)
// 作者注: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,摘录)
// 作者注:运行中通知按 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,摘录)
// 作者注:语义错误不携带 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 的 params | Thread/Turn ID | 正在执行的工作遇到暂时或终止性错误 |
turn/completed 中的 turn.error | Turn 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,摘录)
// 作者注:数值 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 | 辅助名称 | 当前实现中的代表来源 |
|---|---|---|
-32600 | invalid request | typed 解码、未初始化、实验能力未启用、许多业务拒绝 |
-32601 | method not found | 已知 Thread Section 方法的 store 能力不可用 |
-32602 | invalid params | 被移除的权限字段、输入超限、部分业务参数校验 |
-32603 | internal error | 未细分的底层失败、部分 Core 提交失败、响应序列化失败 |
-32001 | overloaded | 接收队列满时对新 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,摘录)
// 作者注:无法解码时只记日志并允许读循环继续,没有构造 -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,摘录)
// 作者注:旧字段检查先执行,随后 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,摘录)
// 作者注: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,局部节选)
// 作者注:握手状态与实验能力都属于请求接纳条件,失败不进入业务 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,摘录)
// 作者注:请求满队列时尝试回过载;非 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,局部节选)
// 作者注:夹具先用容量 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,摘录)
// 作者注:已知方法也能因 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,局部节选)
// 作者注:四个方法都拒绝旧字段;最后检查线程列表仍为空,验证拒绝发生在创建之前。
// ...
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,摘录)
// 作者注:跨全部输入项累加,再以严格大于判断超限,结构化详情保存上限与实际计数。
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,摘录)
// 作者注:只统计 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,局部节选)
// 作者注:两段各自未超限但合计超过上限,测试同时检查 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,局部节选)
// 作者注: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,局部节选)
// 作者注: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 不可 steer | TurnError,含 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,摘录)
// 作者注:线程 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,摘录)
// 作者注: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(¶ms.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,摘录)
// 作者注: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(¶ms.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,摘录)
// 作者注:该子码枚举以 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,摘录)
// 作者注: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,局部节选)
// 作者注: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,摘录)
// 作者注: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,局部节选)
// 作者注:夹具已有 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,摘录)
// 作者注:沿 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,摘录)
// 作者注:底层被包在 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,摘录)
// 作者注:账户 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,摘录)
// 作者注: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 变体中的 httpStatusCode | Core 到 V2 的语义映射 | 结合具体变体,不能当作 RPC code |
顶层 error.code | App Server mapper | 判断当前请求分类,并继续读取可选 data |
5. 运行错误
5.1 Core 转译
被接纳的 Turn 开始之后,新的失败通常由 Core 事件报告,不会重新改写已经返回的 turn/start 响应。 这条路径首先要经过 Core 的语义错误,而不是直接挑一个 JSON-RPC 数字。
源码文件:codex-rs/protocol/src/error.rs
相关函数/类型:CodexErr(L70–L73,摘录)
// 作者注: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,摘录)
// 作者注:多个内部原因可以折叠为同一个公开类型;默认 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,摘录)
// 作者注:这个 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,局部节选)
// 作者注:回滚失败先消费 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,摘录)
// 作者注:回滚与不可 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,局部节选)
// 作者注: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,摘录)
// 作者注:先在 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,摘录)
// 作者注:消费并清空本轮 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,摘录)
// 作者注:历史投影可在 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,摘录)
// 作者注:中断单独映射为 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,摘录)
// 作者注: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,局部节选)
// 作者注:预先写 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,局部节选)
// 作者注:同一 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,摘录)
// 作者注:可重试性依据内部 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,局部节选)
// 作者注:预算内增加 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,摘录)
// 作者注:重连通知的语义类型由这个生产点构造,诊断字符串留在 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,摘录)
// 作者注:相同 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,摘录)
// 作者注: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,摘录)
// 作者注:消费者检查 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,摘录)
// 作者注:测试故意使用 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,局部节选)
// 作者注:已解码的 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,局部节选)
// 作者注:外层负责解码/接纳失败;业务 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,摘录)
// 作者注:取走请求上下文后按 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,摘录)
// 作者注: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,摘录)
// 作者注:只有响应的序列化失败可回同 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,摘录)
// 作者注: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,局部节选)
// 作者注:可断开连接的 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,摘录)
// 作者注:客户端保留传输、服务器 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,局部节选)
// 作者注:收到 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,局部节选)
// 作者注:客户端收到无法解码的服务端消息会退出 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,摘录)
// 作者注:先拆外层传输 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,摘录)
// 作者注:即使泛型写了另一个响应类型,服务器拒绝请求时也先返回 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,摘录)
// 作者注: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 mapper | method 对应的 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 仓库根目录,可以先运行覆盖传输失败与几项业务拒绝的上游测试:
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、状态副作用和最后消费者, 这比记住一张数字表更有助于调试真实调用。
