mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 04:09:48 +08:00
fix(ws): settle the previous attempt before transparent retry replanning
评审第 2 条。配额透明重试原来的顺序是「detach 旧 attempt → 规划并绑定新 attempt → 把旧 attempt 的结算排进队列」。规划因此读到的是旧 attempt 还没投射的 health / adaptive / pool 状态,而且旧 attempt 仍占着自己的 pool key lease——替代 key 的挑选看到的是一把仍被占用的 key,最坏情况下判成「无可用供应商」而放弃一次 本可以成功的重试。 普通的新 turn 早就挡住了这件事:client.rs 在处理 response.create 前调用 await_pending_turn_finalization,注释写的正是「不要让新 turn 基于陈旧的 health / adaptive / pool 状态规划」。透明重试是同一个问题的另一条入口,漏了这一步。 现在顺序是:detach → 释放准入 → 结算旧 attempt 并等它落地 → 规划/绑定新 attempt。 新增 lifecycle::settle_turn_finalization:与 queue_turn_finalization 的区别只在于 「等」。后者把 handle 挂在连接上让 relay loop 继续跑,用在结算之后不再读取共享 状态的出口;前者用在必须先看到结算结果才能继续的路径上。 顺序用类型固定,而不是靠注释:settle_turn_finalization 返回 PreviousAttemptSettled,retry_active_turn_after_quota_exhaustion 要求这个参数。 凭证只能由 lifecycle 颁发(结算完成,或明确「没有 attempt 要结算」),所以把顺序 写反连编译都过不了。 重试失败路径随之变化:旧 attempt 已经结算,不再 resume 回去。logical turn 仍停在 Replanning,后续分支的 end() / finalize_active_turn 只清 logical turn、不交出 attempt,因此不存在重复结算。结算 outcome 取值不变(两条路径用的都是 terminal_outcome.unwrap_or_else(upstream_closed),而这条分支里 terminal_outcome 必为 Some——usage_limit_error 成立意味着有一个已解析的 error 终态帧)。 代价(都落在「重试失败」这一侧,且只影响已终态 attempt 的报告注解,不影响计费): - 那条最终转发给客户端的 429 事件不再进旧 attempt 的 client capture; provider 侧 capture 早在 observe_upstream_frame 里就记下了。 - 如果转发 429 给客户端也失败,record_client_delivery_aborted 落在一个已经结算的 attempt 上,成为 no-op。 测试: - lifecycle:await_turn_finalization_handle 必须「等到落地」而不是「排进队列」 (C6 依赖的性质);结算完成后规划才读状态的顺序型断言(计数器替身);结算任务 panic 也必须放行调用方,不能卡死 relay loop。 - turn_state:Replanning 状态下 end() 不再交出第二个 attempt(无重复结算)。 - e2e 新增 provider_quota_exhaustion_transparently_retries_onto_another_key: mock 上游首轮只回 Codex 的 429 usage_limit_reached,网关换到第二把 key 重放同一个 response.create;断言客户端看不到 429、上游被连两次、两次用的不是同一把 key、两个 attempt 各留一条终态行(429 的那条 + 计费的那条)。已验证它在改动前后都通过—— 它覆盖的是整条路径可用,顺序由上面的单测确定性覆盖。 夹具随之参数化出 ProviderFixture::CodexKeyPair:透明重试只有 Codex adapter 会 开启,而 codex 候选要求 auth_type = oauth,所以这个夹具用未过期的 oauth 凭证。
This commit is contained in:
@@ -10,7 +10,7 @@ use super::adapter::{
|
||||
resolve_responses_websocket_adapter, ResponsesWebSocketDrainDirective,
|
||||
ResponsesWebSocketRebindSafety,
|
||||
};
|
||||
use super::lifecycle::{queue_turn_finalization, ActiveProviderAttempt};
|
||||
use super::lifecycle::{queue_turn_finalization, ActiveProviderAttempt, PreviousAttemptSettled};
|
||||
use super::request::{
|
||||
build_planning_parts, planned_response_create_event, response_create_has_previous_response_id,
|
||||
};
|
||||
@@ -99,10 +99,17 @@ pub(super) fn record_exhausted_bound_key(
|
||||
Some((key_id, exclusion_until))
|
||||
}
|
||||
|
||||
/// 为同一个 logical turn 规划并绑定下一个 attempt。
|
||||
///
|
||||
/// `_previous_settled` 不被使用,它只是把「上一个 attempt 已经结算完毕」这个
|
||||
/// 前置条件写进签名:规划要读 health / adaptive / pool 状态,而这些是上一个
|
||||
/// attempt 结算时才投射的;它的 pool key lease 也要先释放,否则替代 key 的挑选
|
||||
/// 会看到一把仍被占用的 key。
|
||||
pub(super) async fn retry_active_turn_after_quota_exhaustion(
|
||||
bound: &mut BoundResponsesConnection,
|
||||
state: &AppState,
|
||||
context: &WebSocketRequestContext,
|
||||
_previous_settled: PreviousAttemptSettled,
|
||||
) -> bool {
|
||||
let Some(active) = bound.turn_state.logical_mut() else {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user