mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix(gateway-api): translate upstream WebSocket error events into SSE response.failed (#90)
* fix(gateway-api): translate upstream WebSocket error events into SSE response.failed WS 上游协议用 `error` 事件报告流中途失败(如 OpenAI 的 server_is_overloaded), SSE 协议的对应形态是 `response.failed`。SSE 交付边界此前把 `error` 事件按 未知事件原样透传,而 codex 等 SSE 客户端会静默忽略未知事件类型:客户端只看到 无原因的流 EOF("stream closed before response.completed"),丢失了上游的 完整错误负载(message/code/type),也无法据此分类重试。 现在 SSE 编码器在交付边界把 `error` wire 事件翻译成 `response.failed`: 错误负载(type/code/message/param)原样保留,response id 沿用已交付的 response.created。WS 客户端交付不变(WS 协议原生支持 error 帧)。 原有的会话级失败兜底(finish_with_gateway_error 合成 response.failed)不变。 * fix(gateway-api): preserve upstream response failures Co-authored-by: Codex <noreply@openai.com> --------- Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
Zyy
co-authored by
Codex
Zyy
parent
fc07ac0803
commit
1bbae67040
@@ -2,7 +2,9 @@
|
||||
|
||||
use bytes::Bytes;
|
||||
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
|
||||
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
|
||||
use gateway_protocol::openai::sse::{
|
||||
encode_sse_event_with_metadata, response_failed_sse_data_from_error_event,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::error::ResponseEncodeError;
|
||||
@@ -17,6 +19,7 @@ const OPENAI_PROTOCOL: &str = "openai";
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OpenAiResponsesEncoder {
|
||||
response_id: Option<String>,
|
||||
response_snapshot: Option<Value>,
|
||||
wire_terminal: Option<Value>,
|
||||
wire_failure: bool,
|
||||
}
|
||||
@@ -27,6 +30,7 @@ impl OpenAiResponsesEncoder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
response_id: None,
|
||||
response_snapshot: None,
|
||||
wire_terminal: None,
|
||||
wire_failure: false,
|
||||
}
|
||||
@@ -39,6 +43,22 @@ impl OpenAiResponsesEncoder {
|
||||
return Vec::new();
|
||||
};
|
||||
self.observe_wire(wire);
|
||||
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因。
|
||||
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`。
|
||||
if wire.event_type() == Some("error")
|
||||
&& let Some(data) = response_failed_sse_data_from_error_event(
|
||||
self.response_snapshot.as_ref(),
|
||||
self.response_id.as_deref(),
|
||||
wire.data(),
|
||||
)
|
||||
{
|
||||
return vec![Bytes::from(encode_sse_event_with_metadata(
|
||||
"response.failed",
|
||||
&data.to_string(),
|
||||
wire.sse_id(),
|
||||
wire.sse_retry(),
|
||||
))];
|
||||
}
|
||||
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
|
||||
return vec![raw_sse_frame.clone()];
|
||||
}
|
||||
@@ -110,6 +130,16 @@ impl OpenAiResponsesEncoder {
|
||||
let effective_type = wire
|
||||
.event_type()
|
||||
.or_else(|| wire.data().get("type").and_then(Value::as_str));
|
||||
if matches!(
|
||||
effective_type,
|
||||
Some("response.created" | "response.in_progress" | "response.queued")
|
||||
) && let Some(response) = wire
|
||||
.data()
|
||||
.get("response")
|
||||
.filter(|value| value.is_object())
|
||||
{
|
||||
self.response_snapshot = Some(response.clone());
|
||||
}
|
||||
if matches!(
|
||||
effective_type,
|
||||
Some("response.completed" | "response.incomplete")
|
||||
|
||||
@@ -34,6 +34,7 @@ use gateway_core::event::{
|
||||
use gateway_core::operation::{Operation, OperationKind};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
|
||||
use gateway_protocol::openai::sse::{encode_sse_event, parse_sse_events};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
|
||||
@@ -1572,6 +1573,113 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let error_data = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
});
|
||||
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
|
||||
let error_wire = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("error".to_owned()),
|
||||
error_data,
|
||||
projected_error_frame,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream error wire"),
|
||||
);
|
||||
let started_wire = ProviderEvent::canonical_with_wire(
|
||||
vec![started()],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "in_progress",
|
||||
"future_response_field": {"keep": true}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("valid upstream started wire"),
|
||||
);
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
started_wire,
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)),
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
error_wire,
|
||||
CommitRequirement::AlreadyCommitted,
|
||||
)),
|
||||
NextStep::Error(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
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");
|
||||
let events = parse_sse_events(&body).expect("translated SSE should parse");
|
||||
let failed = events
|
||||
.iter()
|
||||
.find(|event| event.event.as_deref() == Some("response.failed"))
|
||||
.expect("translated response.failed event");
|
||||
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
failed,
|
||||
body.matches("event: response.failed").count(),
|
||||
body.contains("event: error\n"),
|
||||
body.ends_with("data: [DONE]\n\n"),
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"future_response_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
}),
|
||||
1,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
|
||||
/// 单条 SSE 事件。
|
||||
@@ -277,12 +277,7 @@ pub fn response_failed_sse_data_with_id(
|
||||
"code": code,
|
||||
"message": message,
|
||||
});
|
||||
let response_id = response_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map_or_else(
|
||||
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
|
||||
ToString::to_string,
|
||||
);
|
||||
let response_id = response_id_or_generated(response_id);
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
@@ -294,6 +289,50 @@ pub fn response_failed_sse_data_with_id(
|
||||
})
|
||||
}
|
||||
|
||||
/// 将携带嵌套错误的上游事件投影为 OpenAI Responses `response.failed` 数据。
|
||||
///
|
||||
/// 该投影供客户端 SSE 兼容转换使用。原始错误对象和事件级元数据保持不透明;
|
||||
/// 已有 response 快照只更新失败终态字段。
|
||||
pub fn response_failed_sse_data_from_error_event(
|
||||
response: Option<&Value>,
|
||||
response_id: Option<&str>,
|
||||
error_event: &Value,
|
||||
) -> Option<Value> {
|
||||
let mut event = error_event.as_object()?.clone();
|
||||
let error = event.remove("error").filter(Value::is_object)?;
|
||||
let mut response = response
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let has_response_id = response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !has_response_id {
|
||||
response.insert(
|
||||
"id".to_owned(),
|
||||
Value::String(response_id_or_generated(response_id)),
|
||||
);
|
||||
}
|
||||
response.insert("status".to_owned(), Value::String("failed".to_owned()));
|
||||
response.insert("error".to_owned(), error);
|
||||
event.insert(
|
||||
"type".to_owned(),
|
||||
Value::String("response.failed".to_owned()),
|
||||
);
|
||||
event.insert("response".to_owned(), Value::Object(response));
|
||||
Some(Value::Object(event))
|
||||
}
|
||||
|
||||
fn response_id_or_generated(response_id: Option<&str>) -> String {
|
||||
response_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map_or_else(
|
||||
|| format!("resp_proxy_{}", uuid::Uuid::new_v4().simple()),
|
||||
ToString::to_string,
|
||||
)
|
||||
}
|
||||
|
||||
/// 返回下一个完整 SSE 帧结束位置(含分隔符)。
|
||||
pub fn sse_frame_end(bytes: &[u8]) -> Option<usize> {
|
||||
sse_frame_separator_bytes(bytes).map(|(position, separator_len)| position + separator_len)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use gateway_protocol::openai::sse::{
|
||||
DONE_SSE_FRAME, MAX_SSE_EVENT_BUFFER_BYTES, SseError, SseEvent, SseEventDecoder,
|
||||
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events, response_failed_sse_event,
|
||||
encode_sse_event, encode_sse_event_with_metadata, parse_sse_events,
|
||||
response_failed_sse_data_from_error_event, response_failed_sse_event,
|
||||
response_failed_sse_event_with_id, sse_frame_end, sse_frame_is_done,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn sse_body_has_done(body: &str) -> bool {
|
||||
body.trim_end_matches(['\r', '\n'])
|
||||
@@ -386,6 +388,57 @@ fn response_failed_event_should_preserve_existing_response_id() {
|
||||
assert_eq!(data["response"]["id"], "resp_existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_failed_projection_should_preserve_response_error_and_event_metadata() {
|
||||
let response = json!({
|
||||
"id": "resp_existing",
|
||||
"model": "gpt-test",
|
||||
"status": "in_progress",
|
||||
"future_response_field": {"keep": true}
|
||||
});
|
||||
let error_event = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "overloaded",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
});
|
||||
|
||||
let projected = response_failed_sse_data_from_error_event(
|
||||
Some(&response),
|
||||
Some("ignored_fallback"),
|
||||
&error_event,
|
||||
)
|
||||
.expect("wrapped error event should project");
|
||||
|
||||
assert_eq!(
|
||||
projected,
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_existing",
|
||||
"model": "gpt-test",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "overloaded",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"future_response_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_frame_detection_should_require_exact_single_data_field() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -470,6 +470,7 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
"acct_websocket_turn_state",
|
||||
"acct_ws_quota_a",
|
||||
"acct_ws_quota_b",
|
||||
"acct_ws_midstream_overload",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
@@ -2373,6 +2374,114 @@ async fn abrupt_websocket_disconnect_preserves_diagnosis_and_ambiguous_send_stat
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_midstream_error_frame_surfaces_upstream_message_after_delivery() {
|
||||
const ACCOUNT_ID: &str = "acct_ws_midstream_overload";
|
||||
const RESPONSE_ID: &str = "resp_midstream_overload";
|
||||
const OVERLOAD_MESSAGE: &str = "Our servers are currently overloaded. Please try again later.";
|
||||
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, ACCOUNT_ID).await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("accept WebSocket");
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
let _request = websocket
|
||||
.next()
|
||||
.await
|
||||
.expect("WebSocket request")
|
||||
.expect("valid WebSocket request");
|
||||
// 真实生产观察:OpenAI 在流已开始后发送带原话的 `error` 帧再断连。
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send response.created");
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.in_progress",
|
||||
"response": {"id": RESPONSE_ID, "model": "gpt-5.4", "status": "in_progress"}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send response.in_progress");
|
||||
tokio::time::sleep(Duration::from_millis(1_600)).await;
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": OVERLOAD_MESSAGE,
|
||||
"param": null
|
||||
},
|
||||
"sequence_number": 2
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send error frame");
|
||||
// 不发送任何终止事件,直接断开,模拟上游发完错误帧后的真实行为。
|
||||
});
|
||||
|
||||
let provider = provider_with_base_url(&store, base_url);
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_ws_midstream_overload", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare midstream-failure stream");
|
||||
let mut failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("midstream error frame must surface a typed failure"),
|
||||
}
|
||||
};
|
||||
server.abort();
|
||||
|
||||
assert_eq!(
|
||||
failure.kind(),
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable
|
||||
);
|
||||
assert_eq!(failure.send_state(), UpstreamSendState::Sent);
|
||||
// 交给 Core 的失败必须保留上游原话:客户端只能靠它知道失败原因。
|
||||
let visible = failure
|
||||
.client_visible_upstream_error()
|
||||
.expect("overload frame must carry a client-visible upstream error");
|
||||
assert_eq!(visible.code(), Some("server_is_overloaded"));
|
||||
assert_eq!(visible.message(), OVERLOAD_MESSAGE);
|
||||
// 原始错误帧必须随失败一起交给交付边界(SSE 侧据此翻译成 response.failed)。
|
||||
let atomic = failure.take_atomic_client_events();
|
||||
let error_frame = atomic
|
||||
.iter()
|
||||
.find_map(|event| {
|
||||
let wire = event.wire_event()?;
|
||||
(wire.event_type() == Some("error")).then(|| wire.data().clone())
|
||||
})
|
||||
.expect("atomic batch must carry the upstream error frame");
|
||||
assert_eq!(
|
||||
error_frame
|
||||
.get("error")
|
||||
.and_then(|error| error.get("message"))
|
||||
.and_then(Value::as_str),
|
||||
Some(OVERLOAD_MESSAGE)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_idle_timeout_diagnosis_survives_ambiguous_send_wrapping() {
|
||||
const ACCOUNT_ID: &str = "acct_abrupt_disconnect";
|
||||
|
||||
Reference in New Issue
Block a user