Skip to content

Responses重试策略

从 HTTP transport、Responses 流、认证恢复到 WebSocket 回退,追踪 Codex 如何决定重放、等待、切换传输或终止。

基于rust-v0.150.0
CodexRustModelRetry

Responses重试策略 ​

本文承接 模型错误分类、Responses流解析 和 ModelClient结构。前文已经说明错误如何变成 CodexErr;本文继续追踪“重试”这个动作本身:哪个循环拥有计数器,哪个循环负责等待,什么时候会重新构造请求,为什么 request_max_retries = 0 仍可能出现流重连。

rust-v0.150.0 中至少有三条独立路径:codex-client 的 HTTP request retry、Core 的 Responses stream retry,以及特定条件下的无限连接重试和 WebSocket 到 HTTPS fallback。认证 401 恢复还会重新取得 auth setup,但它不是 RetryOn 的普通 status 分支。阅读时必须把“分类”“计数”“传输切换”分开。

1. 两个预算 ​

provider 同时保存 request 与 stream 两个预算。前者在 HTTP 请求层消费,后者在 Core sampling 或 remote compaction 层消费;两层可以嵌套,所以一次 stream retry 可能再次包含多次 HTTP attempt。

源码位置:codex-rs/model-provider-info/src/lib.rs :: retry limits

rust
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
const MAX_STREAM_MAX_RETRIES: u64 = 100;
const MAX_REQUEST_MAX_RETRIES: u64 = 100;

pub fn request_max_retries(&self) -> u64 {
    self.request_max_retries
        .unwrap_or(DEFAULT_REQUEST_MAX_RETRIES)
        .min(MAX_REQUEST_MAX_RETRIES)
}

pub fn stream_max_retries(&self) -> u64 {
    self.stream_max_retries
        .unwrap_or(DEFAULT_STREAM_MAX_RETRIES)
        .min(MAX_STREAM_MAX_RETRIES)
}

这里的值是“额外重试次数”。HTTP 循环是 0..=max_attempts,所以 request 预算为 4 时最多执行一次初始请求加四次重试。stream 预算在 Core 里由 retry_state.retries < max_retries 判断;它不会因为 HTTP 层的 attempt 增加而自动增加。

2. HTTP请求循环 ​

codex-rs/codex-client/src/retry.rs 只看 TransportError。429、5xx、timeout、connection 和 network 是否重试由 RetryOn 三个布尔字段决定;构造请求失败等其他变体不会进入 sleep 分支。

源码位置:codex-rs/codex-client/src/retry.rs :: RetryOn::should_retry、run_with_retry

rust
pub fn should_retry(&self, err: &TransportError, attempt: u64, max_attempts: u64) -> bool {
    if attempt >= max_attempts {
        return false;
    }
    match err {
        TransportError::Http { status, .. } => {
            (self.retry_429 && status.as_u16() == 429)
                || (self.retry_5xx && status.is_server_error())
        }
        TransportError::Timeout
        | TransportError::Connection(_)
        | TransportError::Network(_) => self.retry_transport,
        _ => false,
    }
}

pub async fn run_with_retry<T, F, Fut>(
    policy: RetryPolicy,
    mut make_req: impl FnMut() -> Request,
    op: F,
) -> Result<T, TransportError>
where
    T: Sized,
    F: Fn(Request, u64) -> Fut,
    Fut: Future<Output = Result<T, TransportError>>,
{
    for attempt in 0..=policy.max_attempts {
        let req = make_req();
        match op(req, attempt).await {
            Ok(resp) => return Ok(resp),
            Err(err) if policy.retry_on.should_retry(
                &err, attempt, policy.max_attempts,
            ) => {
                let retry_attempt = attempt + 1;
                let delay = backoff(policy.base_delay, retry_attempt);
                crate::record_retry!(retry_attempt, delay, RetryOperation::HttpRequest);
                tokio::time::sleep(delay).await;
            }
            Err(err) => return Err(err),
        }
    }
    Err(TransportError::RetryLimit)
}

make_req() 每轮都会重新生成 request,因此认证头和动态 header 可以在下一轮更新。run_with_retry 返回的仍是 TransportError;它不会知道 ContextWindowExceeded、UsageLimitReached 或 ServerOverloaded,这些语义要等 API bridge 后由 Core 处理。

3. 两套退避公式 ​

