模型错误分类
本文承接 Responses流解析、流式推理与消息Delta 和 模型请求构造。正常 delta 之外,模型请求还会遇到 HTTP 错误、SSE 断流、response.failed、上下文超限、限额和用户主动中断。要理解 Codex 的行为,不能只看错误文案,而要沿着类型转换和调用者决策向下追踪。
本文使用 rust-v0.150.0 源码回答四个问题:错误在哪一层被分类?哪些信息会被保留?何时真的会重试?为什么普通错误和 TurnAborted 最终产生不同的事件?
1. 错误经过三层
错误路径不是一张静态枚举表,而是三个边界连续转换:客户端先产生 TransportError,codex-api 将 HTTP 或 SSE 语义归一化为 ApiError,map_api_error 再把它投影成 Core 使用的 CodexErr。只有最后一层的 CodexErr 才能被采样循环的 is_retryable() 和协议事件消费。
这一区分解释了一个常见误读:HTTP 503 并不自动等于 ServerOverloaded,而是要继续检查响应 body;同样,ApiError::Retryable 也不等于已经重试,只表示 API 层保留了可等待的错误信息。
2. API先保留语义
codex-rs/codex-api/src/error.rs 的 ApiError 将 wire 层事实压缩成少量稳定变体。它同时保留传输错误、业务失败和 retry delay,不在这一层决定整个 Turn 是否再次执行。
源码位置:codex-rs/codex-api/src/error.rs :: ApiError
#[derive(Debug, Error)]
pub enum ApiError {
#[error(transparent)]
Transport(#[from] TransportError),
#[error("api error {status}: {message}")]
Api { status: StatusCode, message: String },
#[error("stream error: {0}")]
Stream(String),
#[error("context window exceeded")]
ContextWindowExceeded,
#[error("quota exceeded")]
QuotaExceeded,
#[error("usage not included")]
UsageNotIncluded,
#[error("retryable error: {message}")]
Retryable { message: String, delay: Option<Duration> },
#[error("rate limit: {0}")]
RateLimit(String),
#[error("invalid request: {message}")]
InvalidRequest { message: String },
#[error("cyber policy: {message}")]
CyberPolicy { message: String },
#[error("misalignment policy violation: {message}")]
MisalignmentPolicyViolation { message: String },
#[error("server overloaded")]
ServerOverloaded,
}Retryable { delay } 与 Stream(String) 的差别在于前者携带服务端等待建议,后者只说明流读取失败。两者 bridge 后都会成为 CodexErr,但只有前者能够让 Core 使用服务端 delay 覆盖默认 backoff。
3. HTTP桥接三输入
map_api_error 位于 codex-rs/codex-api/src/api_bridge.rs。它先处理已经具备业务语义的 ApiError,再展开 TransportError::Http 的 status、body 和 headers。分类依据不是 status 单字段,而是三者组合。
源码位置:codex-rs/codex-api/src/api_bridge.rs :: map_api_error(以下为 ApiError 分支摘录)
pub fn map_api_error(err: ApiError) -> CodexErr {
match err {
ApiError::ContextWindowExceeded => CodexErr::ContextWindowExceeded,
ApiError::QuotaExceeded => CodexErr::QuotaExceeded,
ApiError::UsageNotIncluded => CodexErr::UsageNotIncluded,
ApiError::Retryable { message, delay } => {
let error = CodexErr::Stream(message);
match delay {
Some(delay) => error.with_retry_delay(delay),
None => error,
}
}
ApiError::Stream(msg) => CodexErr::Stream(msg),
ApiError::ServerOverloaded => CodexErr::ServerOverloaded,
ApiError::Api { status, message } => {
let user_message = api_error_user_message(status, &message);
CodexErr::UnexpectedStatus(UnexpectedResponseError {
status, body: message, user_message, url: None,
cf_ray: None, request_id: None,
identity_authorization_error: None, identity_error_code: None,
})
}
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
ApiError::CyberPolicy { message } => {
CodexErr::new(CodexErrorDetails::CyberPolicy { message })
}
ApiError::MisalignmentPolicyViolation { message } => {
CodexErr::new(CodexErrorDetails::MisalignmentPolicyViolation { message })
}
// 后续为 ApiError::Transport(...) 分支,按 HTTP status、body 和 headers 分类。
}
}实际 HTTP 分支有三个关键点:503 只有 body code 为 server_is_overloaded 或 slow_down 才返回 ServerOverloaded;400/403 body code 为 misalignment_policy_violation 才返回对应政策错误;429 会解析 usage_limit_reached、usage_not_included,否则构造带 request id 的 RetryLimit。未命中的状态会保留 body、URL、cf-ray、request id 和身份错误 header,构造 UnexpectedStatus。
4. retryable的含义
CodexErr::is_retryable 是纯分类器;它不睡眠、不增加计数,也不保证下一次请求成功。用户输入、限额、政策和任务控制类错误在 false 分支,暂时性的流和传输错误在 true 分支。
源码位置:codex-rs/protocol/src/error.rs :: CodexErr::is_retryable
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::RetryLimit(_)
| CodexErrorDetails::ContextWindowExceeded
| 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,
// Linux Landlock 错误在源码中单独归入 false。
}
}所以 UnexpectedStatus(429) 和 RetryLimit(429) 不等价:前者可再次尝试,后者表示底层重试预算已耗尽。ServerOverloaded 也明确不可重试,避免采样循环追加请求。
5. 采样循环执行重试
run_sampling_request 先处理需要更新 Session 状态的错误,再询问 is_retryable()。上下文超限写入 token-full 状态,usage limit 写回 rate-limit snapshot;两个分支都直接返回。
源码位置:codex-rs/core/src/session/turn.rs :: run_sampling_request
let err = match try_run_sampling_request(/* ... */).await {
Ok(output) => return Ok((output, original_input.unwrap_or(prompt.input))),
Err(err) => match err.details() {
CodexErrorDetails::ContextWindowExceeded => {
sess.set_total_tokens_full(&turn_context).await;
return Err(err);
}
CodexErrorDetails::UsageLimitReached(e) => {
let rate_limits = e.rate_limits.clone();
if let Some(rate_limits) = rate_limits {
sess.update_rate_limits(&turn_context, *rate_limits).await;
}
return Err(err);
}
_ => err,
},
};
if !err.is_retryable() {
return Err(err);
}
handle_retryable_response_stream_error(
&mut retry_state, max_retries, err, client_session,
&sess, &turn_context, ResponsesStreamRequest::Sampling,
).await?;
turn_context.turn_timing_state.record_sampling_retry();下一轮会从当前 Session history 重新生成 prompt;original_input 只用于最终返回。上下文超限可能在 compaction 流程中恢复,但 sampling loop 不会盲目重发原始输入。
6. 重试状态机有上限
handle_retryable_response_stream_error 区分连接失败、transport fallback 和普通 retry。开启 UnboundedConnectionRetries 且满足 provider、session source 等条件时,连接失败使用指数退避;普通错误仍受 max_retries 限制。
源码位置:codex-rs/core/src/responses_retry.rs :: handle_retryable_response_stream_error
if retry_state.retries >= max_retries
&& client_session.try_switch_fallback_transport(
&turn_context.session_telemetry, &turn_context.model_info,
)
{
sess.send_event(turn_context, EventMsg::Warning(WarningEvent {
message: format!("Falling back from WebSockets to HTTPS transport. {err:#}"),
})).await;
retry_state.retries = 0;
return Ok(());
}
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);
sess.notify_stream_error(
turn_context, format!("Reconnecting... {retry_count}/{max_retries}"), err,
).await;
tokio::time::sleep(delay).await;
return Ok(());
}
Err(err)分类器决定“可尝试”,状态机决定“第几次、等多久”,transport fallback 决定“是否换通道”。三者不能混为一个重试开关。
7. 错误与中断分流
Session::on_task_finished 把 TurnAborted 单独映射为 TurnAbortReason::Interrupted;其他错误先发 turn-error lifecycle、记录错误,再发送 EventMsg::Error。错误返回值不会自动变成 TurnAborted。
源码位置:codex-rs/core/src/tasks/mod.rs :: Session::on_task_finished
let (last_agent_message, abort_reason) = match task_result {
Ok(last_agent_message) => (last_agent_message, None),
Err(err) if matches!(err.details(), CodexErrorDetails::TurnAborted) => {
(None, Some(TurnAbortReason::Interrupted))
}
Err(err) => {
self.emit_turn_error_lifecycle(
turn_context.as_ref(), err.to_codex_protocol_error(),
).await;
self.track_turn_codex_error(turn_context.as_ref(), &err);
self.send_event(
turn_context.as_ref(), EventMsg::Error(err.to_error_event(None)),
).await;
(None, None)
}
};主动中断走另一条链:abort_turn_if_active 取出 active task,handle_task_abort 先取消 token,等待 graceful timeout,必要时 abort task;中断 marker 写入 rollout 后显式 flush_rollout(),最后才发送 TurnAborted。因此客户端收到终止事件时能重新读到 marker。
源码位置:codex-rs/core/src/tasks/mod.rs :: handle_task_abort
task.cancellation_token.cancel();
select! {
_ = task.done.notified() => {},
_ = tokio::time::sleep(Duration::from_millis(
GRACEFULL_INTERRUPTION_TIMEOUT_MS,
)) => {},
}
task.handle.abort();
session_task.abort(Arc::clone(self), Arc::clone(&task.turn_context)).await;
// marker 写入后 flush_rollout(),然后发送 EventMsg::TurnAborted8. 测试读法与边界
协议层测试直接验证分类器的输入和输出,而不是只比较显示文本。
源码位置:codex-rs/protocol/src/error_tests.rs :: 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),
(CodexErr::InternalServerError, true),
];
for (err, expected) in errors {
assert_eq!(err.is_retryable(), expected);
}运行 cargo test -p codex-protocol retryability_preserves_error_details_distinctions 可验证 ServerOverloaded、RetryLimit、UnexpectedStatus 和 InternalServerError 的边界。该测试不覆盖 provider 是否总能发送正确 body code,也不覆盖重试后的网络成功率;后两者需分别阅读 api_bridge.rs 和 responses_retry.rs。
模型错误分类的关键不是记住哪一个字符串“像临时故障”,而是沿着 ApiError -> CodexErr -> is_retryable -> Core 状态更新/重试/事件 这条真实链路定位 owner。
