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
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
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
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
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
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,
}
}
}连接无限重试分支的关键条件和状态更新如下:
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 分支
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
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
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
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"));运行:
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。只有把这些状态机分开,才能准确解释一次失败到底消耗了哪一个预算。
