Skip to content

模型错误分类

从 API wire error 到 CodexErr,再到采样重试、任务终止和协议事件,逐层阅读 Codex 的模型错误路径。

基于rust-v0.150.0
CodexRustModelError

模型错误分类 ​

本文承接 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

rust
#[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 分支摘录)

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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::TurnAborted

8. 测试读法与边界 ​

协议层测试直接验证分类器的输入和输出,而不是只比较显示文本。

源码位置:codex-rs/protocol/src/error_tests.rs :: retryability_preserves_error_details_distinctions

rust
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。