mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(pi): localize UI and isolate credential health
This commit is contained in:
@@ -111,8 +111,7 @@ pub(crate) async fn handle_pi_native(
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut saw_health_failure = false;
|
||||
let mut reached_network = false;
|
||||
let mut provider_health_failure = None;
|
||||
for candidate in attempts[index..provider_end].iter().cloned() {
|
||||
if !network_budget.has_remaining() {
|
||||
break;
|
||||
@@ -155,16 +154,12 @@ pub(crate) async fn handle_pi_native(
|
||||
}
|
||||
if let Some(pending) = pending_retryable.take() {
|
||||
if pending.provider_id != provider_id {
|
||||
record_provider_result(
|
||||
settle_provider_health(
|
||||
&state,
|
||||
route.catalog_epoch,
|
||||
&pending.provider_id,
|
||||
pending.used_half_open_permit,
|
||||
false,
|
||||
Some(format!(
|
||||
"Pi upstream returned retryable status {}",
|
||||
pending.response.status()
|
||||
)),
|
||||
pending.provider_health.clone(),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
@@ -182,7 +177,6 @@ pub(crate) async fn handle_pi_native(
|
||||
.headers(outgoing_headers)
|
||||
.body(body.clone())
|
||||
.send();
|
||||
reached_network = true;
|
||||
let response = match if timeout_seconds > 0 {
|
||||
tokio::time::timeout(Duration::from_secs(u64::from(timeout_seconds)), send)
|
||||
.await
|
||||
@@ -192,25 +186,31 @@ pub(crate) async fn handle_pi_native(
|
||||
} {
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(error)) => {
|
||||
saw_health_failure = true;
|
||||
last_error = Some(if error.is_timeout() {
|
||||
let error = if error.is_timeout() {
|
||||
"Pi upstream request timed out".to_string()
|
||||
} else {
|
||||
"Pi upstream request failed before response".to_string()
|
||||
});
|
||||
};
|
||||
provider_health_failure = Some(error.clone());
|
||||
last_error = Some(error);
|
||||
continue;
|
||||
}
|
||||
Err(()) => {
|
||||
saw_health_failure = true;
|
||||
last_error = Some("Pi upstream response-header timeout".to_string());
|
||||
let error = "Pi upstream response-header timeout".to_string();
|
||||
provider_health_failure = Some(error.clone());
|
||||
last_error = Some(error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
if retryable_status(status) && network_budget.has_remaining() {
|
||||
saw_health_failure = true;
|
||||
last_error = Some(format!("Pi upstream returned retryable status {status}"));
|
||||
let error = format!("Pi upstream returned retryable status {status}");
|
||||
let status_health = provider_health_disposition(status);
|
||||
if status_health == ProviderHealthDisposition::Unhealthy {
|
||||
provider_health_failure = Some(error.clone());
|
||||
}
|
||||
last_error = Some(error);
|
||||
let selected_is_failover = materialized.is_failover;
|
||||
pending_retryable = Some(PendingRetryableResponse {
|
||||
response,
|
||||
@@ -218,10 +218,16 @@ pub(crate) async fn handle_pi_native(
|
||||
provider_id: provider_id.clone(),
|
||||
used_half_open_permit: permit.used_half_open_permit,
|
||||
selected_is_failover,
|
||||
provider_health: ProviderHealthOutcome::from_status(
|
||||
status,
|
||||
provider_health_failure.as_deref(),
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let selected_is_failover = materialized.is_failover;
|
||||
let provider_health =
|
||||
ProviderHealthOutcome::from_status(status, provider_health_failure.as_deref());
|
||||
match prepare_response(
|
||||
state.clone(),
|
||||
response,
|
||||
@@ -236,19 +242,18 @@ pub(crate) async fn handle_pi_native(
|
||||
route.app_config.non_streaming_timeout,
|
||||
permit.used_half_open_permit,
|
||||
selected_is_failover,
|
||||
provider_health.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => {
|
||||
if !prepared.finalization_deferred {
|
||||
let health_success = !retryable_status(status);
|
||||
record_provider_result(
|
||||
settle_provider_health(
|
||||
&state,
|
||||
route.catalog_epoch,
|
||||
&provider_id,
|
||||
permit.used_half_open_permit,
|
||||
health_success,
|
||||
(!health_success).then(|| format!("Pi upstream returned {status}")),
|
||||
provider_health,
|
||||
)
|
||||
.await;
|
||||
record_request_finish(
|
||||
@@ -263,7 +268,7 @@ pub(crate) async fn handle_pi_native(
|
||||
return Ok(prepared.response);
|
||||
}
|
||||
Err(ProxyError::ForwardFailed(error)) | Err(ProxyError::Timeout(error)) => {
|
||||
saw_health_failure = true;
|
||||
provider_health_failure = Some(error.clone());
|
||||
last_error = Some(error);
|
||||
continue;
|
||||
}
|
||||
@@ -273,9 +278,7 @@ pub(crate) async fn handle_pi_native(
|
||||
route.catalog_epoch,
|
||||
&provider_id,
|
||||
permit.used_half_open_permit,
|
||||
reached_network,
|
||||
saw_health_failure,
|
||||
Some(error.to_string()),
|
||||
provider_health_failure.clone(),
|
||||
)
|
||||
.await;
|
||||
record_request_finish(&state, false, false, Some(error.to_string())).await;
|
||||
@@ -293,9 +296,7 @@ pub(crate) async fn handle_pi_native(
|
||||
route.catalog_epoch,
|
||||
&provider_id,
|
||||
permit.used_half_open_permit,
|
||||
reached_network,
|
||||
saw_health_failure,
|
||||
last_error.clone(),
|
||||
provider_health_failure,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -307,6 +308,7 @@ pub(crate) async fn handle_pi_native(
|
||||
let provider_id = pending.provider_id.clone();
|
||||
let used_half_open_permit = pending.used_half_open_permit;
|
||||
let selected_is_failover = pending.selected_is_failover;
|
||||
let provider_health = pending.provider_health;
|
||||
match prepare_response(
|
||||
state.clone(),
|
||||
pending.response,
|
||||
@@ -321,18 +323,18 @@ pub(crate) async fn handle_pi_native(
|
||||
route.app_config.non_streaming_timeout,
|
||||
used_half_open_permit,
|
||||
selected_is_failover,
|
||||
provider_health.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => {
|
||||
if !prepared.finalization_deferred {
|
||||
record_provider_result(
|
||||
settle_provider_health(
|
||||
&state,
|
||||
route.catalog_epoch,
|
||||
&provider_id,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(format!("Pi upstream returned {status}")),
|
||||
provider_health,
|
||||
)
|
||||
.await;
|
||||
record_request_finish(
|
||||
@@ -374,6 +376,7 @@ struct PendingRetryableResponse {
|
||||
provider_id: String,
|
||||
used_half_open_permit: bool,
|
||||
selected_is_failover: bool,
|
||||
provider_health: ProviderHealthOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -497,24 +500,114 @@ async fn record_provider_result(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProviderHealthDisposition {
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
Neutral,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProviderHealthOutcome {
|
||||
disposition: ProviderHealthDisposition,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderHealthOutcome {
|
||||
fn from_status(status: StatusCode, prior_failure: Option<&str>) -> Self {
|
||||
match provider_health_disposition(status) {
|
||||
ProviderHealthDisposition::Healthy => Self {
|
||||
disposition: ProviderHealthDisposition::Healthy,
|
||||
error: None,
|
||||
},
|
||||
ProviderHealthDisposition::Unhealthy => Self {
|
||||
disposition: ProviderHealthDisposition::Unhealthy,
|
||||
error: Some(format!("Pi upstream returned {status}")),
|
||||
},
|
||||
ProviderHealthDisposition::Neutral => prior_failure.map_or_else(
|
||||
|| Self {
|
||||
disposition: ProviderHealthDisposition::Neutral,
|
||||
error: None,
|
||||
},
|
||||
|error| Self {
|
||||
// A credential rejection is neutral by itself, but it
|
||||
// cannot erase a real failure from an earlier endpoint
|
||||
// covered by the same provider-level permit.
|
||||
disposition: ProviderHealthDisposition::Unhealthy,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_health_disposition(status: StatusCode) -> ProviderHealthDisposition {
|
||||
if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
||||
// Authentication/authorization belongs to the candidate credential,
|
||||
// not endpoint availability. Failover may still try another
|
||||
// credential, but this provider's health and circuit stay unchanged.
|
||||
ProviderHealthDisposition::Neutral
|
||||
} else if retryable_status(status) {
|
||||
ProviderHealthDisposition::Unhealthy
|
||||
} else {
|
||||
ProviderHealthDisposition::Healthy
|
||||
}
|
||||
}
|
||||
|
||||
async fn settle_provider_health(
|
||||
state: &ProxyState,
|
||||
catalog_epoch: u64,
|
||||
provider_id: &str,
|
||||
used_half_open_permit: bool,
|
||||
outcome: ProviderHealthOutcome,
|
||||
) {
|
||||
match outcome.disposition {
|
||||
ProviderHealthDisposition::Healthy => {
|
||||
record_provider_result(
|
||||
state,
|
||||
catalog_epoch,
|
||||
provider_id,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ProviderHealthDisposition::Unhealthy => {
|
||||
record_provider_result(
|
||||
state,
|
||||
catalog_epoch,
|
||||
provider_id,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
outcome.error,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ProviderHealthDisposition::Neutral => {
|
||||
state
|
||||
.provider_router
|
||||
.release_permit_neutral(provider_id, "pi", used_half_open_permit)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn release_or_record_provider(
|
||||
state: &ProxyState,
|
||||
catalog_epoch: u64,
|
||||
provider_id: &str,
|
||||
used_half_open_permit: bool,
|
||||
reached_network: bool,
|
||||
saw_health_failure: bool,
|
||||
error: Option<String>,
|
||||
provider_health_failure: Option<String>,
|
||||
) {
|
||||
if reached_network && saw_health_failure {
|
||||
if provider_health_failure.is_some() {
|
||||
record_provider_result(
|
||||
state,
|
||||
catalog_epoch,
|
||||
provider_id,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
error,
|
||||
provider_health_failure,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
@@ -714,6 +807,7 @@ struct PiStreamFinalization {
|
||||
content_is_sse: bool,
|
||||
used_half_open_permit: bool,
|
||||
selected_is_failover: bool,
|
||||
complete_provider_health: ProviderHealthOutcome,
|
||||
}
|
||||
|
||||
enum PiStreamTermination {
|
||||
@@ -723,7 +817,7 @@ enum PiStreamTermination {
|
||||
}
|
||||
|
||||
struct PiStreamDisposition {
|
||||
provider_success: Option<bool>,
|
||||
provider_health: ProviderHealthDisposition,
|
||||
provider_error: Option<String>,
|
||||
request_success: bool,
|
||||
request_error: Option<String>,
|
||||
@@ -731,29 +825,25 @@ struct PiStreamDisposition {
|
||||
|
||||
fn pi_stream_disposition(
|
||||
status: StatusCode,
|
||||
complete_provider_health: &ProviderHealthOutcome,
|
||||
termination: &PiStreamTermination,
|
||||
) -> PiStreamDisposition {
|
||||
match termination {
|
||||
PiStreamTermination::Complete { .. } => {
|
||||
let provider_success = !retryable_status(status);
|
||||
PiStreamDisposition {
|
||||
provider_success: Some(provider_success),
|
||||
provider_error: (!provider_success)
|
||||
.then(|| format!("Pi upstream returned {status}")),
|
||||
request_success: status.is_success(),
|
||||
request_error: (!status.is_success())
|
||||
.then(|| format!("Pi upstream returned {status}")),
|
||||
}
|
||||
}
|
||||
PiStreamTermination::Complete { .. } => PiStreamDisposition {
|
||||
provider_health: complete_provider_health.disposition,
|
||||
provider_error: complete_provider_health.error.clone(),
|
||||
request_success: status.is_success(),
|
||||
request_error: (!status.is_success()).then(|| format!("Pi upstream returned {status}")),
|
||||
},
|
||||
PiStreamTermination::UpstreamFailure { message } => PiStreamDisposition {
|
||||
provider_success: Some(false),
|
||||
provider_health: ProviderHealthDisposition::Unhealthy,
|
||||
provider_error: Some(message.clone()),
|
||||
request_success: false,
|
||||
request_error: Some(message.clone()),
|
||||
},
|
||||
PiStreamTermination::DownstreamDropped => PiStreamDisposition {
|
||||
// A downstream cancellation says nothing about upstream health.
|
||||
provider_success: None,
|
||||
provider_health: ProviderHealthDisposition::Neutral,
|
||||
provider_error: None,
|
||||
request_success: false,
|
||||
request_error: Some(
|
||||
@@ -801,27 +891,19 @@ impl PiStreamFinalization {
|
||||
}
|
||||
|
||||
async fn apply(self, termination: PiStreamTermination) {
|
||||
let disposition = pi_stream_disposition(self.status, &termination);
|
||||
if let Some(provider_success) = disposition.provider_success {
|
||||
record_provider_result(
|
||||
&self.state,
|
||||
self.catalog_epoch,
|
||||
&self.candidate.provider_id,
|
||||
self.used_half_open_permit,
|
||||
provider_success,
|
||||
disposition.provider_error,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
self.state
|
||||
.provider_router
|
||||
.release_permit_neutral(
|
||||
&self.candidate.provider_id,
|
||||
"pi",
|
||||
self.used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let disposition =
|
||||
pi_stream_disposition(self.status, &self.complete_provider_health, &termination);
|
||||
settle_provider_health(
|
||||
&self.state,
|
||||
self.catalog_epoch,
|
||||
&self.candidate.provider_id,
|
||||
self.used_half_open_permit,
|
||||
ProviderHealthOutcome {
|
||||
disposition: disposition.provider_health,
|
||||
error: disposition.provider_error,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
record_request_finish(
|
||||
&self.state,
|
||||
disposition.request_success,
|
||||
@@ -921,6 +1003,7 @@ async fn prepare_response(
|
||||
non_streaming_timeout_seconds: u32,
|
||||
used_half_open_permit: bool,
|
||||
selected_is_failover: bool,
|
||||
complete_provider_health: ProviderHealthOutcome,
|
||||
) -> Result<PreparedPiResponse, ProxyError> {
|
||||
let status = response.status();
|
||||
let headers = filtered_response_headers(response.headers());
|
||||
@@ -1019,6 +1102,7 @@ async fn prepare_response(
|
||||
streaming_idle_timeout_seconds,
|
||||
used_half_open_permit,
|
||||
selected_is_failover,
|
||||
complete_provider_health,
|
||||
);
|
||||
let mut builder = Response::builder().status(status);
|
||||
*builder
|
||||
@@ -1138,6 +1222,7 @@ fn logged_body_stream(
|
||||
streaming_idle_timeout_seconds: u32,
|
||||
used_half_open_permit: bool,
|
||||
selected_is_failover: bool,
|
||||
complete_provider_health: ProviderHealthOutcome,
|
||||
) -> impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
|
||||
// Construct the guard before the generator is polled. Axum may drop a
|
||||
// response body without ever polling it when the client disconnects after
|
||||
@@ -1154,6 +1239,7 @@ fn logged_body_stream(
|
||||
content_is_sse,
|
||||
used_half_open_permit,
|
||||
selected_is_failover,
|
||||
complete_provider_health,
|
||||
});
|
||||
async_stream::stream! {
|
||||
let mut captured = Vec::new();
|
||||
@@ -1358,6 +1444,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_rejections_remain_retryable_but_health_neutral() {
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/unauthorized",
|
||||
axum::routing::get(|| async { StatusCode::UNAUTHORIZED }),
|
||||
)
|
||||
.route(
|
||||
"/forbidden",
|
||||
axum::routing::get(|| async { StatusCode::FORBIDDEN }),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind local Pi capture endpoint");
|
||||
let address = listener.local_addr().expect("local capture address");
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("serve local Pi capture endpoint");
|
||||
});
|
||||
|
||||
for (path, expected) in [
|
||||
("unauthorized", StatusCode::UNAUTHORIZED),
|
||||
("forbidden", StatusCode::FORBIDDEN),
|
||||
] {
|
||||
let response = reqwest::get(format!("http://{address}/{path}"))
|
||||
.await
|
||||
.expect("request local Pi capture endpoint");
|
||||
assert_eq!(response.status(), expected);
|
||||
assert!(retryable_status(response.status()));
|
||||
assert_eq!(
|
||||
ProviderHealthOutcome::from_status(response.status(), None),
|
||||
ProviderHealthOutcome {
|
||||
disposition: ProviderHealthDisposition::Neutral,
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
ProviderHealthOutcome::from_status(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Some("earlier endpoint failed"),
|
||||
),
|
||||
ProviderHealthOutcome {
|
||||
disposition: ProviderHealthDisposition::Unhealthy,
|
||||
error: Some("earlier endpoint failed".to_string()),
|
||||
}
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_comments_do_not_cross_the_commit_fence() {
|
||||
assert!(!contains_semantic_sse_event(
|
||||
@@ -1497,33 +1634,61 @@ mod tests {
|
||||
fn streaming_health_waits_for_the_terminal_outcome() {
|
||||
let complete = pi_stream_disposition(
|
||||
StatusCode::OK,
|
||||
&ProviderHealthOutcome::from_status(StatusCode::OK, None),
|
||||
&PiStreamTermination::Complete {
|
||||
captured: Some(Vec::new()),
|
||||
},
|
||||
);
|
||||
assert_eq!(complete.provider_success, Some(true));
|
||||
assert_eq!(complete.provider_health, ProviderHealthDisposition::Healthy);
|
||||
assert!(complete.request_success);
|
||||
|
||||
let truncated = pi_stream_disposition(
|
||||
StatusCode::OK,
|
||||
&ProviderHealthOutcome::from_status(StatusCode::OK, None),
|
||||
&PiStreamTermination::UpstreamFailure {
|
||||
message: "truncated".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(truncated.provider_success, Some(false));
|
||||
assert_eq!(
|
||||
truncated.provider_health,
|
||||
ProviderHealthDisposition::Unhealthy
|
||||
);
|
||||
assert!(!truncated.request_success);
|
||||
assert_eq!(truncated.provider_error.as_deref(), Some("truncated"));
|
||||
|
||||
let client_drop =
|
||||
pi_stream_disposition(StatusCode::OK, &PiStreamTermination::DownstreamDropped);
|
||||
assert_eq!(client_drop.provider_success, None);
|
||||
let client_drop = pi_stream_disposition(
|
||||
StatusCode::OK,
|
||||
&ProviderHealthOutcome::from_status(StatusCode::OK, None),
|
||||
&PiStreamTermination::DownstreamDropped,
|
||||
);
|
||||
assert_eq!(
|
||||
client_drop.provider_health,
|
||||
ProviderHealthDisposition::Neutral
|
||||
);
|
||||
assert!(!client_drop.request_success);
|
||||
|
||||
let non_retryable_error = pi_stream_disposition(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&ProviderHealthOutcome::from_status(StatusCode::BAD_REQUEST, None),
|
||||
&PiStreamTermination::Complete { captured: None },
|
||||
);
|
||||
assert_eq!(non_retryable_error.provider_success, Some(true));
|
||||
assert_eq!(
|
||||
non_retryable_error.provider_health,
|
||||
ProviderHealthDisposition::Healthy
|
||||
);
|
||||
assert!(!non_retryable_error.request_success);
|
||||
|
||||
for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
|
||||
let credential_rejection = pi_stream_disposition(
|
||||
status,
|
||||
&ProviderHealthOutcome::from_status(status, None),
|
||||
&PiStreamTermination::Complete { captured: None },
|
||||
);
|
||||
assert_eq!(
|
||||
credential_rejection.provider_health,
|
||||
ProviderHealthDisposition::Neutral
|
||||
);
|
||||
assert!(!credential_rejection.request_success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user