fix: classify SSE cyber policy failures

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-26 01:10:03 +08:00
co-authored by Codex
parent 895388f121
commit ff7e4face1
4 changed files with 65 additions and 6 deletions
+5 -4
View File
@@ -192,6 +192,7 @@
- 首个文本帧是 `response.create`,payload 由 `CodexResponsesRequest` 序列化而来。
- WebSocket opening 会移除 `content-type` 和 `accept`,因为这两个是 HTTP/SSE 请求头,不属于 WS opening 业务头。
- 连接池 key 为 `base_url + account_id + conversation_id`,conversation id 优先取 `prompt_cache_key`,其次 `client_conversation_id`,再其次 `previous_response_id`。
- 官方二进制有 60 分钟连接限制文案;当前项目连接池默认 `max_age_ms = 3_300_000`(55 分钟),低于官方限制。连接在 acquire、put 和后台 maintenance sweep 时都会按 `max_age` 回收,避免复用接近官方 60 分钟上限的 socket。
当前 WebSocket fallback:
@@ -322,8 +323,8 @@ WebSocket 事件:
- 403 body 命中 `cf-mitigated`、`cf-chl-bypass`、`_cf_chl`、`cf_chl`、`attention required`、`just a moment` 时进入 Cloudflare challenge cooldown。
- 404 空 body 作为 path-block,立即删除账号 cookie,多次后禁用账号。
- 调度层会在 Cloudflare challenge/path-block 后换号或返回明确错误。
- 官方二进制出现 `This request has been flagged for possible cybersecurity risk.`。当前项目没有单独识别这个文案,若真实链路遇到,应作为独立风控/安全策略错误,而不是归入 quota 或 Cloudflare。
- 官方错误枚举还包含 `usage_limit_exceeded`、`cyber_policy`、`response_stream_connection_failed`、`response_stream_disconnected` 等 CodexErrorInfo 类别;当前项目主要按 HTTP status、SSE failure code/message 和 WS wrapped error 分类。
- 官方二进制出现 `This request has been flagged for possible cybersecurity risk.`。当前项目把 `cyber_policy` 归为请求内容/安全策略类 400,不作为 quota、Cloudflare 或账号封禁信号。
- 官方错误枚举还包含 `context_window_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`cyber_policy`、`response_stream_connection_failed`、`response_stream_disconnected` 等 CodexErrorInfo 类别。当前项目按 HTTP status、SSE failure code/message 和 WS wrapped error 分类;其中 WS `cyber_policy`/`invalid_prompt`/`context_length_exceeded`/`invalid_request` 会映射 400,SSE `cyber_policy`/`invalid_prompt`/`context_window*`/`bad_request` 也会映射 400。
### 额度和限流
@@ -402,8 +403,8 @@ Usage 请求路径按 base URL 选择:
- 原生二进制不能在当前 Linux 环境执行,无法做动态断点或真实 app-server 调用链追踪。
- 官方包里没有 source map,JS bundle 为压缩产物;函数名和局部变量名不能视为稳定 API。
- WebSocket 降级策略目前按现有项目实现理解为连接/transport 错误可降级,显式上游状态错误不降级;官方二进制字符串能证明有降级路径,但不能单独证明完整条件矩阵。
- 官方有 WebSocket 60 分钟连接限制文案;当前项目 WebSocket pool 只按本地 pool TTL 控制,未显式实现官方 60 分钟上限。
- 官方有 `cyber_policy` 风控语义;当前项目还没有把对应文案/错误码拆成独立错误类型。
- 官方有 WebSocket 60 分钟连接限制文案;当前项目用 55 分钟默认 pool TTL 主动规避该上限,但没有实现从上游错误中动态调整 TTL。
- 官方有 `cyber_policy` 风控语义;当前项目已将 WS/SSE 中的 `cyber_policy` 归入 400 类请求失败,但没有创建单独的账号状态或轮换策略。
## 实现建议
+12 -2
View File
@@ -2740,6 +2740,13 @@ fn status_code_for_stream_failure(failure: &ResponsesSseFailure) -> i64 {
if code.contains("invalid_request") || code.contains("not_found") {
return 400;
}
if code.contains("context_window")
|| code.contains("invalid_prompt")
|| code.contains("cyber_policy")
|| code.contains("bad_request")
{
return 400;
}
if code.contains("rate_limit") || code.contains("usage_limit") {
return 429;
}
@@ -2757,6 +2764,9 @@ fn status_code_for_stream_failure(failure: &ResponsesSseFailure) -> i64 {
if code.contains("payment") || code.contains("quota") {
return 402;
}
if code.contains("server_overloaded") {
return 503;
}
502
}
@@ -2927,8 +2937,8 @@ impl ResponseDispatchError {
| Self::CloudflarePathBlocked { .. }
| Self::InvalidSse(_)
| Self::MissingCompleted
| Self::EmptyUpstreamResponse
| Self::Failed(_) => 502,
| Self::EmptyUpstreamResponse => 502,
Self::Failed(failure) => stream_failure_http_status(failure),
Self::ModelUnsupported { .. } => 400,
Self::Upstream(error) => upstream_error_http_status(error),
}
+3
View File
@@ -311,6 +311,9 @@ fn response_dispatch_error_type_and_code(
| ResponseDispatchError::EmptyUpstreamResponse => {
("server_error", "invalid_upstream_response")
}
ResponseDispatchError::Failed(_) => responses_error_type_and_code_for_status(
status_from_u16(error.http_status_code(), StatusCode::BAD_GATEWAY),
),
_ => ("server_error", "upstream_error"),
}
}
@@ -46,6 +46,51 @@ async fn responses_should_dispatch_to_codex_and_return_completed_response() {
assert_eq!(body["usage"]["output_tokens"], 2);
}
#[tokio::test]
async fn responses_should_classify_sse_cyber_policy_failure_as_bad_request() {
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.failed\n",
"data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_cyber_policy\",\"object\":\"response\",\"status\":\"failed\",\"error\":{\"code\":\"cyber_policy\",\"message\":\"This request has been flagged for possible cybersecurity risk.\"}}}\n\n"
)),
)
.mount(&server)
.await;
let (app, api_key, _dir) = test_app_with_account(server.uri()).await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/responses")
.header("authorization", format!("Bearer {api_key}"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "gpt-5.5",
"input": [{"role": "user", "content": "Security assessment"}],
"stream": false,
"use_websocket": false
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(body["error"]["code"], "codex_api_error");
}
#[tokio::test]
async fn responses_should_return_no_available_accounts_error_when_no_accounts_are_available() {
let server = MockServer::start().await;