Skip to main content

cosq_client/
error.rs

1//! Error types for cosq-client
2
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6pub enum ClientError {
7    #[error("authentication failed: {message}")]
8    Auth { message: String },
9
10    #[error("{}", format_request_error(.0))]
11    Request(#[from] reqwest::Error),
12
13    #[error("API error ({status}): {message}")]
14    Api { status: u16, message: String },
15
16    #[error("access denied: {message}\n\nHint: {hint}")]
17    Forbidden { message: String, hint: String },
18
19    #[error("not found: {message}")]
20    NotFound { message: String },
21
22    #[error("Azure CLI error: {message}\n\nHint: {hint}")]
23    AzCli { message: String, hint: String },
24
25    #[error("Azure OpenAI error: {message}")]
26    OpenAI { message: String },
27
28    #[error("local AI agent error: {message}")]
29    LocalAgent { message: String },
30
31    #[error("{0}")]
32    Other(String),
33}
34
35impl ClientError {
36    pub fn auth(msg: impl Into<String>) -> Self {
37        Self::Auth {
38            message: msg.into(),
39        }
40    }
41
42    pub fn az_cli(msg: impl Into<String>, hint: impl Into<String>) -> Self {
43        Self::AzCli {
44            message: msg.into(),
45            hint: hint.into(),
46        }
47    }
48
49    pub fn forbidden(msg: impl Into<String>, hint: impl Into<String>) -> Self {
50        Self::Forbidden {
51            message: extract_message(msg.into()),
52            hint: hint.into(),
53        }
54    }
55
56    pub fn not_found(msg: impl Into<String>) -> Self {
57        Self::NotFound {
58            message: msg.into(),
59        }
60    }
61
62    pub fn openai(msg: impl Into<String>) -> Self {
63        Self::OpenAI {
64            message: msg.into(),
65        }
66    }
67
68    pub fn local_agent(msg: impl Into<String>) -> Self {
69        Self::LocalAgent {
70            message: msg.into(),
71        }
72    }
73
74    pub fn api(status: u16, body: impl Into<String>) -> Self {
75        Self::Api {
76            status,
77            message: extract_message(body.into()),
78        }
79    }
80}
81
82/// Format a reqwest error with TLS-specific diagnostics when applicable
83fn format_request_error(err: &reqwest::Error) -> String {
84    if has_certificate_error(err) {
85        return "TLS certificate verification failed\n\n\
86             The remote server's certificate was not trusted. This typically happens on\n\
87             corporate networks that use TLS inspection with a custom CA certificate.\n\n\
88             Fix: Install the corporate root CA certificate into your operating system's\n\
89             certificate store:\n\
90             \x20 macOS:   Add to Keychain Access > System > Certificates\n\
91             \x20 Linux:   Copy to /usr/local/share/ca-certificates/ and run update-ca-certificates\n\
92             \x20 Windows: Import via certmgr.msc > Trusted Root Certification Authorities"
93            .to_string();
94    }
95    format!("HTTP request failed: {err}")
96}
97
98/// Check if a reqwest error is caused by a TLS certificate verification failure
99fn has_certificate_error(err: &reqwest::Error) -> bool {
100    use std::error::Error;
101    let mut source = err.source();
102    while let Some(cause) = source {
103        let msg = cause.to_string();
104        if msg.contains("certificate") || msg.contains("UnknownIssuer") {
105            return true;
106        }
107        source = cause.source();
108    }
109    false
110}
111
112/// Try to extract a human-readable message from a Cosmos DB JSON error body.
113/// Falls back to the raw string if parsing fails.
114fn extract_message(body: String) -> String {
115    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
116        if let Some(msg) = json["message"].as_str().or(json["Message"].as_str()) {
117            // Cosmos DB often appends "\r\nActivityId: ..." — strip that
118            let clean = msg.split("\r\nActivityId:").next().unwrap_or(msg).trim();
119            return clean.to_string();
120        }
121    }
122    body
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_extract_message_cosmos_json() {
131        let body = r#"{"code":"Forbidden","message":"Request blocked by Auth mklabcosdb : Request is blocked because principal [abc-123] does not have required RBAC permissions to perform action [Microsoft.DocumentDB/databaseAccounts/readMetadata] on any scope. Learn more: https://aka.ms/cosmos-native-rbac.\r\nActivityId: c93b2c4e-faf8-4a23-848e-1f03c0e0d8a7, Microsoft.Azure.Documents.Common/2.14.0"}"#;
132        let msg = extract_message(body.to_string());
133        assert!(msg.starts_with("Request blocked by Auth"));
134        assert!(msg.contains("readMetadata"));
135        assert!(!msg.contains("ActivityId:"));
136    }
137
138    #[test]
139    fn test_extract_message_plain_text() {
140        let body = "something went wrong";
141        let msg = extract_message(body.to_string());
142        assert_eq!(msg, "something went wrong");
143    }
144
145    #[test]
146    fn test_extract_message_json_without_message_field() {
147        let body = r#"{"error": "oops"}"#;
148        let msg = extract_message(body.to_string());
149        assert_eq!(msg, body);
150    }
151
152    #[test]
153    fn test_has_certificate_error_detection() {
154        let check =
155            |msg: &str| -> bool { msg.contains("certificate") || msg.contains("UnknownIssuer") };
156        assert!(check("invalid peer certificate: UnknownIssuer"));
157        assert!(check("certificate verify failed"));
158        assert!(check("self signed certificate in certificate chain"));
159        assert!(!check("connection refused"));
160        assert!(!check("timeout"));
161    }
162
163    #[test]
164    fn test_format_request_error_cert_message() {
165        // Verify the TLS diagnostic message contains key guidance
166        let msg = format!(
167            "TLS certificate verification failed\n\n\
168             The remote server's certificate was not trusted. This typically happens on\n\
169             corporate networks that use TLS inspection with a custom CA certificate."
170        );
171        assert!(msg.contains("TLS certificate verification failed"));
172        assert!(msg.contains("corporate networks"));
173    }
174
175    #[test]
176    fn test_extract_message_capital_message() {
177        let body = r#"{"Message": "Something failed"}"#;
178        let msg = extract_message(body.to_string());
179        assert_eq!(msg, "Something failed");
180    }
181}