From b04abe0ea5de802d7a8bff8985629ac44a208d93 Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Wed, 4 Mar 2026 06:15:52 -0500 Subject: [PATCH] fix(providers): surface TLS root causes for custom endpoint retries --- src/providers/compatible.rs | 147 +++++++++++++++++++++++++++++++++--- 1 file changed, 138 insertions(+), 9 deletions(-) diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs index 268ebfb54..b43cdce90 100644 --- a/src/providers/compatible.rs +++ b/src/providers/compatible.rs @@ -1092,6 +1092,64 @@ fn compact_sanitized_body_snippet(body: &str) -> String { .join(" ") } +fn is_tls_or_certificate_error(detail: &str) -> bool { + let lower = detail.to_ascii_lowercase(); + let hints = [ + "certificate", + "tls", + "ssl", + "x509", + "unknownissuer", + "unknown issuer", + "self-signed", + "self signed", + "invalid peer certificate", + "certificate verify failed", + "unable to get local issuer", + "ca cert", + ]; + hints.iter().any(|hint| lower.contains(hint)) +} + +fn format_transport_error(error: &anyhow::Error) -> String { + let mut chain = Vec::new(); + for cause in error.chain() { + let detail = compact_sanitized_body_snippet(&cause.to_string()); + if detail.is_empty() { + continue; + } + if chain.last() != Some(&detail) { + chain.push(detail); + } + } + + if chain.is_empty() { + return "transport error".to_string(); + } + + let tls_detail = chain + .iter() + .rev() + .find(|item| is_tls_or_certificate_error(item)) + .cloned(); + let specific_detail = tls_detail + .clone() + .unwrap_or_else(|| chain.last().cloned().unwrap_or_default()); + let request_detail = chain.first().cloned().unwrap_or_default(); + + let mut formatted = if specific_detail == request_detail || request_detail.is_empty() { + specific_detail + } else { + format!("{specific_detail} (request: {request_detail})") + }; + + if tls_detail.is_some() { + formatted.push_str(" [hint: verify endpoint/proxy certificate trust]"); + } + + formatted +} + fn parse_chat_response_body(provider_name: &str, body: &str) -> anyhow::Result { serde_json::from_str::(body).map_err(|error| { let snippet = compact_sanitized_body_snippet(body); @@ -1693,13 +1751,14 @@ impl Provider for OpenAiCompatibleProvider { Ok(response) => response, Err(chat_error) => { if self.supports_responses_fallback { - let sanitized = super::sanitize_api_error(&chat_error.to_string()); + let transport_error = format_transport_error(&anyhow::Error::new(chat_error)); return self .chat_via_responses(credential, &fallback_messages, model) .await .map_err(|responses_err| { + let responses_error = format_transport_error(&responses_err); anyhow::anyhow!( - "{} chat completions transport error: {sanitized} (responses fallback failed: {responses_err})", + "{} chat completions transport error: {transport_error} (responses fallback failed: {responses_error})", self.name ) }); @@ -1719,8 +1778,9 @@ impl Provider for OpenAiCompatibleProvider { .chat_via_responses(credential, &fallback_messages, model) .await .map_err(|responses_err| { + let responses_error = format_transport_error(&responses_err); anyhow::anyhow!( - "{} API error ({status}): {sanitized} (chat completions unavailable; responses fallback failed: {responses_err})", + "{} API error ({status}): {sanitized} (chat completions unavailable; responses fallback failed: {responses_error})", self.name ) }); @@ -1810,13 +1870,14 @@ impl Provider for OpenAiCompatibleProvider { Ok(response) => response, Err(chat_error) => { if self.supports_responses_fallback { - let sanitized = super::sanitize_api_error(&chat_error.to_string()); + let transport_error = format_transport_error(&anyhow::Error::new(chat_error)); return self .chat_via_responses(credential, &effective_messages, model) .await .map_err(|responses_err| { + let responses_error = format_transport_error(&responses_err); anyhow::anyhow!( - "{} chat completions transport error: {sanitized} (responses fallback failed: {responses_err})", + "{} chat completions transport error: {transport_error} (responses fallback failed: {responses_error})", self.name ) }); @@ -1835,8 +1896,9 @@ impl Provider for OpenAiCompatibleProvider { .chat_via_responses(credential, &effective_messages, model) .await .map_err(|responses_err| { + let responses_error = format_transport_error(&responses_err); anyhow::anyhow!( - "{} API error (chat completions unavailable; responses fallback failed: {responses_err})", + "{} API error (chat completions unavailable; responses fallback failed: {responses_error})", self.name ) }); @@ -2063,7 +2125,7 @@ impl Provider for OpenAiCompatibleProvider { Ok(response) => response, Err(chat_error) => { if self.supports_responses_fallback { - let sanitized = super::sanitize_api_error(&chat_error.to_string()); + let transport_error = format_transport_error(&anyhow::Error::new(chat_error)); return self .chat_via_responses_chat( credential, @@ -2073,8 +2135,9 @@ impl Provider for OpenAiCompatibleProvider { ) .await .map_err(|responses_err| { + let responses_error = format_transport_error(&responses_err); anyhow::anyhow!( - "{} native chat transport error: {sanitized} (responses fallback failed: {responses_err})", + "{} native chat transport error: {transport_error} (responses fallback failed: {responses_error})", self.name ) }); @@ -2113,8 +2176,9 @@ impl Provider for OpenAiCompatibleProvider { ) .await .map_err(|responses_err| { + let responses_error = format_transport_error(&responses_err); anyhow::anyhow!( - "{} API error ({status}): {sanitized} (chat completions unavailable; responses fallback failed: {responses_err})", + "{} API error ({status}): {sanitized} (chat completions unavailable; responses fallback failed: {responses_error})", self.name ) }); @@ -2271,11 +2335,50 @@ impl Provider for OpenAiCompatibleProvider { #[cfg(test)] mod tests { use super::*; + use std::error::Error as StdError; + use std::fmt; fn make_provider(name: &str, url: &str, key: Option<&str>) -> OpenAiCompatibleProvider { OpenAiCompatibleProvider::new(name, url, key, AuthStyle::Bearer) } + #[derive(Debug)] + struct NestedTestError { + message: &'static str, + source: Option>, + } + + impl NestedTestError { + fn leaf(message: &'static str) -> Self { + Self { + message, + source: None, + } + } + + fn with_source( + message: &'static str, + source: impl StdError + Send + Sync + 'static, + ) -> Self { + Self { + message, + source: Some(Box::new(source)), + } + } + } + + impl fmt::Display for NestedTestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } + } + + impl StdError for NestedTestError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source.as_deref().map(|err| err as _) + } + } + #[test] fn creates_with_key() { let p = make_provider( @@ -2300,6 +2403,32 @@ mod tests { assert_eq!(p.base_url, "https://example.com"); } + #[test] + fn format_transport_error_prioritizes_certificate_root_cause() { + let err = anyhow::Error::new(NestedTestError::with_source( + "error sending request for url (https://coding.dashscope.aliyuncs.com/v1/chat/completions)", + NestedTestError::leaf("invalid peer certificate: UnknownIssuer"), + )); + + let detail = format_transport_error(&err); + assert!(detail.contains("invalid peer certificate: UnknownIssuer")); + assert!(detail.contains("request: error sending request for url")); + assert!(detail.contains("certificate trust")); + } + + #[test] + fn format_transport_error_uses_deepest_non_tls_cause() { + let err = anyhow::Error::new(NestedTestError::with_source( + "error sending request for url (https://api.example.com/v1/chat/completions)", + NestedTestError::leaf("dns lookup failed"), + )); + + let detail = format_transport_error(&err); + assert!(detail.contains("dns lookup failed")); + assert!(detail.contains("request: error sending request for url")); + assert!(!detail.contains("certificate trust")); + } + #[tokio::test] async fn chat_fails_without_key() { let p = make_provider("Venice", "https://api.venice.ai", None);