HTTP 退避和 Core stream 退避虽然都指数增长,却由不同模块拥有。HTTP 使用 provider 传入的 base_delay;Core 使用固定初始值,并在连接失败的特殊分支中使用 5 秒到 60 秒的另一套上限。

源码位置:codex-rs/codex-client/src/retry.rs :: backoff

rust
pub fn backoff(base: Duration, attempt: u64) -> Duration {
    if attempt == 0 {
        return base;
    }
    let exp = 2u64.saturating_pow(attempt as u32 - 1);
    let millis = base.as_millis() as u64;
    let raw = millis.saturating_mul(exp);
    let jitter: f64 = rand::rng().random_range(0.9..1.1);
    Duration::from_millis((raw as f64 * jitter) as u64)
}

源码位置:codex-rs/core/src/util.rs :: backoff

rust
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 2.0;

pub fn backoff(attempt: u64) -> Duration {
    let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32);
    let base = (INITIAL_DELAY_MS as f64 * exp) as u64;
    let jitter = rand::rng().random_range(0.9..1.1);
    Duration::from_millis((base as f64 * jitter) as u64)
}

排查日志时不能只看“第几次重试”:HTTP 的 retry.attempt 来自 run_with_retry,Core 的 retries 来自 ResponsesStreamRetryState,连接无限重试又使用 connection_retries。三个数字的生命周期不同。

4. 流重试状态 ​

Responses 流已经建立后,Core 使用 ResponsesStreamRetryState。它同时保存普通 retry 次数、连接 retry 次数和连接退避延迟。普通 retry 受 provider 的 max_retries 限制;连接无限重试只在 Sampling、外部 session、非 Bedrock 且 feature 开启时生效。

源码位置:codex-rs/core/src/responses_retry.rs :: ResponsesStreamRetryState、handle_retryable_response_stream_error

rust
pub(crate) struct ResponsesStreamRetryState {
    retries: u64,
    connection_retries: u64,
    connection_retry_delay: Duration,
}

impl Default for ResponsesStreamRetryState {
    fn default() -> Self {
        Self {
            retries: 0,
            connection_retries: 0,
            connection_retry_delay: INITIAL_CONNECTION_RETRY_DELAY,
        }
    }
}

连接无限重试分支的关键条件和状态更新如下:

rust
if turn_context.config.features.enabled(Feature::UnboundedConnectionRetries)
    && matches!(request, ResponsesStreamRequest::Sampling)
    && matches!(err.details(), CodexErrorDetails::ConnectionFailed(_))
    && !turn_context.session_source.is_internal()
    && !turn_context.provider.info().is_amazon_bedrock()
{
    let retry_delay = retry_state.connection_retry_delay;
    sess.notify_stream_error(
        turn_context, "Reconnecting... waiting for network", err,
    ).await;
    retry_state.connection_retries = retry_state.connection_retries.saturating_add(1);
    codex_client::record_retry!(retry_state.connection_retries, retry_delay, operation);
    tokio::time::sleep(retry_delay).await;
    retry_state.connection_retry_delay = retry_delay
        .saturating_mul(2)
        .min(MAX_CONNECTION_RETRY_DELAY);
    return Ok(());
}

这条路径不读取 max_retries,但有明确 feature 和 provider 门控;不能把它概括成“所有 connection error 都无限重试”。

5. 普通流重试 ​

无限连接分支未命中后,处理器先在预算耗尽时尝试 WebSocket fallback,再处理普通 retry。服务端提供的 CodexErr::retry_delay() 优先于 Core backoff;每次成功安排重试后,调用者会重新进入 sampling 或 remote compaction loop。

源码位置:codex-rs/core/src/responses_retry.rs :: 普通 retry 分支

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);
    codex_client::record_retry!(retry_count, delay, operation);
    tokio::time::sleep(delay).await;
    return Ok(());
}

Err(err)

源码还有一个可见性策略:release 构建会隐藏 WebSocket 的第一次普通 retry 通知,debug 构建或 HTTP 模式则保留更多通知。日志是否出现不等于 retry 是否发生,计数和 record_retry! 才是动作依据。

6. 采样循环重建 ​

run_sampling_request 在每次 retry 后重新从 Session history 构造 prompt。上下文超限和 usage limit 在进入重试处理器前就返回;其余错误必须通过 is_retryable() 才能消费 stream budget。

