feat(gateway): harden failover and payload handling

Retry pre-response transport failures across candidates with an explicit stop policy, and propagate end-to-end timing into usage records and UI diagnostics.

Remove legacy body, import, cookie, PII, and tunnel replay caps while preserving optional operator-configured gateway limits.
This commit is contained in:
elky
2026-07-30 01:03:27 +08:00
parent a97acc07fc
commit a04673a90d
80 changed files with 3640 additions and 1236 deletions
@@ -9,9 +9,6 @@ use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::GatewayError;
use axum::{body::Body, http, response::Response};
pub(super) const MAX_CLAUDE_COOKIE_AUTHORIZE_BODY_BYTES: usize = 32 * 1024;
pub(super) const MAX_CLAUDE_SESSION_KEY_BYTES: usize = 16 * 1024;
struct ClaudeCookieAuthorizeRequest {
session_key: String,
name: Option<String>,
@@ -102,9 +99,6 @@ fn parse_claude_cookie_authorize_request(
let Some(request_body) = request_body else {
return Err(bad_cookie_request("请求体必须是合法的 JSON 对象"));
};
if request_body.len() > MAX_CLAUDE_COOKIE_AUTHORIZE_BODY_BYTES {
return Err(bad_cookie_request("Cookie 授权请求体过大"));
}
let payload = serde_json::from_slice::<serde_json::Value>(request_body)
.ok()
.and_then(|value| value.as_object().cloned())
@@ -126,7 +120,7 @@ fn parse_claude_cookie_authorize_request(
pub(super) fn normalize_claude_session_key(raw: &str) -> Option<String> {
let raw = raw.trim();
if raw.is_empty() || raw.len() > MAX_CLAUDE_SESSION_KEY_BYTES || raw.contains(['\r', '\n']) {
if raw.is_empty() || raw.contains(['\r', '\n']) {
return None;
}
let cookie = raw
@@ -155,7 +149,6 @@ pub(super) fn normalize_claude_session_key(raw: &str) -> Option<String> {
fn valid_session_key_value(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_CLAUDE_SESSION_KEY_BYTES
&& !value.contains(['\r', '\n', ';'])
&& http::HeaderValue::from_str(value).is_ok()
}
@@ -178,7 +171,9 @@ fn bad_cookie_request(detail: &'static str) -> Response<Body> {
#[cfg(test)]
mod tests {
use super::normalize_claude_session_key;
use super::{normalize_claude_session_key, parse_claude_cookie_authorize_request};
use axum::body::Bytes;
use serde_json::json;
#[test]
fn normalizes_supported_claude_cookie_inputs() {
@@ -211,4 +206,15 @@ mod tests {
);
}
}
#[test]
fn accepts_authorize_body_and_session_key_above_previous_caps() {
let session_key = "x".repeat(40 * 1024);
let body = Bytes::from(json!({ "sessionKey": session_key }).to_string());
assert!(body.len() > 32 * 1024);
let parsed = parse_claude_cookie_authorize_request(Some(&body))
.expect("large Cookie authorization payload should parse");
assert_eq!(parsed.session_key.len(), 40 * 1024);
}
}
@@ -8,7 +8,7 @@ use super::super::state::{
build_admin_provider_oauth_backend_unavailable_response,
};
use super::batch::build_admin_provider_oauth_batch_task_state;
use super::cookie::{normalize_claude_session_key, MAX_CLAUDE_SESSION_KEY_BYTES};
use super::cookie::normalize_claude_session_key;
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_cookie_task_provider_id;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::task_runtime::{
@@ -35,7 +35,6 @@ use uuid::Uuid;
const CLAUDE_COOKIE_TASK_IMPORT_KIND: &str = "cookie_authorize";
const CLAUDE_COOKIE_TASK_ID_PREFIX: &str = "claude-cookie-";
const MAX_CLAUDE_COOKIE_TASK_ENTRIES: usize = 20;
const MAX_CLAUDE_COOKIE_TASK_BODY_BYTES: usize = 768 * 1024;
const CLAUDE_COOKIE_AUTHORIZATION_CONCURRENCY: usize = 3;
const MAX_SAFE_ERROR_DETAIL_BYTES: usize = 512;
@@ -476,9 +475,6 @@ fn parse_claude_cookie_task_request(
let Some(request_body) = request_body else {
return Err(bad_cookie_task_request("请求体必须是合法的 JSON 对象"));
};
if request_body.len() > MAX_CLAUDE_COOKIE_TASK_BODY_BYTES {
return Err(bad_cookie_task_request("Cookie 授权请求体过大"));
}
let payload = serde_json::from_slice::<Value>(request_body)
.ok()
.and_then(|value| value.as_object().cloned())
@@ -571,8 +567,7 @@ fn current_unix_secs_or(fallback: u64) -> u64 {
#[cfg(test)]
mod tests {
use super::{
parse_claude_cookie_task_request, safe_error_detail, MAX_CLAUDE_COOKIE_TASK_BODY_BYTES,
MAX_CLAUDE_COOKIE_TASK_ENTRIES, MAX_CLAUDE_SESSION_KEY_BYTES,
parse_claude_cookie_task_request, safe_error_detail, MAX_CLAUDE_COOKIE_TASK_ENTRIES,
};
use axum::body::{to_bytes, Bytes};
use serde_json::json;
@@ -625,26 +620,23 @@ mod tests {
}
#[test]
fn accepts_twenty_maximum_length_session_keys_within_batch_body_limit() {
fn accepts_twenty_long_session_keys_above_previous_body_cap() {
let cookies = (0..MAX_CLAUDE_COOKIE_TASK_ENTRIES)
.map(|index| {
let prefix = format!("{index:02}-");
format!(
"{prefix}{}",
"x".repeat(MAX_CLAUDE_SESSION_KEY_BYTES - prefix.len())
)
format!("{prefix}{}", "x".repeat(40 * 1024))
})
.collect::<Vec<_>>();
let body = Bytes::from(json!({"cookies": cookies}).to_string());
assert!(body.len() < MAX_CLAUDE_COOKIE_TASK_BODY_BYTES);
let parsed = parse_claude_cookie_task_request(Some(&body))
.expect("maximum valid batch should parse");
assert!(body.len() > 768 * 1024);
let parsed =
parse_claude_cookie_task_request(Some(&body)).expect("large valid batch should parse");
assert_eq!(parsed.entries.len(), MAX_CLAUDE_COOKIE_TASK_ENTRIES);
assert!(parsed.entries.iter().all(Result::is_ok));
}
#[tokio::test]
async fn rejects_ambiguous_or_oversized_cookie_batches_without_echoing_secrets() {
async fn rejects_ambiguous_cookie_batches_without_echoing_secrets() {
let too_many = vec!["sessionKey=value"; MAX_CLAUDE_COOKIE_TASK_ENTRIES + 1];
for payload in [
json!({"cookie": "sessionKey=secret", "cookies": ["sessionKey=other"]}),
@@ -663,13 +655,6 @@ mod tests {
assert!(!text.contains("secret"));
assert!(!text.contains("other"));
}
let oversized_body = Bytes::from(vec![b'x'; MAX_CLAUDE_COOKIE_TASK_BODY_BYTES + 1]);
let response = match parse_claude_cookie_task_request(Some(&oversized_body)) {
Ok(_) => panic!("oversized request should fail"),
Err(response) => response,
};
assert_eq!(response.status(), http::StatusCode::BAD_REQUEST);
}
#[test]
@@ -1,6 +1,4 @@
use super::{
AdminAppState, ADMIN_SYSTEM_DATA_EXPORT_VERSION, ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES,
};
use super::{AdminAppState, ADMIN_SYSTEM_DATA_EXPORT_VERSION};
use crate::ai_serving::build_provider_key_pool_score_upsert;
use crate::api::ai::admin_endpoint_signature_parts;
use crate::handlers::admin::admin_provider_pool_config;
@@ -56,8 +54,6 @@ use std::collections::{BTreeMap, BTreeSet};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
const ADMIN_SYSTEM_IMPORT_MAX_SIZE_BYTES: usize = 500 * 1024 * 1024;
fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, Value) {
(
http::StatusCode::BAD_REQUEST,
@@ -1183,10 +1179,6 @@ impl<'a> AdminAppState<'a> {
)));
}
if request_body.len() > ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES {
return Ok(Err(invalid_request("请求体大小不能超过 500MB")));
}
let root = match serde_json::from_slice::<Value>(request_body) {
Ok(Value::Object(map)) => map,
_ => return Ok(Err(invalid_request("请求数据验证失败"))),
@@ -1280,10 +1272,6 @@ impl<'a> AdminAppState<'a> {
json!({ "detail": "Admin system data unavailable" }),
)));
}
if request_body.len() > ADMIN_SYSTEM_IMPORT_MAX_SIZE_BYTES {
return Ok(Err(invalid_request("请求体大小不能超过 500MB")));
}
let parsed = routed!(parse_admin_system_config_import_request(request_body));
let root = parsed.root;
let merge_mode = parsed.request.merge_mode;
@@ -2307,10 +2295,6 @@ impl<'a> AdminAppState<'a> {
json!({ "detail": "Admin system data unavailable" }),
)));
}
if request_body.len() > ADMIN_SYSTEM_IMPORT_MAX_SIZE_BYTES {
return Ok(Err(invalid_request("请求体大小不能超过 500MB")));
}
let root = match serde_json::from_slice::<Value>(request_body) {
Ok(Value::Object(map)) => map,
_ => return Ok(Err(invalid_request("请求数据验证失败"))),
@@ -9,7 +9,6 @@ mod proxy_nodes;
mod templates;
const ADMIN_SYSTEM_DATA_EXPORT_VERSION: &str = "1.0";
const ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES: usize = 500 * 1024 * 1024;
impl<'a> AdminAppState<'a> {
pub(crate) async fn upsert_system_config_json_value(