1use std::{fmt, time::Duration};
4
5use reqwest::{header::HeaderMap, StatusCode, Url};
6use serde_json::Value;
7
8pub enum Error {
10 Configuration(String),
12 InvalidRequest(String),
14 Api(Box<ApiError>),
16 ResponseValidation {
18 field_path: String,
20 response: Box<crate::RawResponse>,
22 },
23 Connection(reqwest::Error),
25 Timeout {
27 timeout: Duration,
29 source: reqwest::Error,
31 },
32 Cancelled,
34}
35
36impl Error {
37 pub fn status(&self) -> Option<StatusCode> {
39 match self {
40 Self::Api(error) => Some(error.status),
41 Self::ResponseValidation { response, .. } => Some(response.status),
42 _ => None,
43 }
44 }
45
46 pub fn request_id(&self) -> Option<&str> {
48 match self {
49 Self::Api(error) => error.request_id(),
50 Self::ResponseValidation { response, .. } => response.request_id(),
51 _ => None,
52 }
53 }
54
55 pub fn as_api_error(&self) -> Option<&ApiError> {
57 match self {
58 Self::Api(error) => Some(error),
59 _ => None,
60 }
61 }
62}
63
64impl fmt::Display for Error {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 Self::Configuration(message) => write!(f, "Invalid configuration: {message}"),
68 Self::InvalidRequest(message) => write!(f, "Invalid request: {message}"),
69 Self::Api(error) => error.fmt(f),
70 Self::ResponseValidation {
71 field_path,
72 response,
73 } => {
74 write!(
75 f,
76 "{} Invalid response data at {field_path:?}",
77 response.status
78 )?;
79 if let Some(request_id) = response.request_id() {
80 write!(f, " (request_id={request_id})")?;
81 }
82 Ok(())
83 }
84 Self::Connection(_) => f.write_str("Request connection failed"),
85 Self::Timeout { timeout, .. } => {
86 write!(f, "Request timed out (timeout={timeout:?})")
87 }
88 Self::Cancelled => f.write_str("Request was cancelled"),
89 }
90 }
91}
92
93impl fmt::Debug for Error {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {
98 Self::Api(error) => f.debug_tuple("Api").field(error).finish(),
99 Self::ResponseValidation {
100 field_path,
101 response,
102 } => f
103 .debug_struct("ResponseValidation")
104 .field("field_path", field_path)
105 .field("status", &response.status)
106 .field("request_id", &response.request_id())
107 .finish_non_exhaustive(),
108 _ => fmt::Display::fmt(self, f),
109 }
110 }
111}
112
113impl std::error::Error for Error {
114 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
115 match self {
116 Self::Api(error) => Some(error.as_ref()),
117 Self::Connection(error) | Self::Timeout { source: error, .. } => Some(error),
118 _ => None,
119 }
120 }
121}
122
123impl From<ApiError> for Error {
124 fn from(error: ApiError) -> Self {
125 Self::Api(Box::new(error))
126 }
127}
128
129#[derive(Clone, Copy, Debug, Eq, PartialEq)]
131#[non_exhaustive]
132pub enum ApiErrorKind {
133 BadRequest,
135 Authentication,
137 PermissionDenied,
139 NotFound,
141 UnprocessableEntity,
143 RateLimit,
145 InternalServer,
147 Other,
149}
150
151#[derive(Clone)]
156pub struct ApiError {
157 pub status: StatusCode,
159 pub body: Value,
161 pub headers: HeaderMap,
163 pub endpoint: Option<String>,
165}
166
167impl ApiError {
168 pub fn new(
170 status: StatusCode,
171 body: Value,
172 headers: HeaderMap,
173 endpoint: Option<String>,
174 ) -> Self {
175 Self {
176 status,
177 body,
178 headers,
179 endpoint: endpoint.map(|endpoint| sanitize_endpoint(&endpoint)),
180 }
181 }
182
183 pub fn kind(&self) -> ApiErrorKind {
185 match self.status.as_u16() {
186 400 => ApiErrorKind::BadRequest,
187 401 => ApiErrorKind::Authentication,
188 403 => ApiErrorKind::PermissionDenied,
189 404 => ApiErrorKind::NotFound,
190 422 => ApiErrorKind::UnprocessableEntity,
191 429 => ApiErrorKind::RateLimit,
192 500..=599 => ApiErrorKind::InternalServer,
193 _ => ApiErrorKind::Other,
194 }
195 }
196
197 pub fn request_id(&self) -> Option<&str> {
199 self.headers
200 .get("x-typesafe-request-id")
201 .and_then(|value| value.to_str().ok())
202 }
203
204 pub fn retry_after(&self) -> Option<Duration> {
206 crate::retry::parse_retry_after(&self.headers)
207 }
208
209 pub fn message(&self) -> String {
214 if let Some(message) = extract_message(&self.body).filter(|message| !message.is_empty()) {
215 return message;
216 }
217 if self.body.is_null() {
218 return "status code (no body)".into();
219 }
220 let raw = self
221 .body
222 .as_str()
223 .map(str::to_owned)
224 .unwrap_or_else(|| self.body.to_string());
225 let mut chars = raw.chars();
226 let mut message: String = chars.by_ref().take(200).collect();
227 if chars.next().is_some() {
228 message.push('…');
229 }
230 message
231 }
232}
233
234impl fmt::Display for ApiError {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 if let Some(endpoint) = &self.endpoint {
237 write!(f, "{}: ", sanitize_endpoint(endpoint))?;
238 }
239 write!(f, "{}", self.status)?;
240 if let Some(request_id) = self.request_id() {
241 write!(f, " (request_id={request_id})")?;
242 }
243 Ok(())
244 }
245}
246
247impl fmt::Debug for ApiError {
248 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249 f.debug_struct("ApiError")
250 .field("kind", &self.kind())
251 .field("status", &self.status)
252 .field("endpoint", &self.endpoint.as_deref().map(sanitize_endpoint))
253 .field("request_id", &self.request_id())
254 .finish_non_exhaustive()
255 }
256}
257
258impl std::error::Error for ApiError {}
259
260fn sanitize_endpoint(endpoint: &str) -> String {
261 let (method, raw_url) = match endpoint.split_once(' ') {
262 Some((method, url)) if method.bytes().all(|byte| byte.is_ascii_uppercase()) => {
263 (Some(method), url)
264 }
265 _ => (None, endpoint),
266 };
267 let sanitized = match Url::parse(raw_url) {
268 Ok(mut url) if matches!(url.scheme(), "http" | "https") => {
269 let _ = url.set_username("");
270 let _ = url.set_password(None);
271 url.set_query(None);
272 url.set_fragment(None);
273 url.to_string()
274 }
275 _ => "<invalid URL>".into(),
276 };
277 match method {
278 Some(method) => format!("{method} {sanitized}"),
279 None => sanitized,
280 }
281}
282
283fn extract_message(body: &Value) -> Option<String> {
284 if let Some(message) = body.as_str() {
285 return Some(message.to_owned());
286 }
287 let body = body.as_object()?;
288 let error = body.get("error");
289 let detail = body.get("detail");
290 let message = error
291 .and_then(Value::as_str)
292 .or_else(|| error?.get("message")?.as_str())
293 .or_else(|| body.get("message")?.as_str())
294 .or_else(|| detail?.as_str())
295 .or_else(|| detail?.get("message")?.as_str());
296 if let Some(message) = message {
297 return Some(message.to_owned());
298 }
299 let parts: Vec<_> = detail?
300 .as_array()?
301 .iter()
302 .filter_map(|entry| {
303 let message = entry.get("msg")?.as_str()?;
304 let path = entry
305 .get("loc")
306 .and_then(Value::as_array)
307 .map(|location| {
308 location
309 .iter()
310 .filter(|part| part.as_str() != Some("body"))
311 .map(|part| {
312 part.as_str()
313 .map(str::to_owned)
314 .unwrap_or_else(|| part.to_string())
315 })
316 .collect::<Vec<_>>()
317 .join(".")
318 })
319 .unwrap_or_default();
320 Some(if path.is_empty() {
321 message.to_owned()
322 } else {
323 format!("{path}: {message}")
324 })
325 })
326 .collect();
327 (!parts.is_empty()).then(|| parts.join("; "))
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use serde_json::json;
334
335 #[test]
336 fn classifies_all_specialized_statuses() {
337 for (status, kind) in [
338 (400, ApiErrorKind::BadRequest),
339 (401, ApiErrorKind::Authentication),
340 (403, ApiErrorKind::PermissionDenied),
341 (404, ApiErrorKind::NotFound),
342 (422, ApiErrorKind::UnprocessableEntity),
343 (429, ApiErrorKind::RateLimit),
344 (500, ApiErrorKind::InternalServer),
345 (599, ApiErrorKind::InternalServer),
346 (409, ApiErrorKind::Other),
347 ] {
348 assert_eq!(
349 ApiError::new(
350 StatusCode::from_u16(status).unwrap(),
351 Value::Null,
352 HeaderMap::new(),
353 None,
354 )
355 .kind(),
356 kind,
357 );
358 }
359 }
360
361 #[test]
362 fn extracts_message_shapes_and_validation_paths() {
363 for (body, expected) in [
364 (json!("plain"), "plain"),
365 (json!({"error": "error", "message": "message"}), "error"),
366 (json!({"error": {"message": "nested"}}), "nested"),
367 (json!({"message": "message"}), "message"),
368 (json!({"detail": "detail"}), "detail"),
369 (
370 json!({"detail": {"message": "nested detail"}}),
371 "nested detail",
372 ),
373 (
374 json!({"detail": [
375 {"loc": ["body", "questions", 0, "name"], "msg": "required"},
376 {"msg": "invalid input"},
377 {"loc": ["ignored"]}
378 ]}),
379 "questions.0.name: required; invalid input",
380 ),
381 ] {
382 let error = ApiError::new(StatusCode::BAD_REQUEST, body, HeaderMap::new(), None);
383 assert_eq!(error.message(), expected);
384 }
385 }
386
387 #[test]
388 fn logging_omits_response_data_and_sanitizes_urls() {
389 let mut headers = HeaderMap::new();
390 headers.insert("authorization", "Bearer secret-header".parse().unwrap());
391 headers.insert("x-typesafe-request-id", "req-123".parse().unwrap());
392 let error = ApiError::new(
393 StatusCode::UNAUTHORIZED,
394 json!({"message": "secret-body"}),
395 headers,
396 Some("POST https://user:secret-password@example.com/v1/extract?key=secret-query#secret-fragment".into()),
397 );
398 assert_eq!(
399 error.endpoint.as_deref(),
400 Some("POST https://example.com/v1/extract")
401 );
402 for formatted in [
403 format!("{error}"),
404 format!("{error:?}"),
405 format!("{:?}", Error::from(error)),
406 ] {
407 assert!(formatted.contains("req-123"));
408 assert!(!formatted.contains("secret"));
409 assert!(!formatted.contains("user:"));
410 }
411 }
412
413 #[test]
414 fn raw_fallback_truncates_on_unicode_character_boundaries() {
415 let error = ApiError::new(
416 StatusCode::BAD_REQUEST,
417 json!(["é".repeat(300)]),
418 HeaderMap::new(),
419 None,
420 );
421 assert_eq!(error.message().chars().count(), 201);
422 assert!(error.message().ends_with('…'));
423 }
424
425 #[test]
426 fn validation_errors_retain_metadata_without_logging_raw_response() {
427 let mut headers = HeaderMap::new();
428 headers.insert("x-typesafe-request-id", "req-456".parse().unwrap());
429 headers.insert("set-cookie", "secret-session".parse().unwrap());
430 let error = Error::ResponseValidation {
431 field_path: "answers.tone.confidence".into(),
432 response: Box::new(crate::RawResponse {
433 status: StatusCode::OK,
434 headers,
435 body: b"secret-document".to_vec().into(),
436 }),
437 };
438 assert_eq!(error.status(), Some(StatusCode::OK));
439 assert_eq!(error.request_id(), Some("req-456"));
440 for formatted in [format!("{error}"), format!("{error:?}")] {
441 assert!(formatted.contains("answers.tone.confidence"));
442 assert!(formatted.contains("req-456"));
443 assert!(!formatted.contains("secret"));
444 }
445 }
446}