源码位置:codex-rs/core/src/session/turn.rs :: run_sampling_request

rust
let max_retries = turn_context.provider.info().stream_max_retries();
let mut retry_state = ResponsesStreamRetryState::default();
let mut initial_input = Some(input);

loop {
    let prompt_input = if let Some(input) = initial_input.take() {
        input
    } else {
        sess.clone_history()
            .await
            .for_prompt(&step_context.model_info.input_modalities)
    };
    let prompt = build_prompt(
        prompt_input, &step_context, base_instructions.clone(),
    );
    let err = match try_run_sampling_request(/* runtime and request state */).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) => {
                if let Some(rate_limits) = e.rate_limits.clone() {
                    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();
}

这一设计把“重试请求”与“继续使用半截响应”分开:下一轮依据当前 history 和 pending tool output 重新生成输入。它降低了重复副作用的风险,但不提供分布式 exactly-once 保证;服务端可能已经执行了上一轮请求。

7. WebSocket切换 ​

达到 stream 预算后,ModelClientSession::try_switch_fallback_transport 调用 force_http_fallback,清空当前 WebsocketSession,并把 session 级 disable_websockets 设为 true。fallback 成功会把普通 retry 计数清零,使 HTTPS 获得新的 stream retry窗口。

源码位置:codex-rs/core/src/client.rs :: try_switch_fallback_transport

rust
pub(crate) fn try_switch_fallback_transport(
    &mut self,
    session_telemetry: &SessionTelemetry,
    model_info: &ModelInfo,
) -> bool {
    let activated = self
        .client
        .force_http_fallback(session_telemetry, model_info);
    self.websocket_session = WebsocketSession::default();
    activated
}

force_http_fallback 只有在 WebSocket 当前启用且此前没有切换过时返回 true;HTTP 已经是当前传输时再次调用会返回 false,处理器随后返回原错误。这避免了在同一 session 中反复宣称“正在 fallback”。

8. 认证与配置 ​

401 恢复不属于 RetryOn 的 429/5xx 判断。Responses client 会重新取得 current_client_setup,执行 handle_unauthorized,再把 PendingUnauthorizedRetry 传给下一次 telemetry。认证恢复可能替换 token 或 provider auth state,因此不能只复用旧 headers;恢复失败则映射成 RefreshTokenFailed。

配置最终在 ModelProviderInfo::to_api_provider 中形成 API retry policy:当前默认 request_max_retries 为 4、base_delay 为 200ms、retry_429 为 false、retry_5xx 和 retry_transport 为 true。429 是否继续尝试要等业务 body 与限额语义分类,不能仅凭 status 自动重放。

9. 测试与边界 ​

当前仓库的 Core 重试测试是 codex-rs/core/src/responses_retry_tests.rs 中的 sampling_retry_logs_stream_error_context。它构造 Sampling、turn context、CodexErr::Stream、retry 次数和 delay,然后断言日志包含 turn id、错误文本、当前次数和最大次数。

源码位置:codex-rs/core/src/responses_retry_tests.rs :: sampling_retry_logs_stream_error_context

rust
log_retry(
    ResponsesStreamRequest::Sampling,
    &turn_context,
    &CodexErr::Stream(
        "websocket closed by server before response.completed".to_string(),
    ),
    2,
    5,
    Duration::from_secs(1),
);

assert!(logs.contains("stream disconnected - retrying sampling request"));
assert!(logs.contains(&format!("turn_id={}", turn_context.sub_id)));
assert!(logs.contains("retries=2"));
assert!(logs.contains("max_retries=5"));

运行:

bash
cargo test -p codex-core sampling_retry_logs_stream_error_context

该测试证明 log_retry 的输入属于 Core sampling retry,并不证明 HTTP request attempt 数量、WebSocket fallback 是否成功,或真实 provider 会在断线前后执行什么副作用。阅读生产日志时,应分别寻找 codex.retry 的 layer=http/stream、operation=sampling/remote_compaction_v2 和 Core 的 retries/max_retries 字段。

Responses 重试的核心是 owner 分层:codex-client 决定 transport 是否可重放,Core 决定流错误是否可再次 sampling,ModelClientSession 决定是否永久切换 HTTPS,认证恢复则重新构造 auth setup。只有把这些状态机分开,才能准确解释一次失败到底消耗了哪一个预算。