1use 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
82fn 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
98fn 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
112fn 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 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 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}