mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -338,6 +338,14 @@ fn users_me_usage_metadata_string<'a>(
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn users_me_usage_metadata_u64(item: &StoredRequestUsageAudit, key: &str) -> Option<u64> {
|
||||
item.request_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|metadata| metadata.get(key))
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
}
|
||||
|
||||
fn infer_client_family_from_user_agent(user_agent: &str) -> Option<&'static str> {
|
||||
let normalized = user_agent.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
@@ -499,6 +507,11 @@ fn build_users_me_usage_record_payload(
|
||||
auth_api_key_reader_available,
|
||||
),
|
||||
});
|
||||
payload["end_to_end_time_ms"] = json!(users_me_usage_metadata_u64(item, "end_to_end_time_ms"));
|
||||
payload["end_to_end_first_byte_time_ms"] = json!(users_me_usage_metadata_u64(
|
||||
item,
|
||||
"end_to_end_first_byte_time_ms"
|
||||
));
|
||||
|
||||
if item.target_model.is_some() {
|
||||
payload["target_model"] = json!(item.target_model.clone());
|
||||
@@ -559,6 +572,11 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
|
||||
"target_model": item.target_model,
|
||||
"has_fallback": item.has_fallback(),
|
||||
});
|
||||
payload["end_to_end_time_ms"] = json!(users_me_usage_metadata_u64(item, "end_to_end_time_ms"));
|
||||
payload["end_to_end_first_byte_time_ms"] = json!(users_me_usage_metadata_u64(
|
||||
item,
|
||||
"end_to_end_first_byte_time_ms"
|
||||
));
|
||||
if item.api_format.is_none() {
|
||||
payload
|
||||
.as_object_mut()
|
||||
@@ -1661,6 +1679,29 @@ mod tests {
|
||||
assert_eq!(payload["cache_creation_ephemeral_1h_input_tokens"], 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_usage_payloads_project_end_to_end_timings_from_metadata() {
|
||||
let item = StoredRequestUsageAudit {
|
||||
response_time_ms: Some(626),
|
||||
first_byte_time_ms: Some(120),
|
||||
request_metadata: Some(json!({
|
||||
"end_to_end_time_ms": 10_626,
|
||||
"end_to_end_first_byte_time_ms": 10_120,
|
||||
})),
|
||||
..sample_usage("completed")
|
||||
};
|
||||
|
||||
let record = build_users_me_usage_record_payload(&item, false, &BTreeMap::new(), false);
|
||||
let active = build_users_me_usage_active_payload(&item);
|
||||
|
||||
for payload in [&record, &active] {
|
||||
assert_eq!(payload["response_time_ms"], 626);
|
||||
assert_eq!(payload["first_byte_time_ms"], 120);
|
||||
assert_eq!(payload["end_to_end_time_ms"], 10_626);
|
||||
assert_eq!(payload["end_to_end_first_byte_time_ms"], 10_120);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_usage_payloads_expose_requested_and_provider_reasoning_mapping() {
|
||||
let item = StoredRequestUsageAudit {
|
||||
|
||||
Reference in New Issue
Block a user