Compare commits

...
4 Commits
Author SHA1 Message Date
zyycnandCodex 3110b71512 chore: bump version to 3.0.20
Co-authored-by: Codex <noreply@openai.com>
2026-07-28 18:46:20 +08:00
zyycnandCodex eac1b2c39e fix(xai): classify upstream failures by scope
Co-authored-by: Codex <noreply@openai.com>
2026-07-28 18:31:06 +08:00
zyycnandCodex 337bb80230 fix(openai): restore streaming failure fallback
Restore the v2.x stream commit policies so ordinary requests forward the first event immediately while continuation and same-batch failures remain behind the retry boundary. Preserve typed upstream failure batches through Core and both streaming adapters, with a 64 KiB prefetch limit and end-to-end regression coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-07-28 17:29:15 +08:00
zyycnandCodex 82f2a0da06 fix(openai): preserve quota exhaustion state
Prefer explicit quota evidence over generic 429 feedback, keep exhausted and terminal accounts from being downgraded by temporary rate limits, and reconcile historical cooldown states from current quota observations.

Co-authored-by: Codex <noreply@openai.com>
2026-07-28 14:47:21 +08:00
36 changed files with 3502 additions and 278 deletions
+1
View File
@@ -1215,6 +1215,7 @@ dependencies = [
"serde_json",
"thiserror",
"tokio",
"tokio-tungstenite 0.29.0",
"tower",
"tower-http 0.7.0",
"tracing",
+3 -1
View File
@@ -31,7 +31,9 @@ uuid = { version = "1.23.3", features = ["v7"] }
[dev-dependencies]
async-trait = "0.1.89"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] }
tokio = { version = "1.52.3", default-features = false, features = ["macros", "net", "rt-multi-thread"] }
tokio-tungstenite = "0.29.0"
tower = { version = "0.5.3", features = ["util"] }
[lints]
@@ -1055,6 +1055,86 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
);
}
#[tokio::test]
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(Trace::default());
let raw_created = Bytes::from_static(
b"event: response.created\r\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_atomic_api\",\"model\":\"public-model\",\"status\":\"in_progress\"}}\r\n\r\n",
);
let raw_failure = Bytes::from_static(
b"event: response.failed\r\ndata: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_atomic_api\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"atomic upstream marker\"}}}\r\n\r\n",
);
let started_wire = ProviderEvent::canonical_with_wire(
vec![started()],
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_atomic_api",
"model": "public-model",
"status": "in_progress"
}
}),
raw_created.clone(),
None,
None,
)
.expect("valid upstream started wire"),
);
let failed_wire = ProviderEvent::wire(
ProtocolWireEvent::json_with_raw_sse_metadata(
"openai",
Some("response.failed".to_owned()),
json!({
"type": "response.failed",
"response": {
"id": "resp_atomic_api",
"status": "failed",
"error": {
"code": "rate_limit_exceeded",
"message": "atomic upstream marker"
}
}
}),
raw_failure.clone(),
None,
None,
)
.expect("valid upstream failure wire"),
);
let batch = CoordinatedEvent::try_batch(
vec![started_wire, failed_wire],
CommitRequirement::CommitBeforeDelivery,
)
.expect("atomic failure delivery");
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(batch),
NextStep::Error(EngineError::Provider(ProviderError::new(
ProviderErrorKind::RateLimited,
UpstreamSendState::Sent,
))),
],
);
let response = stream_execution_response(Box::new(session), None).await;
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read SSE body");
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
assert_eq!(body.matches("atomic upstream marker").count(), 1);
assert_eq!(body.matches("event: response.failed").count(), 1);
assert!(body.contains(std::str::from_utf8(&raw_created).expect("created frame")));
assert!(body.contains(std::str::from_utf8(&raw_failure).expect("failure frame")));
assert!(!body.contains("upstream capacity is temporarily unavailable"));
assert!(body.ends_with("data: [DONE]\n\n"));
assert_eq!(trace.snapshot(), vec!["next_event", "commit", "next_error"]);
}
#[tokio::test]
async fn streaming_second_commit_request_should_fail_and_finalize_once() {
let trace = Arc::new(Trace::default());
@@ -1,14 +1,34 @@
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::{Duration, SystemTime};
use axum::{
body::Body,
http::{Request, StatusCode, header::AUTHORIZATION},
};
use futures::{SinkExt, StreamExt, future::BoxFuture};
use gateway_api::openai::responses::ResponseCreateFrameError;
use gateway_core::engine::execution::{
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
StartExecution, StartedExecution,
};
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind};
use gateway_core::event::{
GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta,
};
use gateway_core::operation::Operation;
use serde_json::json;
use gateway_core::routing::PublicModelId;
use serde_json::{Value, json};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as ClientMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tower::ServiceExt;
use super::decode_response_create;
use crate::openai::{api_router, models::ModelsExecution};
use crate::openai::{api_router, authenticated_client, models::ModelsExecution};
#[test]
fn response_create_should_default_to_the_websocket_streaming_contract() {
@@ -148,11 +168,233 @@ fn response_create_should_reject_non_boolean_stream_without_disclosing_body_valu
assert!(!rendered.contains(opaque_stream_value));
}
// `serve_responses_websocket`/`forward_execution` 只有拿到 `hyper::upgrade::OnUpgrade`
// 的真实 HTTP/1.1 升级连接才会运行;本 crate 的依赖图按 axum `ws` feature 编译
// hyper(无 `http1`),测试进程内不存在能完成升级的服务端,`hyper::upgrade` 也不
// 提供公开构造器。集成测试因此止步于 upgrade 边界:会话循环内的事件转发、错误帧
// 与断连取消仍未被覆盖,需要 dev 依赖开启 `http1` 或 socket 泛化后才能落地。
#[derive(Default)]
struct AtomicFailureTrace {
next_calls: AtomicUsize,
committed: AtomicBool,
finalized: AtomicBool,
}
struct AtomicFailureSession {
trace: Arc<AtomicFailureTrace>,
}
impl ExecutionSession for AtomicFailureSession {
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
Box::pin(async move {
match self.trace.next_calls.fetch_add(1, Ordering::AcqRel) {
0 => Ok(Some(atomic_failure_batch())),
1 => {
self.trace.finalized.store(true, Ordering::Release);
Err(EngineError::Provider(ProviderError::new(
ProviderErrorKind::RateLimited,
UpstreamSendState::Sent,
)))
}
_ => Ok(None),
}
})
}
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
Box::pin(async { Err(EngineError::InvalidDeliveryState) })
}
fn response_headers(&self) -> &[ProviderResponseHeader] {
&[]
}
fn commit_downstream(&mut self, _: Option<u16>) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async move {
self.trace.committed.store(true, Ordering::Release);
Ok(())
})
}
fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> {
Box::pin(async { Ok(()) })
}
fn is_finalized(&self) -> bool {
self.trace.finalized.load(Ordering::Acquire)
}
fn cancel(&self) {}
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
Box::pin(async move {
self.trace.finalized.store(true, Ordering::Release);
})
}
}
struct AtomicFailureExecution {
client: AuthenticatedClient,
trace: Arc<AtomicFailureTrace>,
}
impl ExecutionService for AtomicFailureExecution {
fn authenticate(
&self,
plaintext: &str,
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
(plaintext == "sk_ws_atomic")
.then(|| self.client.clone())
.ok_or(ClientAuthenticationError::InvalidKey)
}
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
vec![PublicModelId::new("model-a").expect("public model")]
}
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
model.as_str() == "model-a"
}
fn start(
&self,
request: StartExecution,
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
Box::pin(async move {
Ok(StartedExecution {
request_id: gateway_core::engine::ModelRequestId::new("req_ws_atomic")
.expect("request id"),
created_at: SystemTime::now(),
stream: request.metadata.stream,
session: Box::new(AtomicFailureSession {
trace: Arc::clone(&self.trace),
}),
})
})
}
}
fn atomic_failure_batch() -> CoordinatedEvent {
let started = ProviderEvent::canonical_with_wire(
vec![GatewayEvent::Started(ResponseMeta::new(
"resp_ws_atomic",
"model-a",
))],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {
"id": "resp_ws_atomic",
"model": "model-a",
"status": "in_progress"
}
}),
)
.expect("created wire"),
);
let failed = ProviderEvent::wire(
ProtocolWireEvent::json(
"openai",
Some("response.failed".to_owned()),
json!({
"type": "response.failed",
"response": {
"id": "resp_ws_atomic",
"status": "failed",
"error": {
"code": "rate_limit_exceeded",
"message": "websocket atomic upstream marker"
}
}
}),
)
.expect("failed wire"),
);
CoordinatedEvent::try_batch(
vec![started, failed],
CommitRequirement::CommitBeforeDelivery,
)
.expect("atomic WebSocket batch")
}
#[tokio::test]
async fn websocket_atomic_upstream_failure_batch_should_be_forwarded_once() {
let trace = Arc::new(AtomicFailureTrace::default());
let execution = Arc::new(AtomicFailureExecution {
client: authenticated_client("sk_ws_atomic"),
trace: Arc::clone(&trace),
});
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind WebSocket test server");
let address = listener.local_addr().expect("test server address");
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("serve WebSocket test router");
});
let mut request = format!("ws://{address}/v1/responses")
.into_client_request()
.expect("WebSocket request");
request.headers_mut().insert(
AUTHORIZATION,
"Bearer sk_ws_atomic".parse().expect("authorization"),
);
let (mut socket, response) = connect_async(request).await.expect("upgrade WebSocket");
assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS);
socket
.send(ClientMessage::Text(
json!({
"type": "response.create",
"model": "model-a",
"input": "hello"
})
.to_string()
.into(),
))
.await
.expect("send response.create");
let mut messages = Vec::<Value>::new();
loop {
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("WebSocket response timeout")
.expect("WebSocket remains open")
.expect("valid WebSocket frame");
let ClientMessage::Text(text) = message else {
continue;
};
let value: Value = serde_json::from_str(&text).expect("JSON WebSocket event");
let failed = value.get("type").and_then(Value::as_str) == Some("response.failed");
messages.push(value);
if failed {
break;
}
}
assert_eq!(
messages
.iter()
.filter(|message| message.get("type").and_then(Value::as_str) == Some("response.failed"))
.count(),
1
);
let failed = messages
.iter()
.find(|message| message.get("type").and_then(Value::as_str) == Some("response.failed"))
.expect("upstream failed event");
assert_eq!(
failed
.pointer("/response/error/message")
.and_then(Value::as_str),
Some("websocket atomic upstream marker")
);
assert!(trace.committed.load(Ordering::Acquire));
assert_eq!(trace.next_calls.load(Ordering::Acquire), 2);
assert!(trace.finalized.load(Ordering::Acquire));
socket.close(None).await.expect("close WebSocket");
server.abort();
}
const TEST_WEBSOCKET_KEY: &str = "AAAAAAAAAAAAAAAAAAAAAA==";
@@ -165,7 +165,9 @@ where
client_response_id: None,
upstream_response_id: None,
last_account_exhaustion: None,
last_account_exhaustion_events: Vec::new(),
provider_attempt_outcomes: Vec::new(),
pending_terminal_failure: None,
};
if session.cancellation.is_cancelled() {
@@ -243,7 +245,16 @@ pub struct ResponseExecutionSession<S: ?Sized> {
client_response_id: Option<String>,
upstream_response_id: Option<String>,
last_account_exhaustion: Option<ProviderError>,
/// 最近一次账号耗尽在重试时被丢弃的原始失败批次;只用于后续空选路终态。
last_account_exhaustion_events: Vec<ProviderEvent>,
provider_attempt_outcomes: Vec<ProviderAttemptOutcome>,
/// 原子失败批次已交给协议层、但尚待下游提交后收敛的原 Provider 错误。
pending_terminal_failure: Option<PendingTerminalFailure>,
}
struct PendingTerminalFailure {
error: ProviderError,
send_state: UpstreamSendState,
}
impl<S: ?Sized> ResponseExecutionSession<S>
@@ -259,6 +270,12 @@ where
if self.delivery_pending {
return Err(EngineError::DownstreamCommitRequired);
}
if let Some(pending) = self.pending_terminal_failure.take() {
let engine_error = provider_engine_error(&pending.error);
self.finish_provider_error_with_send_state(&pending.error, pending.send_state)
.await?;
return Err(engine_error);
}
if self.finalized {
return Ok(None);
}
@@ -278,6 +295,21 @@ where
)));
}
PullOutcome::AttemptDiscarded => {}
PullOutcome::TerminalFailure {
events,
error,
send_state,
} => {
let requirement = if self.downstream_committed_at.is_some() {
CommitRequirement::AlreadyCommitted
} else {
self.delivery_pending = true;
CommitRequirement::CommitBeforeDelivery
};
self.pending_terminal_failure =
Some(PendingTerminalFailure { error, send_state });
return CoordinatedEvent::try_batch(events, requirement).map(Some);
}
PullOutcome::End => {
if self.downstream_committed_at.is_some() {
self.finish_success().await?;
@@ -306,6 +338,14 @@ where
match self.pull().await? {
PullOutcome::Event(event) => events.push(event),
PullOutcome::AttemptDiscarded => events.clear(),
PullOutcome::TerminalFailure {
error, send_state, ..
} => {
let engine_error = provider_engine_error(&error);
self.finish_provider_error_with_send_state(&error, send_state)
.await?;
return Err(engine_error);
}
PullOutcome::End => {
if events.is_empty() {
return Err(EngineError::InvalidDeliveryState);
@@ -434,13 +474,20 @@ where
return Ok(());
}
self.delivery_pending = false;
if let Some(pending) = self.pending_terminal_failure.take() {
return self
.finish_provider_error_with_send_state(&pending.error, pending.send_state)
.await;
}
self.finish_interruption(EngineError::Cancelled).await
}
async fn pull(&mut self) -> Result<PullOutcome, EngineError> {
loop {
if self.current.is_none() {
self.prepare_attempt().await?;
if self.current.is_none()
&& let Some(outcome) = self.prepare_attempt().await?
{
return Ok(outcome);
}
let boundary = {
@@ -470,10 +517,14 @@ where
if let Some(observation) = event.take_observation()
&& let Err(error) = self.observe_response(observation)
{
if self.handle_stream_error(error).await? {
return Ok(PullOutcome::AttemptDiscarded);
match self.handle_stream_error(error).await? {
StreamErrorOutcome::AttemptDiscarded => {
return Ok(PullOutcome::AttemptDiscarded);
}
terminal @ StreamErrorOutcome::TerminalFailure { .. } => {
return Ok(terminal.into_pull_outcome());
}
}
continue;
}
let mut identity_error = None;
for fact in event.canonical_facts() {
@@ -483,10 +534,14 @@ where
}
}
if let Some(error) = identity_error {
if self.handle_stream_error(error).await? {
return Ok(PullOutcome::AttemptDiscarded);
match self.handle_stream_error(error).await? {
StreamErrorOutcome::AttemptDiscarded => {
return Ok(PullOutcome::AttemptDiscarded);
}
terminal @ StreamErrorOutcome::TerminalFailure { .. } => {
return Ok(terminal.into_pull_outcome());
}
}
continue;
}
for fact in event.canonical_facts() {
self.observe_event(fact).await?;
@@ -500,8 +555,13 @@ where
return Ok(PullOutcome::Event(event));
}
PollBoundary::Item(Some(Err(error))) => {
if self.handle_stream_error(error).await? {
return Ok(PullOutcome::AttemptDiscarded);
match self.handle_stream_error(error).await? {
StreamErrorOutcome::AttemptDiscarded => {
return Ok(PullOutcome::AttemptDiscarded);
}
terminal @ StreamErrorOutcome::TerminalFailure { .. } => {
return Ok(terminal.into_pull_outcome());
}
}
}
PollBoundary::Item(None) => {
@@ -513,7 +573,7 @@ where
}
}
async fn prepare_attempt(&mut self) -> Result<(), EngineError> {
async fn prepare_attempt(&mut self) -> Result<Option<PullOutcome>, EngineError> {
if self.attempts >= self.plan.max_attempts().get() {
return Err(EngineError::EmptyRoutingPlan);
}
@@ -615,6 +675,16 @@ where
if error.kind() == ProviderErrorKind::NoEligibleAccount
&& let Some(exhaustion) = self.last_account_exhaustion.clone()
{
let send_state = self.current_send_state();
let mut events = std::mem::take(&mut self.last_account_exhaustion_events);
if !events.is_empty() {
self.observe_atomic_terminal_events(&mut events)?;
return Ok(Some(PullOutcome::TerminalFailure {
events,
error: exhaustion,
send_state,
}));
}
let engine_error = provider_engine_error(&exhaustion);
self.finish_provider_error(&exhaustion).await?;
return Err(engine_error);
@@ -739,10 +809,24 @@ where
send_observed: false,
response_observation: None,
});
Ok(())
Ok(None)
}
async fn observe_event(&mut self, event: &GatewayEvent) -> Result<(), EngineError> {
self.observe_event_facts(event);
let current = self.current.as_mut().ok_or(EngineError::NoActiveAttempt)?;
if !current.send_observed {
self.engine
.store()
.mark_send_state(&self.request_id, UpstreamSendState::Sent)
.await?;
current.send_observed = true;
self.send_state_watermark = UpstreamSendState::Sent;
}
Ok(())
}
fn observe_event_facts(&mut self, event: &GatewayEvent) {
let elapsed = elapsed_ms(self.timing_started_at);
observe_event_timing(&mut self.timings, event, elapsed);
if let GatewayEvent::Usage(observed) = event {
@@ -756,16 +840,6 @@ where
if let GatewayEvent::ProviderCost(observed) = event {
self.cost = observed.into_estimate();
}
let current = self.current.as_mut().ok_or(EngineError::NoActiveAttempt)?;
if !current.send_observed {
self.engine
.store()
.mark_send_state(&self.request_id, UpstreamSendState::Sent)
.await?;
current.send_observed = true;
self.send_state_watermark = UpstreamSendState::Sent;
}
Ok(())
}
async fn observe_wire_event(&mut self) -> Result<(), EngineError> {
@@ -855,14 +929,22 @@ where
Ok(())
}
/// 返回 `true` 表示调用方必须丢弃本 attempt 已收集的未提交事件。
async fn handle_stream_error(&mut self, error: ProviderError) -> Result<bool, EngineError> {
/// 失败可重试时要求丢弃本 attempt;预算耗尽时可返回最后一批原始失败事件。
async fn handle_stream_error(
&mut self,
mut error: ProviderError,
) -> Result<StreamErrorOutcome, EngineError> {
// 原始 wire 只能活在本次决策栈内;后续 clone、attempt 记录与终态对象均只
// 接触已剥离的稳定错误字段。
let mut atomic_client_events = error.take_atomic_client_events();
let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?;
if matches!(
let account_exhausted = matches!(
error.kind(),
ProviderErrorKind::RateLimited | ProviderErrorKind::QuotaExhausted
) {
);
if account_exhausted {
self.last_account_exhaustion = Some(error.clone());
self.last_account_exhaustion_events.clear();
}
self.record_provider_failure(current.metadata.provider().clone(), error.kind());
// attempt_send_state 是本 attempt 自身的发送事实,驱动重试门;
@@ -910,6 +992,9 @@ where
let retryable = continuation_retry || same_account_retry || ordinary_retry;
if retryable {
if account_exhausted {
self.last_account_exhaustion_events = atomic_client_events;
}
if same_account_retry {
if let Some(account) = current.metadata.provider_account_id() {
self.credential_recovery_attempted_accounts
@@ -946,16 +1031,42 @@ where
})
.await?;
self.reset_uncommitted_observations();
return Ok(true);
return Ok(StreamErrorOutcome::AttemptDiscarded);
}
let engine_error = provider_engine_error(&error);
self.current = Some(current);
if !atomic_client_events.is_empty() {
self.observe_atomic_terminal_events(&mut atomic_client_events)?;
return Ok(StreamErrorOutcome::TerminalFailure {
events: atomic_client_events,
error,
send_state,
});
}
self.finish_provider_error_with_send_state(&error, send_state)
.await?;
Err(engine_error)
}
fn observe_atomic_terminal_events(
&mut self,
events: &mut [ProviderEvent],
) -> Result<(), EngineError> {
for event in events {
if let Some(observation) = event.take_observation() {
self.observe_response(observation)
.map_err(EngineError::Provider)?;
}
for fact in event.canonical_facts() {
self.observe_response_identity(fact)
.map_err(EngineError::Provider)?;
self.observe_event_facts(fact);
}
}
Ok(())
}
fn prepare_continuation_retry(
&mut self,
current: &CurrentAttempt,
@@ -1305,9 +1416,40 @@ where
enum PullOutcome {
Event(ProviderEvent),
AttemptDiscarded,
TerminalFailure {
events: Vec<ProviderEvent>,
error: ProviderError,
send_state: UpstreamSendState,
},
End,
}
enum StreamErrorOutcome {
AttemptDiscarded,
TerminalFailure {
events: Vec<ProviderEvent>,
error: ProviderError,
send_state: UpstreamSendState,
},
}
impl StreamErrorOutcome {
fn into_pull_outcome(self) -> PullOutcome {
match self {
Self::AttemptDiscarded => PullOutcome::AttemptDiscarded,
Self::TerminalFailure {
events,
error,
send_state,
} => PullOutcome::TerminalFailure {
events,
error,
send_state,
},
}
}
}
enum PollBoundary {
Item(Option<Result<ProviderEvent, ProviderError>>),
Cancelled,
@@ -525,7 +525,8 @@ impl DefaultExecutionService {
})
}
/// 探测失败先落库再转成客户端错误;转换会丢掉上游状态码与 provider code。
/// 探测失败先记录完整分类事实,再仅把 Provider 显式标记为客户端可见的
/// 结构化 message/code/type 带入协议错误。
async fn observe_probe_failure(
&self,
observed: &ProbeObservation,
+46 -1
View File
@@ -6,6 +6,7 @@ use std::time::Duration;
use thiserror::Error;
use crate::engine::UpstreamSendState;
use crate::event::ProviderEvent;
/// 应用层标识不满足约束。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
@@ -266,9 +267,13 @@ impl fmt::Debug for ClientVisibleUpstreamError {
/// 单次 Provider 调用的稳定错误。
///
/// 该类型不接收原始响应正文,也不会在 `Debug` 或 `Display` 中打印上游
/// 稳定诊断字段不接收原始响应正文,也不会在 `Debug` 或 `Display` 中打印上游
/// code/request ID/response ID。Adapter 若捕获到可能含 secret 的上下文,只能调用
/// [`ProviderError::redact_sensitive_context`] 丢弃正文并留下脱敏标记。
///
/// 唯一例外是 [`ProviderError::with_atomic_client_events`]:它只承载尚未交付客户端、
/// 必须与本错误一起完成重试判断的协议事件。Core 会在任何持久化或日志记录前取走
/// 这些事件,不能把它们当作诊断上下文使用。
#[derive(Clone)]
pub struct ProviderError {
kind: ProviderErrorKind,
@@ -284,8 +289,12 @@ pub struct ProviderError {
retry_same_account: bool,
sensitive_context_redacted: bool,
client_visible_upstream_error: Option<Box<ClientVisibleUpstreamError>>,
atomic_client_events: Option<Box<AtomicClientEvents>>,
}
#[derive(Clone)]
struct AtomicClientEvents(Vec<ProviderEvent>);
impl ProviderError {
/// 创建 Provider 错误。
#[must_use]
@@ -304,6 +313,7 @@ impl ProviderError {
retry_same_account: false,
sensitive_context_redacted: false,
client_visible_upstream_error: None,
atomic_client_events: None,
}
}
@@ -379,6 +389,17 @@ impl ProviderError {
self
}
/// 附加必须先由 Core 判断重试、再决定丢弃或交付的客户端协议事件。
///
/// Provider 只能放入当前 attempt 尚未交付的事件;调用方应设置明确的有界缓冲,
/// 并保证失败事件本身位于这批事件的末尾。
#[must_use]
pub fn with_atomic_client_events(mut self, events: Vec<ProviderEvent>) -> Self {
self.atomic_client_events =
(!events.is_empty()).then(|| Box::new(AtomicClientEvents(events)));
self
}
/// 丢弃敏感正文,只记录“发生过脱敏”这一事实。
#[must_use]
pub fn redact_sensitive_context(mut self, _sensitive_context: impl AsRef<str>) -> Self {
@@ -462,6 +483,23 @@ impl ProviderError {
pub fn client_visible_upstream_error(&self) -> Option<&ClientVisibleUpstreamError> {
self.client_visible_upstream_error.as_deref()
}
/// 取走只供本次重试/提交决策使用的原子客户端事件。
///
/// Core 必须在克隆错误、记录中间失败或构造终态前调用本方法,避免原始 wire
/// 进入诊断或持久化对象。
#[must_use]
pub fn take_atomic_client_events(&mut self) -> Vec<ProviderEvent> {
self.atomic_client_events
.take()
.map_or_else(Vec::new, |events| events.0)
}
/// 返回是否携带尚未提交的原子客户端事件;不暴露事件内容。
#[must_use]
pub fn has_atomic_client_events(&self) -> bool {
self.atomic_client_events.is_some()
}
}
impl fmt::Debug for ProviderError {
@@ -508,6 +546,13 @@ impl fmt::Debug for ProviderError {
.as_ref()
.map(|_| "<present>"),
)
.field(
"atomic_client_events",
&self
.atomic_client_events
.as_ref()
.map_or(0, |events| events.0.len()),
)
.finish()
}
}
@@ -13,7 +13,7 @@ use crate::engine::credential::{
OpaqueProviderData, ProviderAccountId, ProviderAccountStore,
};
use crate::error::{IdentifierError, validate_text};
use crate::routing::ProviderKind;
use crate::routing::{ProviderKind, UpstreamModelId};
const MAX_PENDING_FLOW_TTL: Duration = Duration::from_secs(30 * 60);
@@ -527,6 +527,80 @@ impl ProviderCooldown {
}
}
/// 可丢失 cooldown 的细粒度作用域。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProviderCooldownScope {
/// 只阻止同一账号调用指定上游模型。
UpstreamModel(UpstreamModelId),
}
impl ProviderCooldownScope {
#[must_use]
pub const fn upstream_model(model: UpstreamModelId) -> Self {
Self::UpstreamModel(model)
}
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::UpstreamModel(_) => "model",
}
}
#[must_use]
pub fn value(&self) -> &str {
match self {
Self::UpstreamModel(model) => model.as_str(),
}
}
}
/// 不进入账号持久状态的账号+作用域 cooldown。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderScopedCooldown {
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
scope: ProviderCooldownScope,
until: SystemTime,
}
impl ProviderScopedCooldown {
#[must_use]
pub const fn new(
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
scope: ProviderCooldownScope,
until: SystemTime,
) -> Self {
Self {
account_id,
credential_revision,
scope,
until,
}
}
#[must_use]
pub const fn account_id(&self) -> &ProviderAccountId {
&self.account_id
}
#[must_use]
pub const fn credential_revision(&self) -> CredentialRevision {
self.credential_revision
}
#[must_use]
pub const fn scope(&self) -> &ProviderCooldownScope {
&self.scope
}
#[must_use]
pub const fn until(&self) -> SystemTime {
self.until
}
}
pub trait ProviderCooldownPort: Send + Sync {
fn put_if_later(
&self,
@@ -543,6 +617,24 @@ pub trait ProviderCooldownPort: Send + Sync {
account_id: &'a ProviderAccountId,
through_revision: CredentialRevision,
) -> BoxFuture<'a, Result<bool, ProviderStoreError>>;
fn put_scoped_if_later(
&self,
cooldown: ProviderScopedCooldown,
) -> BoxFuture<'_, Result<bool, ProviderStoreError>>;
fn read_scoped<'a>(
&'a self,
account_id: &'a ProviderAccountId,
scope: &'a ProviderCooldownScope,
) -> BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>>;
fn clear_scoped<'a>(
&'a self,
account_id: &'a ProviderAccountId,
scope: &'a ProviderCooldownScope,
through_revision: CredentialRevision,
) -> BoxFuture<'a, Result<bool, ProviderStoreError>>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -399,6 +399,69 @@ fn complete_stream(total_tokens: Option<u64>) -> Vec<Result<GatewayEvent, Provid
events
}
fn atomic_response_failed(response_id: &str, marker: &str) -> ProviderError {
let started = ProviderEvent::canonical_with_wire(
vec![GatewayEvent::Started(ResponseMeta::new(
response_id,
"gpt-5",
))],
ProtocolWireEvent::json(
"openai",
Some("response.created".to_owned()),
json!({
"type": "response.created",
"response": {"id": response_id, "model": "gpt-5", "status": "in_progress"}
}),
)
.expect("started wire"),
);
let failed = ProviderEvent::wire(
ProtocolWireEvent::json(
"openai",
Some("response.failed".to_owned()),
json!({
"type": "response.failed",
"response": {
"id": response_id,
"status": "failed",
"error": {"code": "rate_limit_exceeded", "message": marker}
}
}),
)
.expect("failed wire"),
);
ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
.with_status(429)
.with_replay_safe()
.with_atomic_client_events(vec![started, failed])
}
fn bare_atomic_response_failed(response_id: &str) -> ProviderError {
let failed = ProviderEvent::canonical_with_wire(
vec![GatewayEvent::Started(ResponseMeta::new(
response_id,
"fallback-model",
))],
ProtocolWireEvent::json(
"openai",
Some("response.failed".to_owned()),
json!({
"type": "response.failed",
"response": {
"id": response_id,
"status": "failed",
"error": {"code": "rate_limit_exceeded", "message": "bare failure"}
}
}),
)
.expect("bare failed wire"),
);
ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
.with_status(429)
.with_replay_safe()
.with_atomic_client_events(vec![failed])
}
fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
let mut usage = Usage::new();
usage.input_tokens = Some(12);
@@ -1837,6 +1900,277 @@ fn explicit_429_after_structural_event_should_retry_before_commit() {
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
#[test]
fn atomic_response_failed_should_rotate_account_before_stream_commit() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_first",
items: vec![Err(atomic_response_failed(
"response-first-failed",
"first account failure",
))],
},
Script::Stream {
account_id: "acct_second",
items: complete_stream(None),
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let first = block_on(session.next_event())
.expect("second account should produce the first delivery")
.expect("started event");
let delivered = first.into_provider_events();
assert!(delivered.iter().flat_map(ProviderEvent::canonical_facts).any(
|event| matches!(event, GatewayEvent::Started(meta) if meta.response_id() == "response-1")
));
assert!(
!delivered
.iter()
.filter_map(ProviderEvent::wire_event)
.any(|wire| {
wire.data().pointer("/response/id").and_then(Value::as_str)
== Some("response-first-failed")
})
);
block_on(session.commit_downstream(Some(200))).expect("commit second account");
while block_on(session.next_event())
.expect("successful stream")
.is_some()
{}
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
let state = store.state.lock().expect("store lock");
assert_eq!(state.commits, 1);
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
#[test]
fn bare_atomic_response_failed_should_rotate_before_the_first_delivery() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_first",
items: vec![Err(bare_atomic_response_failed("response-bare-failed"))],
},
Script::Stream {
account_id: "acct_second",
items: complete_stream(None),
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let first = block_on(session.next_event())
.expect("second account should replace the bare failure")
.expect("second account started event");
assert!(first
.into_provider_events()
.iter()
.flat_map(ProviderEvent::canonical_facts)
.any(|event| matches!(event, GatewayEvent::Started(meta) if meta.response_id() == "response-1")));
block_on(session.commit_downstream(Some(200))).expect("commit second account");
while block_on(session.next_event())
.expect("successful stream")
.is_some()
{}
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
#[test]
fn exhausted_atomic_response_failed_should_deliver_only_the_last_failure_once() {
let operation = generate_operation();
let route_plan = plan(&operation);
let scripts = (1..=route_plan.max_attempts().get())
.map(|attempt| {
let response_id = format!("response-attempt-{attempt}-failed");
let marker = if attempt == route_plan.max_attempts().get() {
"last upstream failure".to_owned()
} else {
format!("discarded failure {attempt}")
};
Script::ObservedStream {
account_id: "acct_failed",
items: vec![Err(atomic_response_failed(&response_id, &marker))],
}
})
.collect::<Vec<_>>();
let expected_attempts = route_plan.max_attempts().get();
let expected_last_response = format!("response-attempt-{expected_attempts}-failed");
let (coordinator, store, provider) = coordinator(scripts);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let terminal = block_on(session.next_event())
.expect("last failure remains deliverable")
.expect("terminal failure batch");
assert_eq!(
terminal.commit_requirement(),
CommitRequirement::CommitBeforeDelivery
);
let events = terminal.into_provider_events();
let failures = events
.iter()
.filter_map(ProviderEvent::wire_event)
.filter(|wire| wire.event_type() == Some("response.failed"))
.collect::<Vec<_>>();
assert_eq!(failures.len(), 1);
assert_eq!(
failures[0]
.data()
.pointer("/response/id")
.and_then(Value::as_str),
Some(expected_last_response.as_str())
);
assert!(
failures[0]
.data()
.to_string()
.contains("last upstream failure")
);
block_on(session.commit_downstream(Some(200))).expect("commit original terminal failure");
let error = block_on(session.next_event()).expect_err("typed failure finalizes the request");
assert!(matches!(error, EngineError::Provider(_)));
assert!(session.is_finalized());
assert_eq!(
provider.contexts.lock().expect("contexts lock").len(),
usize::try_from(expected_attempts).expect("attempt count fits usize")
);
let state = store.state.lock().expect("store lock");
assert_eq!(
state.intermediate_failures,
usize::try_from(expected_attempts - 1).expect("attempt count fits usize")
);
assert_eq!(state.commits, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete);
assert!(state.finalizations[0].committed);
}
#[test]
fn empty_selection_after_atomic_failure_should_deliver_the_last_upstream_wire() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_only",
items: vec![Err(bare_atomic_response_failed("response-only-failed"))],
},
Script::Error(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
)),
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let terminal = block_on(session.next_event())
.expect("last account failure remains deliverable")
.expect("terminal failure batch");
let events = terminal.into_provider_events();
let failure = events
.iter()
.filter_map(ProviderEvent::wire_event)
.find(|wire| wire.event_type() == Some("response.failed"))
.expect("last upstream response.failed");
assert_eq!(
failure
.data()
.pointer("/response/id")
.and_then(Value::as_str),
Some("response-only-failed")
);
block_on(session.commit_downstream(Some(200))).expect("commit terminal failure");
assert!(matches!(
block_on(session.next_event()),
Err(EngineError::Provider(_))
));
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
let state = store.state.lock().expect("store lock");
assert_eq!(state.attempts.len(), 1);
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete);
assert_eq!(
state.finalizations[0].client_response_id.as_deref(),
Some("response-only-failed")
);
assert_eq!(
state.finalizations[0].upstream_response_id.as_deref(),
Some("response-only-failed")
);
}
#[test]
fn atomic_response_failed_keeps_collect_uncommitted_error_semantics() {
let operation = generate_operation();
let route_plan = plan(&operation);
let (coordinator, store, provider) = coordinator(vec![Script::ObservedStream {
account_id: "acct_required",
items: vec![Err(atomic_response_failed(
"response-required-failed",
"required account failure",
))],
}]);
let required = ProviderAccountId::new("acct_required").expect("required account");
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
Some(required),
None,
CancellationToken::new(),
))
.expect("start execution");
let error = block_on(session.collect_uncommitted())
.expect_err("non-streaming collection must keep returning the typed failure");
assert!(matches!(error, EngineError::Provider(_)));
assert!(session.is_finalized());
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 1);
let state = store.state.lock().expect("store lock");
assert_eq!(state.commits, 0);
assert_eq!(state.intermediate_failures, 0);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
}
#[test]
fn native_continuation_explicit_429_is_not_retried() {
let continuation = NativeContinuationPin::new(
@@ -2,6 +2,8 @@ use gateway_core::engine::UpstreamSendState;
use gateway_core::error::{
ClientVisibleUpstreamError, GatewayError, ProviderError, ProviderErrorKind, SafeUpstreamValue,
};
use gateway_core::event::{ProtocolWireEvent, ProviderEvent};
use serde_json::json;
#[test]
fn provider_error_debug_should_not_expose_sensitive_context() {
@@ -37,6 +39,26 @@ fn provider_error_replay_proof_should_be_explicit() {
assert!(error.replay_is_safe());
}
#[test]
fn provider_error_atomic_client_events_should_be_take_only_and_debug_redacted() {
let marker = "atomic-wire-must-not-enter-debug";
let event = ProviderEvent::wire(
ProtocolWireEvent::json(
"openai",
Some("response.failed".to_owned()),
json!({"type": "response.failed", "message": marker}),
)
.expect("atomic wire"),
);
let mut error = ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
.with_atomic_client_events(vec![event]);
assert!(error.has_atomic_client_events());
assert!(!format!("{error:?}").contains(marker));
assert_eq!(error.take_atomic_client_events().len(), 1);
assert!(!error.has_atomic_client_events());
}
#[test]
fn gateway_error_should_keep_client_visible_upstream_fields_out_of_safe_diagnostics() {
let message = "Your Codex quota is exhausted";
@@ -13,7 +13,8 @@ use gateway_core::{
},
error::StoreError as CoreStoreError,
provider_ports::{
ProviderCooldown, ProviderCooldownPort, ProviderStoreError, ProviderStoreErrorKind,
ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope, ProviderScopedCooldown,
ProviderStoreError, ProviderStoreErrorKind,
},
routing::ProviderKind,
};
@@ -104,24 +105,35 @@ impl RedisCredentialCooldownRepository {
let fingerprint = resource_fingerprint("credential cooldown", provider_account_id)?;
Ok(format!("{}:account:{fingerprint}:cooldown", self.namespace))
}
}
#[async_trait]
impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
async fn cache_credential_cooldown(&self, cooldown: &CredentialCooldown) -> StoreResult<bool> {
require_nonempty(
"credential cooldown",
"provider_account_id",
&cooldown.provider_account_id,
)?;
let until_ms = cooldown.cooldown_until.timestamp_millis();
fn scoped_key(
&self,
provider_account_id: &str,
scope: &ProviderCooldownScope,
) -> StoreResult<String> {
let account_fingerprint = resource_fingerprint("credential cooldown", provider_account_id)?;
let scope_fingerprint = resource_fingerprint("credential cooldown scope", scope.value())?;
Ok(format!(
"{}:account:{account_fingerprint}:cooldown:{}:{scope_fingerprint}",
self.namespace,
scope.kind(),
))
}
async fn cache_at_key(
&self,
key: String,
credential_revision: Revision,
cooldown_until: DateTime<Utc>,
) -> StoreResult<bool> {
let until_ms = cooldown_until.timestamp_millis();
if until_ms <= 0 {
return Err(invalid("cooldown expiry must be positive"));
}
let mut connection = self.connection.clone();
let written = Script::new(WRITE_SCRIPT)
.key(self.key(&cooldown.provider_account_id)?)
.arg(cooldown.credential_revision.get())
.key(key)
.arg(credential_revision.get())
.arg(until_ms)
.invoke_async::<i64>(&mut connection)
.await
@@ -129,18 +141,10 @@ impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
Ok(written == 1)
}
async fn read_credential_cooldown(
&self,
provider_account_id: &str,
) -> StoreResult<Option<CredentialCooldown>> {
require_nonempty(
"credential cooldown",
"provider_account_id",
provider_account_id,
)?;
async fn read_at_key(&self, key: String) -> StoreResult<Option<(Revision, DateTime<Utc>)>> {
let mut connection = self.connection.clone();
let (present, revision, until_ms): (i64, String, String) = Script::new(READ_SCRIPT)
.key(self.key(provider_account_id)?)
.key(key)
.invoke_async(&mut connection)
.await
.map_err(|_| redis_unavailable("read credential cooldown"))?;
@@ -155,11 +159,59 @@ impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
.map_err(|_| invalid("cached cooldown expiry is invalid"))?;
let cooldown_until = DateTime::from_timestamp_millis(until_ms)
.ok_or_else(|| invalid("cached cooldown expiry is invalid"))?;
Ok(Some(CredentialCooldown {
provider_account_id: provider_account_id.to_owned(),
credential_revision: Revision::new(revision)?,
cooldown_until,
}))
Ok(Some((Revision::new(revision)?, cooldown_until)))
}
async fn invalidate_at_key(
&self,
key: String,
through_revision: Revision,
) -> StoreResult<bool> {
let mut connection = self.connection.clone();
let removed = Script::new(INVALIDATE_SCRIPT)
.key(key)
.arg(through_revision.get())
.invoke_async::<i64>(&mut connection)
.await
.map_err(|_| redis_unavailable("invalidate credential cooldown"))?;
Ok(removed == 1)
}
}
#[async_trait]
impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
async fn cache_credential_cooldown(&self, cooldown: &CredentialCooldown) -> StoreResult<bool> {
require_nonempty(
"credential cooldown",
"provider_account_id",
&cooldown.provider_account_id,
)?;
self.cache_at_key(
self.key(&cooldown.provider_account_id)?,
cooldown.credential_revision,
cooldown.cooldown_until,
)
.await
}
async fn read_credential_cooldown(
&self,
provider_account_id: &str,
) -> StoreResult<Option<CredentialCooldown>> {
require_nonempty(
"credential cooldown",
"provider_account_id",
provider_account_id,
)?;
self.read_at_key(self.key(provider_account_id)?)
.await
.map(|value| {
value.map(|(credential_revision, cooldown_until)| CredentialCooldown {
provider_account_id: provider_account_id.to_owned(),
credential_revision,
cooldown_until,
})
})
}
async fn invalidate_credential_cooldown(
@@ -172,14 +224,8 @@ impl CredentialCooldownRepository for RedisCredentialCooldownRepository {
"provider_account_id",
provider_account_id,
)?;
let mut connection = self.connection.clone();
let removed = Script::new(INVALIDATE_SCRIPT)
.key(self.key(provider_account_id)?)
.arg(through_revision.get())
.invoke_async::<i64>(&mut connection)
self.invalidate_at_key(self.key(provider_account_id)?, through_revision)
.await
.map_err(|_| redis_unavailable("invalidate credential cooldown"))?;
Ok(removed == 1)
}
}
@@ -241,6 +287,69 @@ impl ProviderCooldownPort for RedisCredentialCooldownRepository {
.map_err(|_| provider_unavailable("clear credential cooldown"))
})
}
fn put_scoped_if_later(
&self,
cooldown: ProviderScopedCooldown,
) -> futures::future::BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async move {
let revision = Revision::new(cooldown.credential_revision().get())
.map_err(|_| provider_invalid("encode scoped credential cooldown"))?;
self.cache_at_key(
self.scoped_key(cooldown.account_id().as_str(), cooldown.scope())
.map_err(|_| provider_invalid("encode scoped credential cooldown"))?,
revision,
cooldown.until().into(),
)
.await
.map_err(|_| provider_unavailable("cache scoped credential cooldown"))
})
}
fn read_scoped<'a>(
&'a self,
account_id: &'a ProviderAccountId,
scope: &'a ProviderCooldownScope,
) -> futures::future::BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>>
{
Box::pin(async move {
self.read_at_key(
self.scoped_key(account_id.as_str(), scope)
.map_err(|_| provider_invalid("encode scoped credential cooldown"))?,
)
.await
.map_err(|_| provider_unavailable("read scoped credential cooldown"))?
.map(|(revision, until)| {
Ok(ProviderScopedCooldown::new(
account_id.clone(),
CredentialRevision::new(revision.get())
.map_err(|_| provider_invalid("decode scoped credential cooldown"))?,
scope.clone(),
until.into(),
))
})
.transpose()
})
}
fn clear_scoped<'a>(
&'a self,
account_id: &'a ProviderAccountId,
scope: &'a ProviderCooldownScope,
through_revision: CredentialRevision,
) -> futures::future::BoxFuture<'a, Result<bool, ProviderStoreError>> {
Box::pin(async move {
let revision = Revision::new(through_revision.get())
.map_err(|_| provider_invalid("encode scoped cooldown revision"))?;
self.invalidate_at_key(
self.scoped_key(account_id.as_str(), scope)
.map_err(|_| provider_invalid("encode scoped credential cooldown"))?,
revision,
)
.await
.map_err(|_| provider_unavailable("clear scoped credential cooldown"))
})
}
}
/// 账号状态写入到可丢失 cooldown cache 的桥接 adapter。
@@ -13,6 +13,7 @@ use gateway_core::{
QuotaObservation, QuotaWriteOutcome,
},
error::{StoreError as CoreStoreError, StoreErrorKind},
provider_ports::{ProviderCooldownPort, ProviderCooldownScope, ProviderScopedCooldown},
routing::ProviderKind,
};
use gateway_store::{
@@ -530,6 +531,109 @@ async fn credential_cooldown_read_removes_expired_grace_key() {
assert!(namespace_keys(&mut connection, &namespace).await.is_empty());
}
#[tokio::test]
async fn scoped_cooldown_isolated_by_model_and_revision_fenced() {
let Some((repository, mut connection, namespace)) = repository().await else {
return;
};
let account_id = ProviderAccountId::new("acct_scoped_cooldown").expect("valid account ID");
let revision = CredentialRevision::new(2).expect("positive revision");
let model_a = ProviderCooldownScope::upstream_model(
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
);
let model_b = ProviderCooldownScope::upstream_model(
gateway_core::routing::UpstreamModelId::new("grok-4.6").expect("model"),
);
let until_a: SystemTime = millisecond_precision(Utc::now() + Duration::seconds(30)).into();
let until_b: SystemTime = millisecond_precision(Utc::now() + Duration::seconds(60)).into();
assert!(
repository
.put_scoped_if_later(ProviderScopedCooldown::new(
account_id.clone(),
revision,
model_a.clone(),
until_a,
))
.await
.expect("cache model A cooldown")
);
assert!(
!repository
.put_scoped_if_later(ProviderScopedCooldown::new(
account_id.clone(),
CredentialRevision::new(1).expect("stale revision"),
model_a.clone(),
until_b,
))
.await
.expect("reject stale model A cooldown")
);
assert!(
repository
.put_scoped_if_later(ProviderScopedCooldown::new(
account_id.clone(),
revision,
model_b.clone(),
until_b,
))
.await
.expect("cache model B cooldown")
);
assert_eq!(
repository
.read_scoped(&account_id, &model_a)
.await
.expect("read model A cooldown")
.expect("model A cooldown")
.until(),
until_a
);
assert_eq!(
repository
.read_scoped(&account_id, &model_b)
.await
.expect("read model B cooldown")
.expect("model B cooldown")
.until(),
until_b
);
assert!(
!repository
.clear_scoped(
&account_id,
&model_a,
CredentialRevision::new(1).expect("stale revision"),
)
.await
.expect("fence stale model A invalidation")
);
assert!(
repository
.clear_scoped(&account_id, &model_a, revision)
.await
.expect("clear model A cooldown")
);
assert!(
repository
.read_scoped(&account_id, &model_a)
.await
.expect("read cleared model A cooldown")
.is_none()
);
assert!(
repository
.read_scoped(&account_id, &model_b)
.await
.expect("read retained model B cooldown")
.is_some()
);
let keys = namespace_keys(&mut connection, &namespace).await;
assert_eq!(keys.len(), 1);
assert!(!keys[0].contains(account_id.as_str()));
assert!(!keys[0].contains(model_b.value()));
}
fn cooldown(account_id: &str, revision: u64, seconds: i64) -> CredentialCooldown {
CredentialCooldown {
provider_account_id: account_id.to_owned(),
@@ -178,6 +178,14 @@ impl CodexQuotaFact {
}
}
fn quota_fact_is_active_exhaustion(fact: CodexQuotaFact, now: SystemTime) -> bool {
fact.exhausted()
&& fact
.resets_at()
.map(SystemTime::from)
.is_none_or(|reset_at| reset_at > now)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CodexQuotaSyncSummary {
pub updated: u64,
@@ -502,10 +510,20 @@ fn quota_refresh_candidate(
return None;
}
let observation = observations.and_then(|observations| observations.get(account.id()));
let snapshot = quota_snapshot_for_account(&account, observation);
let kind = match account.availability() {
AccountAvailability::QuotaExhausted => CodexQuotaRefreshKind::Exhausted {
reset_at: quota_reset_at_from_observation(&account, observation),
reset_at: quota_reset_at_from_snapshot(snapshot.as_ref()),
},
AccountAvailability::Cooldown
if snapshot
.as_ref()
.is_some_and(|snapshot| snapshot.fact().exhausted()) =>
{
CodexQuotaRefreshKind::Exhausted {
reset_at: quota_reset_at_from_snapshot(snapshot.as_ref()),
}
}
AccountAvailability::Ready
if observations.is_some() && quota_observation_is_stale(&account, observation, now) =>
{
@@ -516,17 +534,32 @@ fn quota_refresh_candidate(
Some(CodexQuotaRefreshCandidate { account, kind })
}
fn quota_reset_at_from_observation(
fn quota_snapshot_for_account(
account: &ProviderAccount,
observation: Option<&QuotaObservation>,
) -> Option<SystemTime> {
) -> Option<CodexAccountQuotaSnapshot> {
observation
.filter(|observation| observation.expected_revision == account.revision())
.and_then(quota_snapshot_from_observation)
}
fn quota_reset_at_from_snapshot(
snapshot: Option<&CodexAccountQuotaSnapshot>,
) -> Option<SystemTime> {
snapshot
.and_then(|snapshot| snapshot.fact().resets_at())
.map(SystemTime::from)
}
fn quota_observation_confirms_active_exhaustion(
account: &ProviderAccount,
observation: Option<&QuotaObservation>,
now: SystemTime,
) -> bool {
quota_snapshot_for_account(account, observation)
.is_some_and(|snapshot| quota_fact_is_active_exhaustion(snapshot.fact(), now))
}
fn quota_observation_is_stale(
account: &ProviderAccount,
observation: Option<&QuotaObservation>,
@@ -674,7 +707,7 @@ impl CodexCredentialQuotaService {
}
pub async fn synchronize(&self) -> Result<CodexQuotaSyncSummary, CodexCredentialQuotaError> {
let accounts = self.repository.list_for_provider().await?;
let mut accounts = self.repository.list_for_provider().await?;
let mut summary = CodexQuotaSyncSummary::default();
let now = SystemTime::now();
let account_ids = accounts
@@ -694,6 +727,8 @@ impl CodexCredentialQuotaService {
None
}
};
self.reconcile_persisted_exhaustion(&mut accounts, observations.as_ref(), now)
.await;
let accounts =
self.scheduling
.reserve_periodic_refreshes(accounts, observations.as_ref(), now);
@@ -730,6 +765,9 @@ impl CodexCredentialQuotaService {
}
Err(CodexQuotaFetchError::Upstream { account, error }) => {
let (availability, reason, cooldown) = classify_error(&error, observed_at);
let preserve_exhaustion = account.availability()
== AccountAvailability::QuotaExhausted
&& availability == Some(AccountAvailability::Cooldown);
match availability {
Some(AccountAvailability::Invalid | AccountAvailability::Banned) => {
summary.invalid += 1;
@@ -741,6 +779,9 @@ impl CodexCredentialQuotaService {
continue;
}
}
if preserve_exhaustion {
continue;
}
let _ = self
.repository
.apply_state(
@@ -757,6 +798,63 @@ impl CodexCredentialQuotaService {
Ok(summary)
}
async fn reconcile_persisted_exhaustion(
&self,
accounts: &mut [ProviderAccount],
observations: Option<&BTreeMap<ProviderAccountId, QuotaObservation>>,
observed_at: SystemTime,
) {
let Some(observations) = observations else {
return;
};
for account in accounts {
if account.availability() != AccountAvailability::Cooldown
|| !quota_observation_confirms_active_exhaustion(
account,
observations.get(account.id()),
observed_at,
)
{
continue;
}
let Ok(Some(current)) = self.store.get_account(account.id()).await else {
continue;
};
if current.revision() != account.revision()
|| current.availability() != AccountAvailability::Cooldown
{
continue;
}
if !matches!(
self.has_active_exhaustion(&current, observed_at).await,
Ok(true)
) {
continue;
}
if let Err(error) = self
.repository
.apply_state(
&current,
AccountAvailability::QuotaExhausted,
Some("quota_exhausted".to_owned()),
None,
observed_at,
)
.await
{
tracing::warn!(
account_id = %account.id(),
error = %error,
"OpenAI persisted quota exhaustion reconciliation failed"
);
continue;
}
let enabled = current.enabled();
*account =
current.with_runtime_state(enabled, AccountAvailability::QuotaExhausted, None);
}
}
/// 解析并 revision-fenced 落库单账号的 Provider quota JSON。
async fn apply_fetched_quota(
&self,
@@ -894,6 +992,30 @@ impl CodexCredentialQuotaService {
.await?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexCredentialQuotaError::NotFound)?;
self.read_snapshot_for(&account).await
}
pub(super) async fn has_active_exhaustion(
&self,
account: &ProviderAccount,
now: SystemTime,
) -> Result<bool, CodexCredentialQuotaError> {
match self.read_snapshot_for(account).await {
Ok(snapshot) => Ok(snapshot
.is_some_and(|snapshot| quota_fact_is_active_exhaustion(snapshot.fact(), now))),
Err(CodexCredentialQuotaError::RevisionConflict) => Ok(false),
Err(error) => Err(error),
}
}
async fn read_snapshot_for(
&self,
account: &ProviderAccount,
) -> Result<Option<CodexAccountQuotaSnapshot>, CodexCredentialQuotaError> {
if account.provider().as_str() != "openai" {
return Err(CodexCredentialQuotaError::NotFound);
}
let account_id = account.id();
let Some(observation) = self
.store
.get_quotas(std::slice::from_ref(account_id))
@@ -552,13 +552,9 @@ impl CodexCredentialSelector {
CookieRecovery::None,
),
CodexAccountFailure::RateLimited { retry_after } => {
let until = now.checked_add(retry_after.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN));
(
AccountAvailability::Cooldown,
Some("rate_limited".to_owned()),
until,
CookieRecovery::None,
)
return self
.record_rate_limit_failure(account, retry_after, now)
.await;
}
CodexAccountFailure::CloudflareChallenge { retry_after } => {
let delay = self.cloudflare_challenge_delay(account.id(), now, retry_after);
@@ -596,6 +592,49 @@ impl CodexCredentialSelector {
Ok(())
}
async fn record_rate_limit_failure(
&self,
account: &ProviderAccount,
retry_after: Option<Duration>,
observed_at: SystemTime,
) -> Result<(), CredentialSelectionError> {
let current = self.current_account(account.id()).await?;
if current.revision() != account.revision() {
return Err(CredentialRepositoryError::RevisionConflict.into());
}
if matches!(
current.availability(),
AccountAvailability::Invalid
| AccountAvailability::Expired
| AccountAvailability::Banned
| AccountAvailability::QuotaExhausted
) {
return Ok(());
}
let quota_exhausted = self
.quota
.has_active_exhaustion(&current, observed_at)
.await
.map_err(|_| CredentialSelectionError::Store)?;
let (availability, reason, cooldown_until) = if quota_exhausted {
(
AccountAvailability::QuotaExhausted,
Some("quota_exhausted".to_owned()),
None,
)
} else {
(
AccountAvailability::Cooldown,
Some("rate_limited".to_owned()),
observed_at.checked_add(retry_after.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)),
)
};
self.repository
.apply_state(&current, availability, reason, cooldown_until, observed_at)
.await?;
Ok(())
}
pub async fn record_success(
&self,
account: &ProviderAccount,
+197 -26
View File
@@ -79,6 +79,8 @@ const PROVIDER_NAME: &str = "openai";
const HTTP_SSE_TRANSPORT: &str = "http_sse";
const WEBSOCKET_TRANSPORT: &str = "websocket";
const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
/// 与 2.x 预取器一致:提交边界前最多保留 64 KiB 原始上游 chunk。
const MAX_STREAM_PREFETCH_BYTES: usize = 64 * 1024;
pub const OFFICIAL_CODEX_BASE_PATH: &str = "/backend-api";
pub const OFFICIAL_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
@@ -684,6 +686,8 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
})?;
let request_id = context.request_id().as_str().to_owned();
let cancellation = context.cancellation().clone();
let stream_commit_policy =
StreamCommitPolicy::for_continuation(context.continuation_attempt());
let mut response = create_response_attempt(
&client,
&request,
@@ -824,6 +828,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
// (transport/protocol/websocket.rs),push_frames 抽出的 data 即上游原文。
let mut decoder =
CodexCanonicalDecoder::new(upstream_model.as_str()).with_raw_sse_passthrough();
let mut pre_commit_events = PreCommitClientEvents::new(stream_commit_policy);
loop {
let Some(stream_deadline) = remaining(context.deadline()) else {
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Sent))?;
@@ -882,30 +887,41 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
}
let first_event_changed =
observation_state.observe_stream_chunk(&chunk, context.timing_started_at());
let (mut events, terminal_failure) = match decoder.push(&chunk) {
let chunk_len = chunk.len();
let (mut events, canonical_failure) = match decoder.push(&chunk) {
CodexCanonicalOutcome::Events(events) => (events, None),
CodexCanonicalOutcome::Failed(failure) => {
let (events, error, semantic_output_seen) = failure.into_parts();
let failure = map_canonical_error(
(events, Some((error, semantic_output_seen)))
}
};
pre_commit_events.observe_chunk(chunk_len);
let terminal_failure = canonical_failure.map(|(error, semantic_output_seen)| {
let atomic_upstream_failure = matches!(&error, CodexCanonicalError::Upstream(_));
(
map_canonical_error(
error,
&failure_diagnostics,
&failure_set_cookie_headers,
&failure_rate_limit_headers,
ReplayBoundary::from_semantic_output(semantic_output_seen),
);
(events, Some(failure))
}
};
ReplayBoundary::from_semantic_output(
semantic_output_seen || pre_commit_events.is_committed(),
),
),
atomic_upstream_failure,
)
});
let timing_signals = decoder.take_timing_signals();
let timing_changed = first_event_changed
|| observation_state
.observe_timing_signals(decoder.take_timing_signals(), context.timing_started_at());
.observe_timing_signals(timing_signals, context.timing_started_at());
let completed = events
.iter()
.flat_map(ProviderEvent::canonical_facts)
.any(|event| matches!(event, GatewayEvent::Completed(_)));
let terminal_changed = completed
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
if let Some(failure) = terminal_failure.as_ref() {
if let Some((failure, _)) = terminal_failure.as_ref() {
apply_failure(&failure_context, &active_account, failure)
.await;
}
@@ -933,33 +949,54 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
if rate_limits_changed || timing_changed || terminal_changed {
yield ProviderEvent::observation(observation_state.observation()?);
}
for event in events {
yield event;
}
if let Some(failure) = terminal_failure {
if let Some((mut failure, atomic_upstream_failure)) = terminal_failure {
let failure_after_commit =
timing_signals.semantic_output || pre_commit_events.is_committed();
if failure_after_commit {
for event in pre_commit_events.commit(events) {
yield event;
}
} else if atomic_upstream_failure {
failure.error = failure
.error
.with_atomic_client_events(pre_commit_events.take_for_failure(events));
}
Err(failure.error)?;
return;
}
let events = pre_commit_events.stage(events, timing_signals, completed)?;
for event in events {
yield event;
}
if completed {
return;
}
}
let (mut events, terminal_failure) = match decoder.finish() {
let (mut events, canonical_failure) = match decoder.finish() {
CodexCanonicalOutcome::Events(events) => (events, None),
CodexCanonicalOutcome::Failed(failure) => {
let (events, error, semantic_output_seen) = failure.into_parts();
let failure = map_canonical_error(
(events, Some((error, semantic_output_seen)))
}
};
let terminal_failure = canonical_failure.map(|(error, semantic_output_seen)| {
let atomic_upstream_failure = matches!(&error, CodexCanonicalError::Upstream(_));
(
map_canonical_error(
error,
&failure_diagnostics,
&failure_set_cookie_headers,
&failure_rate_limit_headers,
ReplayBoundary::from_semantic_output(semantic_output_seen),
);
(events, Some(failure))
}
};
ReplayBoundary::from_semantic_output(
semantic_output_seen || pre_commit_events.is_committed(),
),
),
atomic_upstream_failure,
)
});
let timing_signals = decoder.take_timing_signals();
let timing_changed = observation_state
.observe_timing_signals(decoder.take_timing_signals(), context.timing_started_at());
.observe_timing_signals(timing_signals, context.timing_started_at());
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
let rate_limits_changed = if updates.is_empty() {
false
@@ -969,7 +1006,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
}
observation_state.merge_rate_limit_headers(&updates)
};
if let Some(failure) = terminal_failure.as_ref() {
if let Some((failure, _)) = terminal_failure.as_ref() {
apply_failure(&failure_context, &active_account, failure)
.await;
}
@@ -1008,13 +1045,25 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
if timing_changed || rate_limits_changed || terminal_changed {
yield ProviderEvent::observation(observation_state.observation()?);
}
for event in events {
yield event;
}
if let Some(failure) = terminal_failure {
if let Some((mut failure, atomic_upstream_failure)) = terminal_failure {
let failure_after_commit =
timing_signals.semantic_output || pre_commit_events.is_committed();
if failure_after_commit {
for event in pre_commit_events.commit(events) {
yield event;
}
} else if atomic_upstream_failure {
failure.error = failure
.error
.with_atomic_client_events(pre_commit_events.take_for_failure(events));
}
Err(failure.error)?;
return;
}
let events = pre_commit_events.finish(events, timing_signals, completed)?;
for event in events {
yield event;
}
})
}
@@ -1735,6 +1784,128 @@ impl MappedProviderFailure {
}
}
/// 与 2.x 一致的流提交策略:普通请求优先首事件,续写/重放优先可恢复性。
#[derive(Clone, Copy)]
enum StreamCommitPolicy {
FirstForwardableEvent,
UntilOutputOrTerminal,
}
impl StreamCommitPolicy {
const fn for_continuation(attempt: ContinuationAttempt) -> Self {
match attempt {
ContinuationAttempt::None => Self::FirstForwardableEvent,
ContinuationAttempt::Native
| ContinuationAttempt::ReplayOwner
| ContinuationAttempt::ReplayAny => Self::UntilOutputOrTerminal,
}
}
fn boundary_reached(
self,
events: &[ProviderEvent],
timing_signals: ResponseEventSignals,
completed: bool,
) -> bool {
match self {
Self::FirstForwardableEvent => events.iter().any(ProviderEvent::has_client_event),
Self::UntilOutputOrTerminal => timing_signals.semantic_output || completed,
}
}
}
/// 提交边界前的上游事件预取。
///
/// 原始 chunk 计数而不是重编码后的 event 大小,保持 2.x 的 64 KiB 资源边界;一旦
/// 提交,后续事件不再具备无痕重放资格。
struct PreCommitClientEvents {
policy: StreamCommitPolicy,
pending: Vec<ProviderEvent>,
prefetched_bytes: usize,
committed: bool,
}
impl PreCommitClientEvents {
const fn new(policy: StreamCommitPolicy) -> Self {
Self {
policy,
pending: Vec::new(),
prefetched_bytes: 0,
committed: false,
}
}
fn observe_chunk(&mut self, bytes: usize) {
if !self.committed {
self.prefetched_bytes = self.prefetched_bytes.saturating_add(bytes);
}
}
fn stage(
&mut self,
incoming: Vec<ProviderEvent>,
timing_signals: ResponseEventSignals,
completed: bool,
) -> Result<Vec<ProviderEvent>, ProviderError> {
if self.committed {
return Ok(incoming);
}
let boundary_reached = self
.policy
.boundary_reached(&incoming, timing_signals, completed);
self.pending.extend(incoming);
if boundary_reached {
return Ok(self.commit_pending());
}
if self.prefetched_bytes > MAX_STREAM_PREFETCH_BYTES {
return Err(prefetch_protocol_error());
}
Ok(Vec::new())
}
fn finish(
&mut self,
incoming: Vec<ProviderEvent>,
timing_signals: ResponseEventSignals,
completed: bool,
) -> Result<Vec<ProviderEvent>, ProviderError> {
let events = self.stage(incoming, timing_signals, completed)?;
if self.committed {
Ok(events)
} else {
Err(prefetch_protocol_error())
}
}
fn commit(&mut self, incoming: Vec<ProviderEvent>) -> Vec<ProviderEvent> {
if self.committed {
return incoming;
}
self.pending.extend(incoming);
self.commit_pending()
}
fn take_for_failure(&mut self, incoming: Vec<ProviderEvent>) -> Vec<ProviderEvent> {
self.pending.extend(incoming);
self.prefetched_bytes = 0;
std::mem::take(&mut self.pending)
}
const fn is_committed(&self) -> bool {
self.committed
}
fn commit_pending(&mut self) -> Vec<ProviderEvent> {
self.committed = true;
self.prefetched_bytes = 0;
std::mem::take(&mut self.pending)
}
}
fn prefetch_protocol_error() -> ProviderError {
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::Sent)
}
#[derive(Clone, Copy)]
enum ReplayBoundary {
BeforeSemanticOutput,
@@ -273,8 +273,20 @@ impl CodexCanonicalDecoder {
self.merge_timing_signals(signals);
if matches!(event_type, Some("response.failed" | "error")) {
let failure = ResponsesSseFailure::from_event(event_type.unwrap_or_default(), &value);
let mut canonical = Vec::new();
if !self.started {
// 失败首帧可能是唯一携带 response_id 的上游事实。身份投影只做
// 最佳努力:缺少 response/id 时仍必须保留原 typed failure。
let _ = self.start(&value, &mut canonical);
}
if let Some(wire) = Self::wire_for_event(event, value, raw_sse_frame) {
output.push(ProviderEvent::wire(wire));
output.push(if canonical.is_empty() {
ProviderEvent::wire(wire)
} else {
ProviderEvent::canonical_with_wire(canonical, wire)
});
} else {
output.extend(canonical.into_iter().map(ProviderEvent::canonical));
}
return Err(CodexCanonicalError::Upstream(failure));
}
@@ -415,15 +415,6 @@ fn classify_upstream_failure(
if status == Some(StatusCode::NOT_FOUND) && body.trim().is_empty() {
return CodexFailureCategory::CloudflarePathBlocked;
}
if [code.as_str(), error_type.as_str()]
.into_iter()
.any(is_rate_limit_signal)
|| is_rate_limit_message(&message)
|| is_rate_limit_message(&body)
|| status == Some(StatusCode::TOO_MANY_REQUESTS)
{
return CodexFailureCategory::RateLimited;
}
if [code.as_str(), error_type.as_str()]
.into_iter()
.any(is_quota_signal)
@@ -433,6 +424,15 @@ fn classify_upstream_failure(
{
return CodexFailureCategory::QuotaExhausted;
}
if [code.as_str(), error_type.as_str()]
.into_iter()
.any(is_rate_limit_signal)
|| is_rate_limit_message(&message)
|| is_rate_limit_message(&body)
|| status == Some(StatusCode::TOO_MANY_REQUESTS)
{
return CodexFailureCategory::RateLimited;
}
if is_upstream_overload(&code) || is_upstream_overload(&message) {
return CodexFailureCategory::Unavailable;
}
+27 -2
View File
@@ -36,8 +36,9 @@ use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderStoreError, ProviderStorePorts,
ProviderCooldownScope, ProviderCredentialState, ProviderCredentialStatePort,
ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError,
ProviderStorePorts,
};
use gateway_core::routing::{
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext,
@@ -901,6 +902,30 @@ impl ProviderCooldownPort for TestCooldown {
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
Box::pin(async { Ok(false) })
}
fn put_scoped_if_later(
&self,
_cooldown: ProviderScopedCooldown,
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async { Ok(false) })
}
fn read_scoped<'a>(
&'a self,
_account_id: &'a ProviderAccountId,
_scope: &'a ProviderCooldownScope,
) -> BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>> {
Box::pin(async { Ok(None) })
}
fn clear_scoped<'a>(
&'a self,
_account_id: &'a ProviderAccountId,
_scope: &'a ProviderCooldownScope,
_through_revision: CredentialRevision,
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
Box::pin(async { Ok(false) })
}
}
struct TestRuntimePolicy;
@@ -7,7 +7,8 @@ use chrono::Utc;
use futures::executor::block_on;
use gateway_core::engine::credential::{
AccountAttemptFeedback, AccountAvailability, AccountFeedbackStats, AccountSelectionPolicy,
ProviderAccountId, RotationStrategy,
AccountStateChange, OpaqueProviderData, ProviderAccountId, ProviderAccountStore as _,
QuotaObservation, RotationStrategy,
};
use gateway_core::engine::{
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext,
@@ -23,6 +24,7 @@ use provider_openai::credential::{
};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use secrecy::ExposeSecret;
use serde_json::json;
use url::Url;
use crate::support::{
@@ -637,6 +639,171 @@ fn rate_limited_failure_marks_account_cooldown_with_a_cooldown_deadline() {
);
}
#[test]
fn rate_limited_failure_does_not_downgrade_persisted_quota_exhaustion() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_primary", "at-primary");
let account = store.account("acct_primary").expect("account");
let reset_at = Utc::now().timestamp() + 24 * 60 * 60;
block_on(
store.compare_and_swap_quota(QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: Some(OpaqueProviderData::new(
json!({
"rate_limit": {
"allowed": false,
"primary_window": {"used_percent": 100, "reset_at": reset_at}
}
})
.as_object()
.expect("quota object")
.clone(),
)),
observed_at: Some(SystemTime::now()),
}),
)
.expect("persist exhausted quota");
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
block_on(selector.record_failure(
&account,
CodexAccountFailure::RateLimited {
retry_after: Some(Duration::from_secs(30)),
},
))
.expect("record rate-limit failure");
assert_eq!(
store
.account("acct_primary")
.expect("persisted account")
.availability(),
AccountAvailability::QuotaExhausted
);
}
#[test]
fn rate_limited_failure_ignores_exhaustion_after_its_reset_time() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_primary", "at-primary");
let account = store.account("acct_primary").expect("account");
let reset_at = Utc::now().timestamp() - 1;
block_on(
store.compare_and_swap_quota(QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: Some(OpaqueProviderData::new(
json!({
"rate_limit": {
"allowed": false,
"primary_window": {"used_percent": 100, "reset_at": reset_at}
}
})
.as_object()
.expect("quota object")
.clone(),
)),
observed_at: Some(SystemTime::now()),
}),
)
.expect("persist expired quota exhaustion");
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
block_on(selector.record_failure(
&account,
CodexAccountFailure::RateLimited {
retry_after: Some(Duration::from_secs(30)),
},
))
.expect("record rate-limit failure");
assert_eq!(
store
.account("acct_primary")
.expect("persisted account")
.availability(),
AccountAvailability::Cooldown
);
}
#[test]
fn rate_limited_failure_preserves_terminal_account_state() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_primary", "at-primary");
let account = store.account("acct_primary").expect("account");
block_on(store.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
availability: AccountAvailability::Invalid,
reason: Some("identity_verification_required".to_owned()),
cooldown_until: None,
observed_at: SystemTime::now(),
}))
.expect("mark account invalid");
let current = store.account("acct_primary").expect("invalid account");
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
block_on(selector.record_failure(
&current,
CodexAccountFailure::RateLimited {
retry_after: Some(Duration::from_secs(30)),
},
))
.expect("record rate-limit failure");
assert_eq!(
store
.account("acct_primary")
.expect("persisted account")
.availability(),
AccountAvailability::Invalid
);
}
#[test]
fn rate_limited_failures_for_distinct_accounts_do_not_conflict() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_first", "at-first");
create_account(&store, "acct_second", "at-second");
let first = store.account("acct_first").expect("first account");
let second = store.account("acct_second").expect("second account");
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
block_on(async {
let (first_result, second_result) = futures::join!(
selector.record_failure(
&first,
CodexAccountFailure::RateLimited {
retry_after: Some(Duration::from_secs(30)),
},
),
selector.record_failure(
&second,
CodexAccountFailure::RateLimited {
retry_after: Some(Duration::from_secs(30)),
},
),
);
first_result.expect("record first account failure");
second_result.expect("record second account failure");
});
assert_eq!(
[
store
.account("acct_first")
.expect("persisted first account")
.availability(),
store
.account("acct_second")
.expect("persisted second account")
.availability(),
],
[AccountAvailability::Cooldown; 2]
);
}
#[test]
fn selector_keeps_a_quota_exhausted_account_eligible_when_configured_not_to_skip_it() {
let store = Arc::new(MemoryAccountStore::default());
@@ -480,6 +480,148 @@ async fn periodic_quota_synchronization_skips_fresh_ready_accounts() {
assert_eq!(summary, CodexQuotaSyncSummary::default());
}
#[tokio::test]
async fn periodic_quota_synchronization_repairs_cooldown_with_active_exhausted_quota() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_periodic_stuck_cooldown").await;
let account = store
.account("acct_periodic_stuck_cooldown")
.expect("created account");
let reset_at = Utc::now().timestamp() + 24 * 60 * 60;
store
.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
availability: AccountAvailability::Cooldown,
reason: Some("rate_limited".to_owned()),
cooldown_until: SystemTime::now().checked_add(Duration::from_secs(24 * 60 * 60)),
observed_at: SystemTime::now(),
})
.await
.expect("mark account cooldown");
store
.compare_and_swap_quota(QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: Some(OpaqueProviderData::new(
json!({
"rate_limit": {
"allowed": false,
"primary_window": {"used_percent": 100, "reset_at": reset_at}
}
})
.as_object()
.expect("quota object")
.clone(),
)),
observed_at: Some(SystemTime::now()),
})
.await
.expect("persist exhausted quota");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(429))
.mount(&server)
.await;
let service = quota_service_with_base_url(
&store,
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
service.synchronize().await.expect("quota synchronization");
assert_eq!(
store
.account("acct_periodic_stuck_cooldown")
.expect("reconciled account")
.availability(),
AccountAvailability::QuotaExhausted
);
}
#[tokio::test]
async fn periodic_quota_synchronization_does_not_downgrade_exhaustion_on_rate_limit() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_periodic_exhausted").await;
let account = store
.account("acct_periodic_exhausted")
.expect("created account");
store
.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
availability: AccountAvailability::QuotaExhausted,
reason: Some("quota_exhausted".to_owned()),
cooldown_until: None,
observed_at: SystemTime::now(),
})
.await
.expect("mark account exhausted");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(429))
.mount(&server)
.await;
let service = quota_service_with_base_url(
&store,
reqwest::Client::builder()
.no_proxy()
.build()
.expect("client"),
server.uri(),
);
service.synchronize().await.expect("quota synchronization");
assert_eq!(
store
.account("acct_periodic_exhausted")
.expect("persisted account")
.availability(),
AccountAvailability::QuotaExhausted
);
}
#[tokio::test]
async fn periodic_quota_synchronization_skips_ordinary_cooldown_without_exhausted_quota() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_periodic_temporary_cooldown").await;
let account = store
.account("acct_periodic_temporary_cooldown")
.expect("created account");
store
.apply_state_change(AccountStateChange {
account_id: account.id().clone(),
expected_revision: account.revision(),
availability: AccountAvailability::Cooldown,
reason: Some("rate_limited".to_owned()),
cooldown_until: SystemTime::now().checked_add(Duration::from_secs(60)),
observed_at: SystemTime::now(),
})
.await
.expect("mark account cooldown");
let summary = blocked_network_quota_service(&store)
.synchronize()
.await
.expect("ordinary cooldown must not query upstream");
assert_eq!(summary, CodexQuotaSyncSummary::default());
assert_eq!(
store
.account("acct_periodic_temporary_cooldown")
.expect("cooldown account")
.availability(),
AccountAvailability::Cooldown
);
}
#[tokio::test]
async fn periodic_quota_synchronization_limits_stale_ready_account_scans() {
let store = Arc::new(MemoryAccountStore::default());
@@ -10,7 +10,7 @@ use gateway_core::engine::credential::{
};
use gateway_core::engine::provider::{Provider as _, ProviderRequest};
use gateway_core::engine::{
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId,
AccountAttemptContext, AttemptContext, CancellationToken, ContinuationAttempt, ModelRequestId,
RequestAttemptContext, UpstreamSendState,
};
use gateway_core::error::ProviderErrorKind;
@@ -29,6 +29,12 @@ use provider_openai::transport::CodexWebSocketPool;
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use provider_openai::{CodexProvider, OFFICIAL_CODEX_BASE_URL};
use serde_json::{Map, json};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::oneshot,
time::timeout,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -225,6 +231,78 @@ fn diagnostic_context(request_id: &str, account_id: &str) -> AttemptContext {
)
}
async fn paused_chunked_sse_server(
first_chunk: String,
second_chunk: String,
) -> (
String,
oneshot::Sender<()>,
oneshot::Receiver<()>,
tokio::task::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind chunked SSE listener");
let base_url = format!(
"http://{}",
listener.local_addr().expect("listener address")
);
let (release_sender, release_receiver) = oneshot::channel();
let (first_chunk_sender, first_chunk_sent) = oneshot::channel();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept chunked SSE request");
read_http_request(&mut stream).await;
stream
.write_all(
b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n",
)
.await
.expect("write chunked SSE headers");
write_http_chunk(&mut stream, &first_chunk).await;
let _ = first_chunk_sender.send(());
let _ = release_receiver.await;
if !second_chunk.is_empty() {
write_http_chunk(&mut stream, &second_chunk).await;
}
stream
.write_all(b"0\r\n\r\n")
.await
.expect("terminate chunked SSE response");
});
(base_url, release_sender, first_chunk_sent, server)
}
async fn read_http_request(stream: &mut TcpStream) {
let mut request = Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let read = stream.read(&mut buffer).await.expect("read HTTP request");
if read == 0 {
return;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
return;
}
}
}
async fn write_http_chunk(stream: &mut TcpStream, body: &str) {
stream
.write_all(format!("{:X}\r\n", body.len()).as_bytes())
.await
.expect("write HTTP chunk size");
stream
.write_all(body.as_bytes())
.await
.expect("write HTTP chunk body");
stream
.write_all(b"\r\n")
.await
.expect("terminate HTTP chunk");
stream.flush().await.expect("flush HTTP chunk");
}
#[tokio::test]
async fn openai_provider_rejects_a_foreign_provider_candidate_before_account_selection() {
let store = Arc::new(MemoryAccountStore::default());
@@ -449,6 +527,355 @@ async fn completed_response_persists_session_affinity_before_stream_consumer_sto
assert_eq!(affinity.binding_count(), 1);
}
#[tokio::test]
async fn response_failed_before_semantic_output_is_atomic_and_persists_cooldown() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_atomic_failure";
create_account(&store, account_id).await;
let (base_url, release, _first_chunk_sent, server) = paused_chunked_sse_server(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_atomic_failure\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n",
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"status_code\":429,\"retry_after_seconds\":17,\"response\":{\"id\":\"resp_atomic_failure\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"atomic upstream failure\"}}}\n\n"
)
.to_owned(),
String::new(),
)
.await;
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
context("req_atomic_failure", CancellationToken::new()),
)
.await
.expect("prepare provider stream");
let mut visible_before_error = 0;
let mut failure = loop {
match stream.next().await {
Some(Ok(event)) => visible_before_error += usize::from(event.has_client_event()),
Some(Err(error)) => break error,
None => panic!("response.failed must produce a typed failure"),
}
};
assert_eq!(visible_before_error, 0);
assert_eq!(failure.kind(), ProviderErrorKind::RateLimited);
assert_eq!(failure.send_state(), UpstreamSendState::Sent);
assert_eq!(failure.upstream_status(), Some(429));
assert!(failure.replay_is_safe());
let events = failure.take_atomic_client_events();
assert_eq!(
events
.iter()
.filter_map(|event| event.wire_event()?.event_type())
.collect::<Vec<_>>(),
vec!["response.created", "response.failed"]
);
let account = store.account(account_id).expect("rate-limited account");
assert_eq!(account.availability(), AccountAvailability::Cooldown);
assert!(
account
.cooldown_until()
.is_some_and(|until| until > SystemTime::now())
);
let _ = release.send(());
server.abort();
let _ = server.await;
}
#[tokio::test]
async fn ordinary_request_should_forward_created_before_later_failed_chunk() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_first_event_latency").await;
let (base_url, release, _first_chunk_sent, server) = paused_chunked_sse_server(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_first_event\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n"
)
.to_owned(),
concat!(
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"status_code\":429,\"response\":{\"id\":\"resp_first_event\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"later failure\"}}}\n\n"
)
.to_owned(),
)
.await;
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
context("req_first_event_latency", CancellationToken::new()),
)
.await
.expect("prepare provider stream");
let first_event = loop {
let next = timeout(Duration::from_secs(1), stream.next())
.await
.expect("first chunk must not wait for the withheld failure")
.expect("provider stream must stay open")
.expect("provider event");
if next.has_client_event() {
break next;
}
};
assert_eq!(
first_event.wire_event().and_then(|wire| wire.event_type()),
Some("response.created")
);
release.send(()).expect("release second upstream chunk");
let mut later_wire_types = Vec::new();
let failure = loop {
let next = timeout(Duration::from_secs(1), stream.next())
.await
.expect("later failure must arrive after release")
.expect("provider stream must return typed failure");
match next {
Ok(event) => {
if let Some(event_type) = event.wire_event().and_then(|wire| wire.event_type()) {
later_wire_types.push(event_type.to_owned());
}
}
Err(error) => break error,
}
};
assert_eq!(later_wire_types, vec!["response.failed"]);
assert!(!failure.replay_is_safe());
assert!(!failure.has_atomic_client_events());
server.await.expect("chunked SSE server");
}
#[tokio::test]
async fn continuation_should_hold_created_until_semantic_output() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_continuation_prefetch").await;
let (base_url, release, first_chunk_sent, server) = paused_chunked_sse_server(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_continuation\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n"
)
.to_owned(),
concat!(
"event: response.content_part.added\n",
"data: {\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\"}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"hello\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_continuation\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
)
.to_owned(),
)
.await;
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
context("req_continuation_prefetch", CancellationToken::new())
.with_continuation_attempt(ContinuationAttempt::Native),
)
.await
.expect("prepare provider stream");
let mut first_chunk_sent = Box::pin(first_chunk_sent);
loop {
tokio::select! {
sent = &mut first_chunk_sent => {
sent.expect("first upstream chunk");
break;
}
next = stream.next() => {
let event = next
.expect("provider stream must stay open")
.expect("provider event");
assert!(!event.has_client_event(), "created was exposed before the first chunk barrier");
}
}
}
let blocked = timeout(Duration::from_millis(100), async {
loop {
let next = stream
.next()
.await
.expect("provider stream must stay open")
.expect("provider event");
if next.has_client_event() {
return next;
}
}
})
.await;
assert!(blocked.is_err());
release.send(()).expect("release semantic output chunk");
let first_event = loop {
let next = timeout(Duration::from_secs(1), stream.next())
.await
.expect("semantic output must release the prefetched batch")
.expect("provider stream must stay open")
.expect("provider event");
if next.has_client_event() {
break next;
}
};
assert_eq!(
first_event.wire_event().and_then(|wire| wire.event_type()),
Some("response.created")
);
while let Some(event) = stream.next().await {
event.expect("continuation response must complete");
}
server.await.expect("chunked SSE server");
}
#[tokio::test]
async fn bare_response_failed_should_remain_an_atomic_replay_safe_failure() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_bare_atomic_failure").await;
let (base_url, release, _first_chunk_sent, server) = paused_chunked_sse_server(
concat!(
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"status_code\":429,\"response\":{\"id\":\"resp_bare_failure\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"bare failure\"}}}\n\n"
)
.to_owned(),
String::new(),
)
.await;
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
context("req_bare_atomic_failure", CancellationToken::new()),
)
.await
.expect("prepare provider stream");
let mut visible_before_error = 0;
let mut failure = loop {
match stream.next().await {
Some(Ok(event)) => visible_before_error += usize::from(event.has_client_event()),
Some(Err(error)) => break error,
None => panic!("bare response.failed must produce a typed failure"),
}
};
assert_eq!(visible_before_error, 0);
assert!(failure.replay_is_safe());
let events = failure.take_atomic_client_events();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].wire_event().and_then(|wire| wire.event_type()),
Some("response.failed")
);
assert!(events[0]
.canonical_facts()
.iter()
.any(|event| matches!(event, GatewayEvent::Started(meta) if meta.response_id() == "resp_bare_failure")));
let _ = release.send(());
server.abort();
let _ = server.await;
}
#[tokio::test]
async fn continuation_prefetch_over_64_kib_without_a_boundary_should_fail_as_protocol_error() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_prefetch_limit").await;
let padding = "x".repeat(64 * 1024);
let body = format!(
"event: response.created\ndata: {}\n\n",
json!({
"type": "response.created",
"response": {
"id": "resp_prefetch_limit",
"model": "gpt-5.4",
"status": "in_progress",
"padding": padding,
}
})
);
assert!(body.len() > 64 * 1024);
let (base_url, release, _first_chunk_sent, server) =
paused_chunked_sse_server(body, String::new()).await;
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
context("req_prefetch_limit", CancellationToken::new())
.with_continuation_attempt(ContinuationAttempt::Native),
)
.await
.expect("prepare provider stream");
let mut visible_before_error = 0;
let failure = loop {
match stream.next().await {
Some(Ok(event)) => visible_before_error += usize::from(event.has_client_event()),
Some(Err(error)) => break error,
None => panic!("oversized prefetch must fail"),
}
};
assert_eq!(visible_before_error, 0);
assert_eq!(failure.kind(), ProviderErrorKind::Protocol);
assert!(!failure.has_atomic_client_events());
let _ = release.send(());
server.abort();
let _ = server.await;
}
#[tokio::test]
async fn response_failed_after_semantic_output_is_exposed_and_not_replay_safe() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_semantic_failure").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_semantic_failure\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\"}}\n\n",
"event: response.content_part.added\n",
"data: {\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\"}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"hello\"}\n\n",
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"status_code\":429,\"response\":{\"id\":\"resp_semantic_failure\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"failure after output\"}}}\n\n"
)),
)
.mount(&server)
.await;
let mut stream = provider_with_base_url(&store, server.uri())
.execute(
planned_request("openai", http_generate_operation()),
context("req_semantic_failure", CancellationToken::new()),
)
.await
.expect("prepare provider stream");
let mut wire_types = Vec::new();
let failure = loop {
match stream.next().await {
Some(Ok(event)) => {
if let Some(event_type) = event.wire_event().and_then(|wire| wire.event_type()) {
wire_types.push(event_type.to_owned());
}
}
Some(Err(error)) => break error,
None => panic!("response.failed must produce a typed failure"),
}
};
assert_eq!(
wire_types,
vec![
"response.created",
"response.content_part.added",
"response.output_text.delta",
"response.failed"
]
);
assert!(!failure.replay_is_safe());
assert!(!failure.has_atomic_client_events());
}
#[tokio::test]
async fn disabled_account_diagnostic_uses_upstream_without_persisting_account_state() {
let store = Arc::new(MemoryAccountStore::default());
@@ -528,6 +528,40 @@ fn raw_sse_failure_should_keep_the_original_frame_before_reporting_the_typed_fai
assert!(!format!("{failure:?}").contains("raw failure marker"));
}
#[test]
fn bare_response_failed_should_project_started_identity_with_the_fallback_model() {
let raw = concat!(
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_bare_failed\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"bare failure\"}}}\n\n",
);
let failure = CodexCanonicalDecoder::new("fallback-model")
.push(raw.as_bytes())
.expect_err("bare response.failed remains a typed lifecycle failure");
assert!(matches!(
canonical_facts(failure.events()).as_slice(),
[GatewayEvent::Started(metadata)]
if metadata.response_id() == "resp_bare_failed"
&& metadata.model() == "fallback-model"
));
}
#[test]
fn response_failed_without_identity_should_keep_the_upstream_typed_failure() {
let raw = concat!(
"event: response.failed\n",
"data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"missing identity\"}}}\n\n",
);
let failure = CodexCanonicalDecoder::new("fallback")
.push(raw.as_bytes())
.expect_err("missing identity must not replace the upstream lifecycle failure");
assert!(matches!(failure.error(), CodexCanonicalError::Upstream(_)));
assert!(canonical_facts(failure.events()).is_empty());
}
#[test]
fn decoder_should_preserve_same_chunk_output_before_typed_failure() {
let marker = "same-chunk-secret-marker";
@@ -143,3 +143,19 @@ fn structured_expiry_code_classifies_without_an_auth_status() {
CodexFailureCategory::CredentialExpired
);
}
#[test]
fn structured_quota_signal_takes_precedence_over_http_429() {
assert_eq!(
classify(
429,
r#"{"error":{"code":"insufficient_quota","message":"rate limit reached"}}"#
),
CodexFailureCategory::QuotaExhausted
);
}
#[test]
fn bare_http_429_remains_a_temporary_rate_limit() {
assert_eq!(classify(429, ""), CodexFailureCategory::RateLimited);
}
@@ -10,10 +10,11 @@ use gateway_core::engine::credential::{
AccountSelectionContext, AccountSelector, ProviderAccount, ProviderAccountId,
};
use gateway_core::provider_ports::{
ProviderCooldown, ProviderCooldownPort, ProviderLeaseAcquisition, ProviderLeasePort,
ProviderLeaseRequest, ProviderSchedulingLeaseRequest,
ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope, ProviderLeaseAcquisition,
ProviderLeasePort, ProviderLeaseRequest, ProviderSchedulingLeaseRequest,
ProviderScopedCooldown,
};
use gateway_core::routing::ProviderKind;
use gateway_core::routing::{ProviderKind, UpstreamModelId};
use super::catalog::{GrokCatalogScope, GrokCredentialCatalogCache, GrokCredentialQuotaService};
use super::repository::GrokCredentialRepository;
@@ -28,6 +29,22 @@ const DEFAULT_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(60);
const MAX_RATE_LIMIT_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
const STREAM_INTERRUPTION_COOLDOWN: Duration = Duration::from_secs(30);
const UNAUTHORIZED_COOLDOWN: Duration = Duration::from_secs(60);
const PAYMENT_REQUIRED_COOLDOWN: Duration = Duration::from_secs(60);
const MODEL_ACCESS_DENIED_COOLDOWN: Duration = Duration::from_secs(5 * 60);
const MODEL_QUOTA_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
const MAX_MODEL_COOLDOWN: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimeCooldownScope {
Account,
Model,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RuntimeCooldown {
until: SystemTime,
scope: RuntimeCooldownScope,
}
/// 仅经 Core account port、TTL catalog cache 和 Redis lease 选择一个 OAuth session。
pub struct GrokAccountSessionSelector {
@@ -124,7 +141,8 @@ impl GrokAccountSessionSelector {
let runtime_cooldowns = if diagnostic {
std::collections::BTreeMap::new()
} else {
self.runtime_cooldowns(&catalog_eligible).await
self.runtime_cooldowns(&catalog_eligible, request.upstream_model())
.await
};
let mut candidates = catalog_eligible
.into_iter()
@@ -234,16 +252,26 @@ impl GrokAccountSessionSelector {
return Err(GrokSessionSelectorError::CapacityUnavailable { retry_after });
}
// 钉死账号时只看该账号,否则取最早退出 cooldown 的候选。
let cooled_until = match request.required_account() {
let cooled = match request.required_account() {
Some(required) => runtime_cooldowns.get(required).copied(),
None => runtime_cooldowns.values().copied().min(),
None => runtime_cooldowns
.values()
.copied()
.min_by_key(|item| item.until),
};
if let Some(until) = cooled_until {
return Err(GrokSessionSelectorError::AccountCoolingDown {
retry_after: until
.duration_since(SystemTime::now())
.ok()
.filter(|remaining| !remaining.is_zero()),
if let Some(cooled) = cooled {
let retry_after = cooled
.until
.duration_since(SystemTime::now())
.ok()
.filter(|remaining| !remaining.is_zero());
return Err(match cooled.scope {
RuntimeCooldownScope::Account => {
GrokSessionSelectorError::AccountCoolingDown { retry_after }
}
RuntimeCooldownScope::Model => {
GrokSessionSelectorError::ModelCoolingDown { retry_after }
}
});
}
Err(GrokSessionSelectorError::NoEligibleSession)
@@ -252,28 +280,56 @@ impl GrokAccountSessionSelector {
async fn runtime_cooldowns(
&self,
accounts: &[ProviderAccount],
) -> std::collections::BTreeMap<ProviderAccountId, SystemTime> {
upstream_model: &UpstreamModelId,
) -> std::collections::BTreeMap<ProviderAccountId, RuntimeCooldown> {
let now = SystemTime::now();
// 冷却状态并发批量读取,避免逐账号串行往返。
let reads = futures::future::join_all(
let model_scope = ProviderCooldownScope::upstream_model(upstream_model.clone());
// 账号级和当前模型级冷却都并发批量读取,避免逐账号串行往返。
let account_reads = futures::future::join_all(
accounts
.iter()
.map(|account| self.cooldowns.read(account.id())),
)
.await;
);
let model_reads = futures::future::join_all(
accounts
.iter()
.map(|account| self.cooldowns.read_scoped(account.id(), &model_scope)),
);
let (account_reads, model_reads) = futures::join!(account_reads, model_reads);
let mut cooled = std::collections::BTreeMap::new();
for (account, read) in accounts.iter().zip(reads) {
let Ok(Some(cooldown)) = read else {
continue;
};
if cooldown.credential_revision() != account.revision() {
if cooldown.credential_revision() < account.revision() {
let _ = self.cooldowns.clear(account.id(), account.revision()).await;
for ((account, account_read), model_read) in
accounts.iter().zip(account_reads).zip(model_reads)
{
if let Ok(Some(cooldown)) = account_read {
if cooldown.credential_revision() != account.revision() {
if cooldown.credential_revision() < account.revision() {
let _ = self.cooldowns.clear(account.id(), account.revision()).await;
}
} else if cooldown.until() > now {
insert_runtime_cooldown(
&mut cooled,
account.id(),
cooldown.until(),
RuntimeCooldownScope::Account,
);
}
continue;
}
if cooldown.until() > now {
cooled.insert(account.id().clone(), cooldown.until());
if let Ok(Some(cooldown)) = model_read {
if cooldown.credential_revision() != account.revision() {
if cooldown.credential_revision() < account.revision() {
let _ = self
.cooldowns
.clear_scoped(account.id(), &model_scope, account.revision())
.await;
}
} else if cooldown.until() > now {
insert_runtime_cooldown(
&mut cooled,
account.id(),
cooldown.until(),
RuntimeCooldownScope::Model,
);
}
}
}
cooled
@@ -292,6 +348,26 @@ impl GrokAccountSessionSelector {
))
.await;
}
async fn record_model_runtime_cooldown(
&self,
session: &SelectedGrokSession,
upstream_model: UpstreamModelId,
duration: Duration,
) {
let cooldown_until = SystemTime::now()
.checked_add(duration)
.unwrap_or_else(SystemTime::now);
let _ = self
.cooldowns
.put_scoped_if_later(ProviderScopedCooldown::new(
session.account_id().clone(),
session.credential_revision(),
ProviderCooldownScope::upstream_model(upstream_model),
cooldown_until,
))
.await;
}
}
impl fmt::Debug for GrokAccountSessionSelector {
@@ -333,6 +409,43 @@ impl GrokSessionSelector for GrokAccountSessionSelector {
"upstream_quota_exhausted",
None,
),
GrokCredentialFailure::FreeQuotaExhausted => (
GrokCredentialAvailability::QuotaExhausted,
"upstream_free_quota_exhausted",
None,
),
GrokCredentialFailure::PaymentRequired { retry_after } => {
let retry_after = bounded_cooldown(
retry_after,
PAYMENT_REQUIRED_COOLDOWN,
MAX_RATE_LIMIT_COOLDOWN,
);
self.record_runtime_cooldown(session, retry_after).await;
return;
}
GrokCredentialFailure::ModelQuotaExhausted {
upstream_model,
retry_after,
} => {
let retry_after =
bounded_cooldown(retry_after, MODEL_QUOTA_COOLDOWN, MAX_MODEL_COOLDOWN);
self.record_model_runtime_cooldown(session, upstream_model, retry_after)
.await;
return;
}
GrokCredentialFailure::ModelAccessDenied {
upstream_model,
retry_after,
} => {
let retry_after = bounded_cooldown(
retry_after,
MODEL_ACCESS_DENIED_COOLDOWN,
MAX_MODEL_COOLDOWN,
);
self.record_model_runtime_cooldown(session, upstream_model, retry_after)
.await;
return;
}
GrokCredentialFailure::RateLimited { retry_after } => {
let retry_after = retry_after
.unwrap_or(DEFAULT_RATE_LIMIT_COOLDOWN)
@@ -414,3 +527,29 @@ fn minimum_retry_after(left: Option<Duration>, right: Option<Duration>) -> Optio
(None, None) => None,
}
}
fn bounded_cooldown(
retry_after: Option<Duration>,
default: Duration,
maximum: Duration,
) -> Duration {
retry_after
.filter(|duration| !duration.is_zero())
.unwrap_or(default)
.min(maximum)
}
fn insert_runtime_cooldown(
cooldowns: &mut std::collections::BTreeMap<ProviderAccountId, RuntimeCooldown>,
account_id: &ProviderAccountId,
until: SystemTime,
scope: RuntimeCooldownScope,
) {
let replacement = RuntimeCooldown { until, scope };
if cooldowns
.get(account_id)
.is_none_or(|current| current.until < until)
{
cooldowns.insert(account_id.clone(), replacement);
}
}
+140 -24
View File
@@ -927,6 +927,7 @@ async fn start_grok_inference(
transport: &dyn GrokInferenceTransport,
credential_recovery: &dyn GrokCredentialRecovery,
request: GrokInferenceRequest,
upstream_model: &UpstreamModelId,
context: &AttemptContext,
session: &SelectedGrokSession,
) -> Result<AcceptedGrokInference, GrokInferenceStartFailure> {
@@ -965,6 +966,7 @@ async fn start_grok_inference(
InferenceBoundary::Response(Ok(response)) => response,
InferenceBoundary::Response(Err(error)) => {
let observation = xai_error_observation(&error).ok();
let credential_failure = transport_credential_failure(&error, upstream_model);
let error =
map_continuation_failure(context, map_transport_error_for_context(error, context));
let error = recover_or_record_failure(
@@ -972,6 +974,7 @@ async fn start_grok_inference(
credential_recovery,
session,
error,
credential_failure,
context.credential_recovery_attempted(),
)
.await;
@@ -993,6 +996,7 @@ async fn next_grok_chunk(
body: &mut GrokInferenceChunkStream,
selector: &dyn GrokSessionSelector,
session: &SelectedGrokSession,
upstream_model: &UpstreamModelId,
context: &AttemptContext,
) -> Result<Option<bytes::Bytes>, ProviderError> {
let Some(stream_deadline) = remaining(context.deadline()) else {
@@ -1014,10 +1018,12 @@ async fn next_grok_chunk(
)),
chunk = body.next() => match chunk {
Some(Ok(chunk)) => Ok(Some(chunk)),
Some(Err(error)) => {
let error = map_stream_error(error);
Err(record_stream_failure(selector, session, error).await)
}
Some(Err(error)) => Err(map_and_record_stream_transport_failure(
selector,
session,
error,
upstream_model,
).await),
None => Ok(None),
},
}
@@ -1067,6 +1073,7 @@ fn cold_compaction_http_sse_stream(
transport.as_ref(),
credential_recovery.as_ref(),
inference_request,
&upstream_model,
&context,
&session,
)
@@ -1092,6 +1099,7 @@ fn cold_compaction_http_sse_stream(
&mut body,
selector.as_ref(),
&session,
&upstream_model,
&context,
)
.await
@@ -1287,6 +1295,7 @@ fn cold_http_sse_stream(
InferenceBoundary::Response(Ok(response)) => response,
InferenceBoundary::Response(Err(error)) => {
let observation = xai_error_observation(&error)?;
let credential_failure = transport_credential_failure(&error, &upstream_model);
let error =
map_continuation_failure(&context, map_transport_error_for_context(error, &context));
let error = recover_or_record_failure(
@@ -1294,6 +1303,7 @@ fn cold_http_sse_stream(
credential_recovery.as_ref(),
&session,
error,
credential_failure,
context.credential_recovery_attempted(),
)
.await;
@@ -1328,10 +1338,12 @@ fn cold_http_sse_stream(
)),
chunk = body.next() => match chunk {
Some(Ok(chunk)) => Ok(Some(chunk)),
Some(Err(error)) => {
let error = map_stream_error(error);
Err(record_stream_failure(selector.as_ref(), &session, error).await)
},
Some(Err(error)) => Err(map_and_record_stream_transport_failure(
selector.as_ref(),
&session,
error,
&upstream_model,
).await),
None => Ok(None),
},
}?;
@@ -1495,6 +1507,18 @@ async fn record_failure(
error
}
async fn record_credential_failure(
selector: &dyn GrokSessionSelector,
session: &SelectedGrokSession,
error: ProviderError,
failure: GrokCredentialFailure,
) -> ProviderError {
if session.allows_account_state_mutation() {
selector.record_failure(session, failure).await;
}
error
}
async fn record_stream_failure(
selector: &dyn GrokSessionSelector,
session: &SelectedGrokSession,
@@ -1524,6 +1548,7 @@ async fn recover_or_record_failure(
recovery: &dyn GrokCredentialRecovery,
session: &SelectedGrokSession,
error: ProviderError,
credential_failure: Option<GrokCredentialFailure>,
recovery_attempted: bool,
) -> ProviderError {
if !session.allows_account_state_mutation() {
@@ -1536,12 +1561,68 @@ async fn recover_or_record_failure(
{
GrokCredentialRecoveryOutcome::Recovered => error.with_same_account_retry(),
GrokCredentialRecoveryOutcome::Rejected => error,
GrokCredentialRecoveryOutcome::Unavailable => {
record_failure(selector, session, error).await
}
GrokCredentialRecoveryOutcome::Unavailable => match credential_failure {
Some(failure) => record_credential_failure(selector, session, error, failure).await,
None => error,
},
};
}
record_failure(selector, session, error).await
match credential_failure {
Some(failure) => record_credential_failure(selector, session, error, failure).await,
None => error,
}
}
fn transport_credential_failure(
error: &GrokInferenceTransportError,
upstream_model: &UpstreamModelId,
) -> Option<GrokCredentialFailure> {
match error.kind() {
GrokInferenceTransportErrorKind::Unauthorized => Some(GrokCredentialFailure::Unauthorized),
GrokInferenceTransportErrorKind::RateLimited => Some(GrokCredentialFailure::RateLimited {
retry_after: error.retry_after(),
}),
GrokInferenceTransportErrorKind::QuotaExhausted => {
Some(GrokCredentialFailure::QuotaExhausted)
}
GrokInferenceTransportErrorKind::FreeQuotaExhausted => {
Some(GrokCredentialFailure::FreeQuotaExhausted)
}
GrokInferenceTransportErrorKind::PaymentRequired => {
Some(GrokCredentialFailure::PaymentRequired {
retry_after: error.retry_after(),
})
}
GrokInferenceTransportErrorKind::ModelQuotaExhausted => {
Some(GrokCredentialFailure::ModelQuotaExhausted {
upstream_model: upstream_model.clone(),
retry_after: error.retry_after(),
})
}
GrokInferenceTransportErrorKind::ModelAccessDenied => {
Some(GrokCredentialFailure::ModelAccessDenied {
upstream_model: upstream_model.clone(),
retry_after: error.retry_after(),
})
}
_ => None,
}
}
async fn map_and_record_stream_transport_failure(
selector: &dyn GrokSessionSelector,
session: &SelectedGrokSession,
error: GrokInferenceTransportError,
upstream_model: &UpstreamModelId,
) -> ProviderError {
let request_scoped = error.kind() == GrokInferenceTransportErrorKind::SafetyRejected;
let credential_failure = transport_credential_failure(&error, upstream_model);
let error = map_stream_error(error);
match credential_failure {
Some(failure) => record_credential_failure(selector, session, error, failure).await,
None if request_scoped => error,
None => record_stream_failure(selector, session, error).await,
}
}
fn map_continuation_failure(context: &AttemptContext, error: ProviderError) -> ProviderError {
@@ -1628,6 +1709,11 @@ fn map_selection_error(error: GrokSessionSelectorError) -> ProviderError {
cooling_down_message(retry_after),
"account_cooling_down",
),
GrokSessionSelectorError::ModelCoolingDown { retry_after } => (
retry_after,
model_cooling_down_message(retry_after),
"model_cooling_down",
),
GrokSessionSelectorError::CapacityUnavailable { retry_after } => (
retry_after,
"account is at its concurrency or request-interval limit".to_owned(),
@@ -1665,10 +1751,20 @@ fn map_selection_error(error: GrokSessionSelectorError) -> ProviderError {
fn cooling_down_message(retry_after: Option<Duration>) -> String {
match retry_after {
Some(retry_after) => format!(
"account is cooling down after an interrupted upstream stream; retry in {}s",
"account is cooling down after an upstream failure; retry in {}s",
retry_after.as_secs().saturating_add(1)
),
None => "account is cooling down after an interrupted upstream stream".to_owned(),
None => "account is cooling down after an upstream failure".to_owned(),
}
}
fn model_cooling_down_message(retry_after: Option<Duration>) -> String {
match retry_after {
Some(retry_after) => format!(
"all eligible accounts are cooling down for this model; retry in {}s",
retry_after.as_secs().saturating_add(1)
),
None => "all eligible accounts are cooling down for this model".to_owned(),
}
}
@@ -1690,13 +1786,19 @@ fn map_transport_error_with_state(
forced_send_state: Option<UpstreamSendState>,
allow_explicit_replay: bool,
) -> ProviderError {
let kind = match error.kind() {
let transport_kind = error.kind();
let kind = match transport_kind {
GrokInferenceTransportErrorKind::InvalidRequest => ProviderErrorKind::InvalidRequest,
GrokInferenceTransportErrorKind::Unsupported => ProviderErrorKind::Unsupported,
GrokInferenceTransportErrorKind::Unauthorized => ProviderErrorKind::Unauthorized,
GrokInferenceTransportErrorKind::PermissionDenied => ProviderErrorKind::PermissionDenied,
GrokInferenceTransportErrorKind::PermissionDenied
| GrokInferenceTransportErrorKind::ModelAccessDenied
| GrokInferenceTransportErrorKind::PaymentRequired
| GrokInferenceTransportErrorKind::SafetyRejected => ProviderErrorKind::PermissionDenied,
GrokInferenceTransportErrorKind::RateLimited => ProviderErrorKind::RateLimited,
GrokInferenceTransportErrorKind::QuotaExhausted => ProviderErrorKind::QuotaExhausted,
GrokInferenceTransportErrorKind::QuotaExhausted
| GrokInferenceTransportErrorKind::FreeQuotaExhausted
| GrokInferenceTransportErrorKind::ModelQuotaExhausted => ProviderErrorKind::QuotaExhausted,
GrokInferenceTransportErrorKind::Timeout => ProviderErrorKind::Timeout,
GrokInferenceTransportErrorKind::Transport => ProviderErrorKind::Transport,
GrokInferenceTransportErrorKind::Protocol => ProviderErrorKind::Protocol,
@@ -1711,7 +1813,7 @@ fn map_transport_error_with_state(
mapped = mapped.with_status(status);
if allow_explicit_replay
&& forced_send_state.is_none()
&& explicit_rejection_is_replay_safe(kind, status)
&& explicit_rejection_is_replay_safe(transport_kind, status)
{
mapped = mapped.with_replay_safe();
}
@@ -1725,6 +1827,9 @@ fn map_transport_error_with_state(
if let Some(code) = error.upstream_code().cloned() {
mapped = mapped.with_upstream_code(code);
}
if let Some(detail) = error.client_visible_upstream_error().cloned() {
mapped = mapped.with_client_visible_upstream_error(detail);
}
if error.requires_credential_recovery() {
mapped = mapped.with_credential_recovery().with_replay_safe();
}
@@ -1734,14 +1839,25 @@ fn map_transport_error_with_state(
mapped
}
fn explicit_rejection_is_replay_safe(kind: ProviderErrorKind, status: u16) -> bool {
fn explicit_rejection_is_replay_safe(kind: GrokInferenceTransportErrorKind, status: u16) -> bool {
matches!(
(kind, status),
(ProviderErrorKind::Unauthorized, 401)
| (ProviderErrorKind::QuotaExhausted, 402)
| (ProviderErrorKind::QuotaExhausted, 403)
| (ProviderErrorKind::QuotaExhausted, 429)
| (ProviderErrorKind::RateLimited, 429)
(GrokInferenceTransportErrorKind::Unauthorized, 401)
| (
GrokInferenceTransportErrorKind::QuotaExhausted,
402 | 403 | 429
)
| (
GrokInferenceTransportErrorKind::FreeQuotaExhausted,
402 | 403 | 429
)
| (
GrokInferenceTransportErrorKind::ModelQuotaExhausted,
402 | 403 | 429
)
| (GrokInferenceTransportErrorKind::PaymentRequired, 402)
| (GrokInferenceTransportErrorKind::ModelAccessDenied, 403)
| (GrokInferenceTransportErrorKind::RateLimited, 429)
)
}
@@ -19,6 +19,7 @@ use gateway_protocol::openai::sse::{SseEvent, SseEventDecoder};
use serde_json::Value;
use super::request::{GrokResponseTransform, GrokResponsesRequest};
use super::scrub_account_fingerprints;
const CONTENTS_PER_OUTPUT: u32 = 1_024;
const LONG_CONTEXT_THRESHOLD: u64 = 200_000;
@@ -820,53 +821,6 @@ fn upstream_error_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
.and_then(Value::as_str)
}
const UUID_TEXT_LEN: usize = 36;
/// 把 message 中的 UUID 替换为占位符,控制字符归一为空格,
/// 使文本满足客户端可见错误的约束且不携带可定位账号的标识。
fn scrub_account_fingerprints(message: &str) -> String {
let bytes = message.as_bytes();
let mut scrubbed = String::with_capacity(message.len());
let mut index = 0;
while index < message.len() {
if uuid_at(bytes, index) {
scrubbed.push_str("[redacted]");
index += UUID_TEXT_LEN;
continue;
}
let character = message[index..].chars().next().expect("char boundary");
scrubbed.push(if character.is_control() {
' '
} else {
character
});
index += character.len_utf8();
}
scrubbed
}
fn uuid_at(bytes: &[u8], index: usize) -> bool {
let Some(candidate) = bytes.get(index..index + UUID_TEXT_LEN) else {
return false;
};
if index > 0 && bytes[index - 1].is_ascii_alphanumeric() {
return false;
}
if bytes
.get(index + UUID_TEXT_LEN)
.is_some_and(u8::is_ascii_alphanumeric)
{
return false;
}
candidate.iter().enumerate().all(|(offset, byte)| {
if matches!(offset, 8 | 13 | 18 | 23) {
*byte == b'-'
} else {
byte.is_ascii_hexdigit()
}
})
}
fn protocol_error(_error: impl std::fmt::Debug) -> ProviderError {
protocol_error_marker()
}
@@ -54,3 +54,50 @@ pub use transport::{
GrokInferenceTransportError, GrokInferenceTransportErrorKind, GrokInferenceTransportFuture,
GrokInferenceTransportMetrics,
};
const UUID_TEXT_LEN: usize = 36;
/// 把 message 中的 UUID 替换为占位符,控制字符归一为空格,
/// 使文本满足客户端可见错误的约束且不携带可定位账号的标识。
fn scrub_account_fingerprints(message: &str) -> String {
let bytes = message.as_bytes();
let mut scrubbed = String::with_capacity(message.len());
let mut index = 0;
while index < message.len() {
if uuid_at(bytes, index) {
scrubbed.push_str("[redacted]");
index += UUID_TEXT_LEN;
continue;
}
let character = message[index..].chars().next().expect("char boundary");
scrubbed.push(if character.is_control() {
' '
} else {
character
});
index += character.len_utf8();
}
scrubbed
}
fn uuid_at(bytes: &[u8], index: usize) -> bool {
let Some(candidate) = bytes.get(index..index + UUID_TEXT_LEN) else {
return false;
};
if index > 0 && bytes[index - 1].is_ascii_alphanumeric() {
return false;
}
if bytes
.get(index + UUID_TEXT_LEN)
.is_some_and(u8::is_ascii_alphanumeric)
{
return false;
}
candidate.iter().enumerate().all(|(offset, byte)| {
if matches!(offset, 8 | 13 | 18 | 23) {
*byte == b'-'
} else {
byte.is_ascii_hexdigit()
}
})
}
@@ -30,9 +30,11 @@ use crate::{
OAuthHttpResponse, OAuthHttpTransport, TransportFailure, TransportFailureKind, TransportFuture,
};
use gateway_core::engine::UpstreamSendState;
use gateway_core::error::SafeUpstreamValue;
use gateway_core::error::{ClientVisibleUpstreamError, SafeUpstreamValue};
use gateway_core::event::UpstreamHttpVersion;
use super::scrub_account_fingerprints;
pub(crate) const OFFICIAL_OAUTH_HOST: &str = "auth.x.ai";
const OFFICIAL_INFERENCE_HOST: &str = "cli-chat-proxy.grok.com";
const OFFICIAL_INFERENCE_PATH: &str = "/v1/responses";
@@ -990,29 +992,22 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
Ok(BoundedBody::TooLarge) | Err(_) => Vec::new(),
};
let metadata = inference_error_metadata(&body);
let credential_recovery_required = status == StatusCode::UNAUTHORIZED
|| (status == StatusCode::FORBIDDEN && forbidden_requires_recovery(&metadata));
let kind = match status {
StatusCode::BAD_REQUEST
| StatusCode::NOT_FOUND
| StatusCode::CONFLICT
| StatusCode::UNPROCESSABLE_ENTITY => GrokInferenceTransportErrorKind::InvalidRequest,
StatusCode::UNAUTHORIZED => GrokInferenceTransportErrorKind::Unauthorized,
StatusCode::PAYMENT_REQUIRED => GrokInferenceTransportErrorKind::QuotaExhausted,
StatusCode::FORBIDDEN => classify_forbidden(&metadata),
StatusCode::PAYMENT_REQUIRED => classify_payment_required(&metadata),
StatusCode::FORBIDDEN => classify_forbidden(&metadata, &body),
StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => {
GrokInferenceTransportErrorKind::Timeout
}
StatusCode::TOO_MANY_REQUESTS => {
if quota_exhausted(&metadata) {
GrokInferenceTransportErrorKind::QuotaExhausted
} else {
GrokInferenceTransportErrorKind::RateLimited
}
}
StatusCode::TOO_MANY_REQUESTS => classify_too_many_requests(&metadata),
status if status.is_server_error() => GrokInferenceTransportErrorKind::Unavailable,
_ => GrokInferenceTransportErrorKind::Protocol,
};
let credential_recovery_required = kind == GrokInferenceTransportErrorKind::Unauthorized;
let mut error = GrokInferenceTransportError::new(kind, UpstreamSendState::Sent)
.with_status(status_code)
.with_response_facts(http_version, request_id)
@@ -1022,6 +1017,15 @@ async fn classify_inference_status(response: Response) -> GrokInferenceTransport
} else {
metadata.code.as_deref().and_then(normalize_failure_code)
};
if let Some(message) = metadata.client_message.as_deref()
&& let Ok(detail) = ClientVisibleUpstreamError::new(
scrub_account_fingerprints(message),
upstream_code.clone(),
metadata.error_type.clone(),
)
{
error = error.with_client_visible_upstream_error(detail);
}
if let Some(code) = upstream_code.and_then(|code| SafeUpstreamValue::new(code).ok()) {
error = error.with_upstream_code(code);
}
@@ -1039,6 +1043,8 @@ struct InferenceErrorMetadata {
code: Option<String>,
error_type: Option<String>,
message: Option<String>,
// 仅结构化 JSON 的 message 可进入客户端协议;纯文本正文只参与内部分类。
client_message: Option<String>,
}
fn inference_error_metadata(body: &[u8]) -> InferenceErrorMetadata {
@@ -1053,6 +1059,9 @@ fn inference_error_metadata(body: &[u8]) -> InferenceErrorMetadata {
return InferenceErrorMetadata::default();
};
let nested = root.get("error").and_then(Value::as_object);
let message = nested
.and_then(|error| first_string(error, &["message", "error"]))
.or_else(|| first_string(root, &["message", "error"]));
InferenceErrorMetadata {
code: nested
.and_then(|error| first_string(error, &["code", "error_code"]))
@@ -1060,9 +1069,8 @@ fn inference_error_metadata(body: &[u8]) -> InferenceErrorMetadata {
error_type: nested
.and_then(|error| first_string(error, &["type", "error_type"]))
.or_else(|| first_string(root, &["type", "error_type"])),
message: nested
.and_then(|error| first_string(error, &["message", "error"]))
.or_else(|| first_string(root, &["message", "error"])),
client_message: message.clone(),
message,
}
}
@@ -1075,22 +1083,53 @@ fn first_string(object: &serde_json::Map<String, Value>, fields: &[&str]) -> Opt
.map(ToOwned::to_owned)
}
fn classify_forbidden(metadata: &InferenceErrorMetadata) -> GrokInferenceTransportErrorKind {
let text = inference_metadata_text(metadata);
if credential_rejected(&text) {
GrokInferenceTransportErrorKind::Unauthorized
} else if quota_exhausted(metadata) {
fn classify_payment_required(metadata: &InferenceErrorMetadata) -> GrokInferenceTransportErrorKind {
if model_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::ModelQuotaExhausted
} else if paid_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::QuotaExhausted
} else if free_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::FreeQuotaExhausted
} else {
GrokInferenceTransportErrorKind::PaymentRequired
}
}
fn classify_forbidden(
metadata: &InferenceErrorMetadata,
body: &[u8],
) -> GrokInferenceTransportErrorKind {
let text = inference_metadata_text(metadata);
let body_text = String::from_utf8_lossy(body).to_ascii_lowercase();
if safety_rejected(&text) || safety_rejected(&body_text) {
GrokInferenceTransportErrorKind::SafetyRejected
} else if credential_rejected(&text) {
GrokInferenceTransportErrorKind::Unauthorized
} else if model_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::ModelQuotaExhausted
} else if paid_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::QuotaExhausted
} else if free_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::FreeQuotaExhausted
} else if model_access_denied(metadata) {
GrokInferenceTransportErrorKind::ModelAccessDenied
} else {
GrokInferenceTransportErrorKind::PermissionDenied
}
}
fn forbidden_requires_recovery(metadata: &InferenceErrorMetadata) -> bool {
let text = inference_metadata_text(metadata);
credential_rejected(&text)
|| text.contains("access to the chat endpoint is denied")
|| text.trim_matches([' ', '.', '!', '\t', '\r', '\n']) == "access denied"
fn classify_too_many_requests(
metadata: &InferenceErrorMetadata,
) -> GrokInferenceTransportErrorKind {
if model_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::ModelQuotaExhausted
} else if paid_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::QuotaExhausted
} else if free_quota_exhausted(metadata) {
GrokInferenceTransportErrorKind::FreeQuotaExhausted
} else {
GrokInferenceTransportErrorKind::RateLimited
}
}
fn reasoning_decode_failed(metadata: &InferenceErrorMetadata) -> bool {
@@ -1100,7 +1139,17 @@ fn reasoning_decode_failed(metadata: &InferenceErrorMetadata) -> bool {
})
}
fn quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
fn paid_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
contains_any(
&inference_metadata_text(metadata),
&[
"personal-team-blocked:spending-limit",
"personal_team_blocked_spending_limit",
],
)
}
fn free_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
contains_any(
&inference_metadata_text(metadata),
&[
@@ -1110,12 +1159,29 @@ fn quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
"free_usage_exhausted",
"used all the included free usage",
"used all your free usage",
"personal-team-blocked:spending-limit",
"personal_team_blocked_spending_limit",
],
)
}
fn model_quota_exhausted(metadata: &InferenceErrorMetadata) -> bool {
inference_metadata_text(metadata).contains("used all the included free usage for model")
}
fn model_access_denied(metadata: &InferenceErrorMetadata) -> bool {
metadata.message.as_deref().is_some_and(|message| {
let message = message.to_ascii_lowercase();
message.contains("access to the chat endpoint is denied")
|| message.trim_matches([' ', '.', '!', '\t', '\r', '\n']) == "access denied"
})
}
fn safety_rejected(text: &str) -> bool {
contains_any(
text,
&["content violates usage guidelines", "safety_check_type_"],
)
}
fn inference_metadata_text(metadata: &InferenceErrorMetadata) -> String {
[
metadata.code.as_deref().unwrap_or_default(),
@@ -300,8 +300,8 @@ pub type GrokSessionSelectorFuture<'a> = Pin<
Box<dyn Future<Output = Result<SelectedGrokSession, GrokSessionSelectorError>> + Send + 'a>,
>;
/// 凭据维度的上游失败,可安全落库为可用性反馈。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// 已脱敏的上游失败反馈;selector 决定账号持久状态或细粒度运行时 cooldown。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrokCredentialFailure {
/// 选中的 OAuth token 被拒绝。
Unauthorized,
@@ -310,8 +310,25 @@ pub enum GrokCredentialFailure {
/// 已解析并脱敏的延迟;不保留上游原始 header。
retry_after: Option<Duration>,
},
/// 选中的 OAuth 账号配额耗尽。
/// 选中的 OAuth 账号付费额度耗尽。
QuotaExhausted,
/// 选中的 OAuth 账号订阅级免费额度耗尽。
FreeQuotaExhausted,
/// HTTP 402 没有给出可证明账号额度耗尽的稳定信号。
PaymentRequired {
/// 已解析并脱敏的延迟;缺失时由 selector 使用短暂默认值。
retry_after: Option<Duration>,
},
/// 选中账号只耗尽了指定模型的额度。
ModelQuotaExhausted {
upstream_model: UpstreamModelId,
retry_after: Option<Duration>,
},
/// 选中账号只缺少指定模型的访问权限。
ModelAccessDenied {
upstream_model: UpstreamModelId,
retry_after: Option<Duration>,
},
/// 成功的 SSE 响应在终止 Responses 事件前结束。
StreamInterrupted,
}
@@ -325,7 +342,7 @@ pub trait GrokSessionSelector: Send + Sync {
/// 执行一次选择,不做 Provider 内部回退。
fn select(&self, request: GrokSessionSelection) -> GrokSessionSelectorFuture<'_>;
/// 持久化一条已分类的失败,不重试也不替换原错误。
/// 记录一条已分类的失败反馈,不重试也不替换原错误。
fn record_failure<'a>(
&'a self,
session: &'a SelectedGrokSession,
@@ -361,6 +378,12 @@ pub enum GrokSessionSelectorError {
/// 最早恢复的会话剩余冷却时长。
retry_after: Option<Duration>,
},
/// 每个可用会话都只针对当前模型处于运行时冷却窗口内。
#[error("Grok Build model is cooling down for the available accounts")]
ModelCoolingDown {
/// 最早恢复的账号+模型组合剩余冷却时长。
retry_after: Option<Duration>,
},
/// 会话元数据或 Provider 持有的明文密钥非法。
#[error("Grok Build session data is invalid")]
InvalidSession,
@@ -5,7 +5,7 @@ use std::time::Duration;
use futures::Stream;
use gateway_core::engine::UpstreamSendState;
use gateway_core::error::SafeUpstreamValue;
use gateway_core::error::{ClientVisibleUpstreamError, SafeUpstreamValue};
use gateway_core::event::UpstreamHttpVersion;
use url::Url;
use zeroize::Zeroizing;
@@ -278,8 +278,18 @@ pub enum GrokInferenceTransportErrorKind {
PermissionDenied,
/// 会话级或 Provider 级限流。
RateLimited,
/// 账号额度或配额耗尽。
/// 账号付费额度或 spending limit 耗尽。
QuotaExhausted,
/// 账号订阅级免费额度耗尽。
FreeQuotaExhausted,
/// 当前账号只耗尽了指定模型的免费额度。
ModelQuotaExhausted,
/// 当前账号只缺少指定模型的访问权限。
ModelAccessDenied,
/// 上游要求付费,但没有给出可证明账号额度耗尽的稳定信号。
PaymentRequired,
/// 当前请求被内容安全策略拒绝,不代表账号或模型不可用。
SafetyRejected,
/// 截止时间或 transport 超时。
Timeout,
/// 网络/TLS/连接失败。
@@ -301,7 +311,8 @@ pub struct GrokInferenceTransportError {
retry_after: Option<Duration>,
http_version: Option<UpstreamHttpVersion>,
request_id: Option<SafeUpstreamValue>,
upstream_code: Option<SafeUpstreamValue>,
upstream_code: Option<Box<SafeUpstreamValue>>,
client_visible_upstream_error: Option<Box<ClientVisibleUpstreamError>>,
transport_metrics: GrokInferenceTransportMetrics,
credential_recovery_required: bool,
sensitive_context_redacted: bool,
@@ -319,6 +330,7 @@ impl GrokInferenceTransportError {
http_version: None,
request_id: None,
upstream_code: None,
client_visible_upstream_error: None,
transport_metrics: GrokInferenceTransportMetrics {
headers_ms: None,
client_cache_status: None,
@@ -359,7 +371,14 @@ impl GrokInferenceTransportError {
/// 附着从错误 JSON 中提取并清洗后的稳定机器码。
#[must_use]
pub fn with_upstream_code(mut self, code: SafeUpstreamValue) -> Self {
self.upstream_code = Some(code);
self.upstream_code = Some(Box::new(code));
self
}
/// 附着仅供原客户端协议展示的结构化上游错误。
#[must_use]
pub fn with_client_visible_upstream_error(mut self, error: ClientVisibleUpstreamError) -> Self {
self.client_visible_upstream_error = Some(Box::new(error));
self
}
@@ -420,8 +439,13 @@ impl GrokInferenceTransportError {
}
#[must_use]
pub const fn upstream_code(&self) -> Option<&SafeUpstreamValue> {
self.upstream_code.as_ref()
pub fn upstream_code(&self) -> Option<&SafeUpstreamValue> {
self.upstream_code.as_deref()
}
#[must_use]
pub fn client_visible_upstream_error(&self) -> Option<&ClientVisibleUpstreamError> {
self.client_visible_upstream_error.as_deref()
}
#[must_use]
@@ -452,6 +476,13 @@ impl fmt::Debug for GrokInferenceTransportError {
.field("http_version", &self.http_version)
.field("request_id", &self.request_id)
.field("upstream_code", &self.upstream_code)
.field(
"client_visible_upstream_error",
&self
.client_visible_upstream_error
.as_ref()
.map(|_| "<present>"),
)
.field("transport_metrics", &self.transport_metrics)
.field(
"credential_recovery_required",
+27 -3
View File
@@ -33,9 +33,9 @@ use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
ProviderCatalogCacheKey, ProviderCatalogCachePort, ProviderCooldown, ProviderCooldownPort,
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
ProviderStoreError, ProviderStorePorts,
ProviderCooldownScope, ProviderCredentialState, ProviderCredentialStatePort,
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
};
use gateway_core::routing::{
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, PublicModelId, RoutingContext,
@@ -796,6 +796,30 @@ impl ProviderCooldownPort for TestCooldown {
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
Box::pin(async { Ok(false) })
}
fn put_scoped_if_later(
&self,
_cooldown: ProviderScopedCooldown,
) -> BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async { Ok(false) })
}
fn read_scoped<'a>(
&'a self,
_account_id: &'a ProviderAccountId,
_scope: &'a ProviderCooldownScope,
) -> BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>> {
Box::pin(async { Ok(None) })
}
fn clear_scoped<'a>(
&'a self,
_account_id: &'a ProviderAccountId,
_scope: &'a ProviderCooldownScope,
_through_revision: CredentialRevision,
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
Box::pin(async { Ok(false) })
}
}
struct TestRuntimePolicy;
@@ -10,9 +10,10 @@ use gateway_core::engine::credential::{
QuotaWriteOutcome, RotationStrategy,
};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderSchedulingState,
ProviderStoreError,
ProviderCooldownScope, ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
ProviderSchedulingState, ProviderStoreError,
};
use gateway_core::routing::UpstreamModelId;
use provider_xai::{
GrokAccountSessionSelector, GrokBillingRequest, GrokBillingTransport,
GrokBillingTransportError, GrokBillingTransportErrorKind, GrokBillingTransportFuture,
@@ -119,7 +120,8 @@ impl SelectorFixture {
.replace(GrokPlanCatalog::new(
GrokCatalogScope::for_account(&account).expect("catalog scope"),
Utc::now(),
GrokCredentialCatalogSeed::new(["grok-4.5"], None).expect("catalog"),
GrokCredentialCatalogSeed::new(["grok-4.5", "grok-4.6"], None)
.expect("catalog"),
))
.await
.expect("cache catalog");
@@ -183,9 +185,32 @@ impl SelectorFixture {
excluded: BTreeSet<ProviderAccountId>,
required_account: Option<ProviderAccountId>,
strategy: RotationStrategy,
) -> GrokSessionSelection {
self.request_for_model_with_policy("grok-4.5", excluded, required_account, strategy)
}
fn request_for_model(
&self,
upstream_model: &str,
required_account: Option<ProviderAccountId>,
) -> GrokSessionSelection {
self.request_for_model_with_policy(
upstream_model,
BTreeSet::new(),
required_account,
RotationStrategy::Smart,
)
}
fn request_for_model_with_policy(
&self,
upstream_model: &str,
excluded: BTreeSet<ProviderAccountId>,
required_account: Option<ProviderAccountId>,
strategy: RotationStrategy,
) -> GrokSessionSelection {
GrokSessionSelection::new(
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
UpstreamModelId::new(upstream_model).expect("model"),
excluded,
required_account,
AccountSelectionPolicy::new(
@@ -387,6 +412,131 @@ async fn rate_limit_feedback_records_runtime_cooldown_without_persisting_account
assert_ne!(next.account_id(), &selected);
}
#[tokio::test]
async fn unknown_payment_required_feedback_only_records_runtime_account_cooldown() {
let fixture = SelectorFixture::new(&["payment-required", "available"]).await;
let session = fixture
.selector
.select(fixture.request(BTreeSet::new()))
.await
.expect("session");
let selected = session.account_id().clone();
fixture
.selector
.record_failure(
&session,
GrokCredentialFailure::PaymentRequired {
retry_after: Some(Duration::from_secs(5)),
},
)
.await;
assert_eq!(
fixture
.store
.account(&selected)
.expect("selected account")
.availability(),
AccountAvailability::Ready
);
assert!(fixture.cooldowns.cooldown(&selected).is_some());
let next = fixture
.selector
.select(fixture.request(BTreeSet::new()))
.await
.expect("another account remains available");
assert_ne!(next.account_id(), &selected);
}
#[tokio::test]
async fn model_quota_feedback_blocks_only_the_failed_account_and_model() {
let fixture = SelectorFixture::new(&["model-quota", "available"]).await;
let failed_model = UpstreamModelId::new("grok-4.5").expect("failed model");
let session = fixture
.selector
.select(fixture.request_for_model(failed_model.as_str(), None))
.await
.expect("session");
let selected = session.account_id().clone();
fixture
.selector
.record_failure(
&session,
GrokCredentialFailure::ModelQuotaExhausted {
upstream_model: failed_model.clone(),
retry_after: Some(Duration::from_secs(5)),
},
)
.await;
assert_eq!(
fixture
.store
.account(&selected)
.expect("selected account")
.availability(),
AccountAvailability::Ready
);
let scope = ProviderCooldownScope::upstream_model(failed_model);
assert!(
fixture
.cooldowns
.scoped_cooldown(&selected, &scope)
.is_some()
);
let unrelated_model = fixture
.selector
.select(fixture.request_for_model("grok-4.6", Some(selected.clone())))
.await
.expect("same account remains valid for another model");
assert_eq!(unrelated_model.account_id(), &selected);
let failed_model_retry = fixture
.selector
.select(fixture.request_for_model("grok-4.5", None))
.await
.expect("another account serves the failed model");
assert_ne!(failed_model_retry.account_id(), &selected);
}
#[tokio::test]
async fn model_access_feedback_reports_model_cooldown_without_blocking_the_account() {
let fixture = SelectorFixture::new(&["model-access"]).await;
let model = UpstreamModelId::new("grok-4.5").expect("model");
let session = fixture
.selector
.select(fixture.request_for_model(model.as_str(), None))
.await
.expect("session");
fixture
.selector
.record_failure(
&session,
GrokCredentialFailure::ModelAccessDenied {
upstream_model: model,
retry_after: None,
},
)
.await;
assert_eq!(
fixture
.store
.account(session.account_id())
.expect("account")
.availability(),
AccountAvailability::Ready
);
assert!(matches!(
fixture
.selector
.select(fixture.request_for_model("grok-4.5", None))
.await,
Err(GrokSessionSelectorError::ModelCoolingDown {
retry_after: Some(_)
})
));
}
#[tokio::test]
async fn interrupted_stream_feedback_records_runtime_cooldown_without_persisting_account_state() {
let fixture = SelectorFixture::new(&["stream-interrupted"]).await;
@@ -18,7 +18,8 @@ use gateway_core::engine::{
UpstreamSendState,
};
use gateway_core::error::{
ContinuationFailure, ProviderError, ProviderErrorKind, SafeUpstreamValue,
ClientVisibleUpstreamError, ContinuationFailure, ProviderError, ProviderErrorKind,
SafeUpstreamValue,
};
use gateway_core::event::{GatewayEvent, UpstreamHttpVersion};
use gateway_core::operation::{
@@ -1295,6 +1296,157 @@ async fn explicit_quota_http_429_marks_provider_error_replay_safe() {
assert!(error.replay_is_safe());
}
#[tokio::test]
async fn unknown_http_402_is_retryable_without_account_quota_feedback() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::PaymentRequired,
UpstreamSendState::Sent,
)
.with_status(402),
);
let provider = provider(selector.clone(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("stream");
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::PermissionDenied);
assert!(error.replay_is_safe());
assert_eq!(
selector.feedback.lock().expect("feedback").as_slice(),
&[GrokCredentialFailure::PaymentRequired { retry_after: None }]
);
}
#[tokio::test]
async fn model_quota_transport_failure_preserves_model_scoped_feedback() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::ModelQuotaExhausted,
UpstreamSendState::Sent,
)
.with_status(403),
);
let provider = provider(selector.clone(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("stream");
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::QuotaExhausted);
assert!(error.replay_is_safe());
assert_eq!(
selector.feedback.lock().expect("feedback").as_slice(),
&[GrokCredentialFailure::ModelQuotaExhausted {
upstream_model: UpstreamModelId::new("grok-4.5").expect("model"),
retry_after: None,
}]
);
}
#[tokio::test]
async fn model_access_denial_is_retryable_without_credential_recovery() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::ModelAccessDenied,
UpstreamSendState::Sent,
)
.with_status(403),
);
let provider = provider(selector.clone(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("stream");
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::PermissionDenied);
assert!(error.replay_is_safe());
assert_eq!(
selector.feedback.lock().expect("feedback").as_slice(),
&[GrokCredentialFailure::ModelAccessDenied {
upstream_model: UpstreamModelId::new("grok-4.5").expect("model"),
retry_after: None,
}]
);
}
#[tokio::test]
async fn safety_rejection_does_not_rotate_or_mutate_account_state() {
let selector = StubSelector::success();
let transport = StubInferenceTransport::error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::SafetyRejected,
UpstreamSendState::Sent,
)
.with_status(403),
);
let provider = provider(selector.clone(), transport).await;
let mut stream = provider
.execute(
provider_request("xai"),
context(CancellationToken::new(), None),
)
.await
.expect("stream");
let error = next_provider_error(&mut stream).await;
assert_eq!(error.kind(), ProviderErrorKind::PermissionDenied);
assert!(!error.replay_is_safe());
assert!(selector.feedback.lock().expect("feedback").is_empty());
}
#[tokio::test]
async fn transport_error_should_preserve_client_visible_upstream_detail() {
let detail = ClientVisibleUpstreamError::new(
"You have run out of credits",
Some("personal_team_blocked_spending_limit".to_owned()),
Some("insufficient_quota".to_owned()),
)
.expect("safe upstream detail");
let error = mapped_transport_error(
GrokInferenceTransportError::new(
GrokInferenceTransportErrorKind::QuotaExhausted,
UpstreamSendState::Sent,
)
.with_status(402)
.with_client_visible_upstream_error(detail),
false,
)
.await;
let detail = error
.client_visible_upstream_error()
.expect("mapped client-visible detail");
assert_eq!(
(detail.message(), detail.code(), detail.error_type()),
(
"You have run out of credits",
Some("personal_team_blocked_spending_limit"),
Some("insufficient_quota"),
)
);
}
#[tokio::test]
async fn explicit_http_408_does_not_mark_provider_error_replay_safe() {
let error = mapped_transport_error(
+63 -3
View File
@@ -15,9 +15,10 @@ use gateway_core::engine::credential::{
};
use gateway_core::error::{StoreError, StoreErrorKind};
use gateway_core::provider_ports::{
ProviderCooldown, ProviderCooldownPort, ProviderRefreshPolicy, ProviderRuntimePolicyPort,
ProviderSessionAffinityKey, ProviderSessionAffinityPort, ProviderSessionExclusionPort,
ProviderSessionExclusions, ProviderStoreError,
ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope, ProviderRefreshPolicy,
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderSessionAffinityKey,
ProviderSessionAffinityPort, ProviderSessionExclusionPort, ProviderSessionExclusions,
ProviderStoreError,
};
use gateway_core::routing::ProviderKind;
use provider_xai::{
@@ -561,12 +562,24 @@ pub struct TestSessionExclusions;
#[derive(Default)]
pub struct MemoryCooldownPort {
cooldowns: Mutex<BTreeMap<ProviderAccountId, ProviderCooldown>>,
scoped_cooldowns:
Mutex<BTreeMap<(ProviderAccountId, ProviderCooldownScope), ProviderScopedCooldown>>,
}
impl MemoryCooldownPort {
pub fn cooldown(&self, account_id: &ProviderAccountId) -> Option<ProviderCooldown> {
lock(&self.cooldowns).get(account_id).cloned()
}
pub fn scoped_cooldown(
&self,
account_id: &ProviderAccountId,
scope: &ProviderCooldownScope,
) -> Option<ProviderScopedCooldown> {
lock(&self.scoped_cooldowns)
.get(&(account_id.clone(), scope.clone()))
.cloned()
}
}
impl ProviderSessionAffinityPort for TestSessionAffinity {
@@ -674,6 +687,53 @@ impl ProviderCooldownPort for MemoryCooldownPort {
Ok(should_remove)
})
}
fn put_scoped_if_later(
&self,
cooldown: ProviderScopedCooldown,
) -> futures::future::BoxFuture<'_, Result<bool, ProviderStoreError>> {
Box::pin(async move {
let key = (cooldown.account_id().clone(), cooldown.scope().clone());
let mut cooldowns = lock(&self.scoped_cooldowns);
let should_write = cooldowns.get(&key).is_none_or(|current| {
current.credential_revision() < cooldown.credential_revision()
|| (current.credential_revision() == cooldown.credential_revision()
&& current.until() < cooldown.until())
});
if should_write {
cooldowns.insert(key, cooldown);
}
Ok(should_write)
})
}
fn read_scoped<'a>(
&'a self,
account_id: &'a ProviderAccountId,
scope: &'a ProviderCooldownScope,
) -> futures::future::BoxFuture<'a, Result<Option<ProviderScopedCooldown>, ProviderStoreError>>
{
Box::pin(async move { Ok(self.scoped_cooldown(account_id, scope)) })
}
fn clear_scoped<'a>(
&'a self,
account_id: &'a ProviderAccountId,
scope: &'a ProviderCooldownScope,
through_revision: CredentialRevision,
) -> futures::future::BoxFuture<'a, Result<bool, ProviderStoreError>> {
Box::pin(async move {
let key = (account_id.clone(), scope.clone());
let mut cooldowns = lock(&self.scoped_cooldowns);
let should_remove = cooldowns
.get(&key)
.is_some_and(|current| current.credential_revision() <= through_revision);
if should_remove {
cooldowns.remove(&key);
}
Ok(should_remove)
})
}
}
impl ProviderRuntimePolicyPort for StaticRuntimePolicy {
@@ -257,7 +257,7 @@ async fn inference_transport_should_classify_http_failures_without_retaining_bod
let cases = [
(400, GrokInferenceTransportErrorKind::InvalidRequest),
(401, GrokInferenceTransportErrorKind::Unauthorized),
(402, GrokInferenceTransportErrorKind::QuotaExhausted),
(402, GrokInferenceTransportErrorKind::PaymentRequired),
(403, GrokInferenceTransportErrorKind::PermissionDenied),
(408, GrokInferenceTransportErrorKind::Timeout),
(429, GrokInferenceTransportErrorKind::RateLimited),
@@ -302,10 +302,82 @@ async fn inference_transport_should_classify_http_failures_without_retaining_bod
Some(GrokInferenceClientCacheStatus::Miss)
);
assert!(error.transport_metrics().headers_ms().is_some());
assert!(error.client_visible_upstream_error().is_none());
assert!(!rendered.contains(&secret));
}
}
#[tokio::test]
async fn inference_transport_should_expose_safe_flat_json_error_details() {
let server = MockServer::start().await;
let message = "You have run out of credits or need a Grok subscription";
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(402).set_body_json(json!({
"code": "personal-team-blocked:spending-limit",
"error": message,
})))
.expect(1)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let error = inference_transport(&origin)
.execute(inference_request(&origin))
.await
.expect_err("spending limit response must fail");
let detail = error
.client_visible_upstream_error()
.expect("structured error detail");
let rendered = format!("{error:?}\n{error}");
assert_eq!(
error.kind(),
GrokInferenceTransportErrorKind::QuotaExhausted
);
assert_eq!(
(detail.message(), detail.code(), detail.error_type()),
(message, Some("personal_team_blocked_spending_limit"), None,)
);
assert!(!rendered.contains(message));
}
#[tokio::test]
async fn inference_transport_should_scrub_nested_json_error_details() {
let server = MockServer::start().await;
let account_fingerprint = "123e4567-e89b-12d3-a456-426614174000";
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
"error": {
"code": "rate-limit:burst",
"type": "rate_limit_error",
"message": format!("team {account_fingerprint}\nrate limited"),
}
})))
.expect(1)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let error = inference_transport(&origin)
.execute(inference_request(&origin))
.await
.expect_err("rate limit response must fail");
let detail = error
.client_visible_upstream_error()
.expect("structured error detail");
let rendered = format!("{error:?}\n{error}");
assert_eq!(
(detail.message(), detail.code(), detail.error_type()),
(
"team [redacted] rate limited",
Some("rate_limit_burst"),
Some("rate_limit_error"),
)
);
assert!(!rendered.contains(account_fingerprint));
}
#[tokio::test]
async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata() {
let cases = [
@@ -317,14 +389,20 @@ async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata(
),
(
json!({"error": {"code": "usage_exhausted", "message": "used all the included free usage for model"}}),
GrokInferenceTransportErrorKind::QuotaExhausted,
GrokInferenceTransportErrorKind::ModelQuotaExhausted,
false,
Some("usage_exhausted"),
),
(
json!({"error": {"code": "permission_denied", "message": "access to the chat endpoint is denied"}}),
GrokInferenceTransportErrorKind::PermissionDenied,
true,
GrokInferenceTransportErrorKind::ModelAccessDenied,
false,
Some("permission_denied"),
),
(
json!({"error": {"code": "permission_denied", "message": "request rejected", "details": {"check": "SAFETY_CHECK_TYPE_INPUT"}}}),
GrokInferenceTransportErrorKind::SafetyRejected,
false,
Some("permission_denied"),
),
(
@@ -359,7 +437,62 @@ async fn inference_transport_should_scope_forbidden_failures_from_safe_metadata(
}
#[tokio::test]
async fn inference_transport_should_classify_free_usage_429_as_quota_exhausted() {
async fn inference_transport_should_keep_unknown_402_out_of_account_quota_state() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(402).set_body_json(json!({
"error": {
"code": "payment_required",
"type": "billing_error",
"message": "Payment is required for this request"
}
})))
.expect(1)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let error = inference_transport(&origin)
.execute(inference_request(&origin))
.await
.expect_err("payment-required response must fail");
assert_eq!(
(error.kind(), error.requires_credential_recovery()),
(GrokInferenceTransportErrorKind::PaymentRequired, false)
);
}
#[tokio::test]
async fn inference_transport_should_classify_model_quota_for_403_and_429() {
for status in [403, 429] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(status).set_body_json(json!({
"error": {
"code": "subscription:free-usage-exhausted",
"message": "You have used all the included free usage for model grok-4.5"
}
})))
.expect(1)
.mount(&server)
.await;
let origin = Url::parse(&server.uri()).expect("wiremock origin");
let error = inference_transport(&origin)
.execute(inference_request(&origin))
.await
.expect_err("model quota response must fail");
assert_eq!(
error.kind(),
GrokInferenceTransportErrorKind::ModelQuotaExhausted
);
}
}
#[tokio::test]
async fn inference_transport_should_classify_free_usage_429_as_account_free_quota_exhausted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
@@ -380,7 +513,7 @@ async fn inference_transport_should_classify_free_usage_429_as_quota_exhausted()
assert_eq!(
error.kind(),
GrokInferenceTransportErrorKind::QuotaExhausted
GrokInferenceTransportErrorKind::FreeQuotaExhausted
);
assert_eq!(error.status(), Some(429));
assert_eq!(
+1 -1
View File
@@ -1 +1 @@
version: "3.0.19"
version: "3.0.20"