1use std::fmt;
4use std::time::Duration;
5
6use reqwest::StatusCode;
7use reqwest::header::HeaderMap;
8use serde_json::Value;
9
10use crate::retry::parse_retry_after;
11
12pub const REQUEST_ID_HEADER: &str = "x-typesafe-request-id";
14
15const MAX_RAW_BODY_IN_MESSAGE: usize = 200;
17
18pub type Result<T, E = Error> = std::result::Result<T, E>;
20
21#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum Error {
25 #[error("{0}")]
27 Config(String),
28
29 #[error("{0}")]
31 InvalidRequest(String),
32
33 #[error(transparent)]
35 Api(Box<ApiError>),
36
37 #[error("Connection error: {source}")]
39 Connection {
40 #[source]
42 source: reqwest::Error,
43 },
44
45 #[error("Request timed out after {}ms.", .timeout.as_millis())]
47 Timeout {
48 timeout: Duration,
50 },
51
52 #[error("{message}")]
54 Decode {
55 message: String,
57 request_id: Option<String>,
59 #[source]
61 source: Option<serde_json::Error>,
62 },
63
64 #[error("Could not get credentials: {source}")]
68 Credentials {
69 #[source]
71 source: crate::credentials::BoxError,
72 },
73
74 #[error("Unexpected answer \"{name}\": {reason}.")]
76 UnexpectedAnswer {
77 name: String,
79 reason: String,
81 },
82}
83
84impl Error {
85 pub fn status(&self) -> Option<StatusCode> {
87 self.api_error().map(ApiError::status)
88 }
89
90 pub fn request_id(&self) -> Option<&str> {
92 match self {
93 Error::Api(err) => err.request_id(),
94 Error::Decode { request_id, .. } => request_id.as_deref(),
95 _ => None,
96 }
97 }
98
99 pub fn api_error(&self) -> Option<&ApiError> {
101 match self {
102 Error::Api(err) => Some(err),
103 _ => None,
104 }
105 }
106
107 pub fn is_timeout(&self) -> bool {
109 matches!(self, Error::Timeout { .. })
110 }
111
112 pub fn is_connection(&self) -> bool {
114 matches!(self, Error::Connection { .. } | Error::Timeout { .. })
115 }
116}
117
118impl From<ApiError> for Error {
119 fn from(err: ApiError) -> Self {
120 Error::Api(Box::new(err))
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
126#[non_exhaustive]
127pub enum ApiErrorKind {
128 BadRequest,
130 Authentication,
132 PermissionDenied,
134 NotFound,
136 UnprocessableEntity,
138 RateLimit,
140 Overloaded,
143 InternalServer,
145 Other,
147}
148
149impl ApiErrorKind {
150 pub fn from_status(status: StatusCode) -> Self {
152 match status.as_u16() {
153 400 => Self::BadRequest,
154 401 => Self::Authentication,
155 403 => Self::PermissionDenied,
156 404 => Self::NotFound,
157 422 => Self::UnprocessableEntity,
158 429 => Self::RateLimit,
159 529 => Self::Overloaded,
160 500.. => Self::InternalServer,
161 _ => Self::Other,
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq)]
168pub enum ErrorBody {
169 Empty,
171 Json(Value),
173 Text(String),
175}
176
177impl ErrorBody {
178 pub fn parse(bytes: &[u8]) -> Self {
182 if bytes.is_empty() {
183 return Self::Empty;
184 }
185 match serde_json::from_slice(bytes) {
186 Ok(value) => Self::Json(value),
187 Err(_) => Self::Text(String::from_utf8_lossy(bytes).into_owned()),
188 }
189 }
190}
191
192#[derive(Debug, Clone)]
194pub struct ApiError {
195 kind: ApiErrorKind,
196 status: StatusCode,
197 headers: HeaderMap,
198 body: ErrorBody,
199 request_id: Option<String>,
200 message: String,
201}
202
203impl ApiError {
204 pub fn from_response(status: StatusCode, headers: HeaderMap, body: &[u8]) -> Self {
206 let body = ErrorBody::parse(body);
207 let message = describe(status, &body);
208 Self {
209 kind: ApiErrorKind::from_status(status),
210 request_id: request_id_from(&headers),
211 status,
212 headers,
213 body,
214 message,
215 }
216 }
217
218 pub fn kind(&self) -> ApiErrorKind {
220 self.kind
221 }
222
223 pub fn status(&self) -> StatusCode {
225 self.status
226 }
227
228 pub fn headers(&self) -> &HeaderMap {
230 &self.headers
231 }
232
233 pub fn body(&self) -> &ErrorBody {
235 &self.body
236 }
237
238 pub fn request_id(&self) -> Option<&str> {
240 self.request_id.as_deref()
241 }
242
243 pub fn retry_after(&self) -> Option<Duration> {
247 parse_retry_after(&self.headers, std::time::SystemTime::now())
248 }
249
250 pub fn message(&self) -> &str {
252 &self.message
253 }
254}
255
256impl fmt::Display for ApiError {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 f.write_str(&self.message)
259 }
260}
261
262impl std::error::Error for ApiError {}
263
264pub(crate) fn request_id_from(headers: &HeaderMap) -> Option<String> {
265 headers
266 .get(REQUEST_ID_HEADER)
267 .and_then(|value| value.to_str().ok())
268 .map(str::to_owned)
269}
270
271fn describe(status: StatusCode, body: &ErrorBody) -> String {
273 let status = status.as_u16();
274 if let Some(detail) = extract_message(body).filter(|detail| !detail.is_empty()) {
276 return format!("{status} {detail}");
277 }
278 let raw = match body {
279 ErrorBody::Empty => return format!("{status} status code (no body)"),
280 ErrorBody::Text(text) | ErrorBody::Json(Value::String(text)) => text.clone(),
281 ErrorBody::Json(value) => value.to_string(),
282 };
283 match raw.char_indices().nth(MAX_RAW_BODY_IN_MESSAGE) {
284 Some((cut, _)) => format!("{status} {}…", &raw[..cut]),
285 None => format!("{status} {raw}"),
286 }
287}
288
289fn extract_message(body: &ErrorBody) -> Option<String> {
291 let value = match body {
292 ErrorBody::Empty => return None,
293 ErrorBody::Text(text) => return non_empty(text),
294 ErrorBody::Json(value) => value,
295 };
296 if let Value::String(text) = value {
297 return non_empty(text);
298 }
299 let object = value.as_object()?;
300 let error = object.get("error");
301 let detail = object.get("detail");
302 if let Some(Value::String(text)) = error {
303 return Some(text.clone());
304 }
305 if let Some(text) = error.and_then(Value::as_object).and_then(nested_message) {
306 return Some(text);
307 }
308 if let Some(Value::String(text)) = object.get("message") {
309 return Some(text.clone());
310 }
311 match detail {
312 Some(Value::String(text)) => Some(text.clone()),
313 Some(Value::Object(inner)) => nested_message(inner),
314 Some(Value::Array(errors)) => describe_validation_errors(errors),
315 _ => None,
316 }
317}
318
319fn nested_message(object: &serde_json::Map<String, Value>) -> Option<String> {
326 ["message", "error_type"]
327 .into_iter()
328 .find_map(|key| object.get(key).and_then(Value::as_str))
329 .and_then(non_empty)
330}
331
332fn non_empty(text: &str) -> Option<String> {
333 (!text.is_empty()).then(|| text.to_owned())
334}
335
336fn describe_validation_errors(errors: &[Value]) -> Option<String> {
338 let parts: Vec<String> = errors
339 .iter()
340 .filter_map(|error| {
341 let msg = error.get("msg")?.as_str()?;
342 let loc = error
343 .get("loc")
344 .and_then(Value::as_array)
345 .map(|segments| {
346 segments
347 .iter()
348 .filter(|segment| segment.as_str() != Some("body"))
349 .map(loc_segment)
350 .collect::<Vec<_>>()
351 .join(".")
352 })
353 .unwrap_or_default();
354 Some(if loc.is_empty() {
355 msg.to_owned()
356 } else {
357 format!("{loc}: {msg}")
358 })
359 })
360 .collect();
361 (!parts.is_empty()).then(|| parts.join("; "))
362}
363
364fn loc_segment(segment: &Value) -> String {
365 match segment {
366 Value::String(text) => text.clone(),
367 Value::Null => String::new(),
368 other => other.to_string(),
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use reqwest::header::HeaderValue;
376 use serde_json::json;
377
378 fn message(status: u16, body: &[u8]) -> String {
379 ApiError::from_response(
380 StatusCode::from_u16(status).unwrap(),
381 HeaderMap::new(),
382 body,
383 )
384 .to_string()
385 }
386
387 #[test]
388 fn classifies_statuses() {
389 let kind = |s| ApiErrorKind::from_status(StatusCode::from_u16(s).unwrap());
390 assert_eq!(kind(400), ApiErrorKind::BadRequest);
391 assert_eq!(kind(401), ApiErrorKind::Authentication);
392 assert_eq!(kind(403), ApiErrorKind::PermissionDenied);
393 assert_eq!(kind(404), ApiErrorKind::NotFound);
394 assert_eq!(kind(422), ApiErrorKind::UnprocessableEntity);
395 assert_eq!(kind(429), ApiErrorKind::RateLimit);
396 assert_eq!(kind(500), ApiErrorKind::InternalServer);
397 assert_eq!(kind(529), ApiErrorKind::Overloaded);
398 assert_eq!(kind(599), ApiErrorKind::InternalServer);
399 assert_eq!(kind(409), ApiErrorKind::Other);
400 assert_eq!(kind(302), ApiErrorKind::Other);
401 }
402
403 #[test]
404 fn extracts_messages_from_common_shapes() {
405 assert_eq!(message(400, br#"{"error":"bad"}"#), "400 bad");
406 assert_eq!(
407 message(400, br#"{"error":{"message":"nested"}}"#),
408 "400 nested"
409 );
410 assert_eq!(message(400, br#"{"message":"plain"}"#), "400 plain");
411 assert_eq!(message(400, br#"{"detail":"why"}"#), "400 why");
412 assert_eq!(
413 message(400, br#"{"detail":{"message":"deep"}}"#),
414 "400 deep"
415 );
416 assert_eq!(message(400, br#""quoted""#), "400 quoted");
417 assert_eq!(message(502, b"Bad Gateway"), "502 Bad Gateway");
418 }
419
420 #[test]
423 fn error_type_is_used_when_there_is_no_message() {
424 assert_eq!(
425 message(400, br#"{"detail":{"error_type":"max_tokens_exceeded"}}"#),
426 "400 max_tokens_exceeded"
427 );
428 assert_eq!(
429 message(400, br#"{"error":{"error_type":"rate_limited"}}"#),
430 "400 rate_limited"
431 );
432 assert_eq!(
434 message(
435 400,
436 br#"{"detail":{"error_type":"bad","message":"be specific"}}"#
437 ),
438 "400 be specific"
439 );
440 }
441
442 #[test]
443 fn prefers_error_over_message_and_detail() {
444 assert_eq!(
445 message(400, br#"{"detail":"d","message":"m","error":"e"}"#),
446 "400 e"
447 );
448 assert_eq!(message(400, br#"{"detail":"d","message":"m"}"#), "400 m");
449 }
450
451 #[test]
452 fn formats_validation_errors_without_the_body_prefix() {
453 let body = json!({"detail": [
454 {"loc": ["body", "questions", "x", 0], "msg": "too short"},
455 {"loc": [], "msg": "bad state"},
456 {"msg": 3},
457 ]});
458 assert_eq!(
459 message(422, body.to_string().as_bytes()),
460 "422 questions.x.0: too short; bad state"
461 );
462 }
463
464 #[test]
465 fn falls_back_to_raw_body_or_no_body() {
466 assert_eq!(message(500, b""), "500 status code (no body)");
467 assert_eq!(message(500, br#"{"other":1}"#), r#"500 {"other":1}"#);
468 assert_eq!(message(500, br#"{"detail":[]}"#), r#"500 {"detail":[]}"#);
469 }
470
471 #[test]
472 fn empty_extracted_messages_fall_back_to_the_raw_body() {
473 assert_eq!(
476 message(400, br#"{"error":"","message":"m"}"#),
477 r#"400 {"error":"","message":"m"}"#
478 );
479 assert_eq!(message(400, br#"{"message":""}"#), r#"400 {"message":""}"#);
480 assert_eq!(
481 message(400, br#"{"error":{"message":""}}"#),
482 r#"400 {"error":{"message":""}}"#
483 );
484 assert_eq!(message(400, br#"{"detail":""}"#), r#"400 {"detail":""}"#);
485 }
486
487 #[test]
488 fn json_string_bodies_are_not_quoted() {
489 assert_eq!(message(400, br#""""#), "400 ");
490 }
491
492 #[test]
493 fn truncates_long_json_bodies_on_char_boundaries() {
494 let body = json!({ "x": "é".repeat(250) }).to_string();
495 let expected: String = body.chars().take(200).collect();
496 assert_eq!(message(500, body.as_bytes()), format!("500 {expected}…"));
497
498 let exact = json!({ "x": "a".repeat(192) }).to_string();
499 assert_eq!(exact.chars().count(), 200);
500 assert_eq!(message(500, exact.as_bytes()), format!("500 {exact}"));
501 }
502
503 #[test]
504 fn text_bodies_are_the_message_in_full() {
505 let long = "é".repeat(250);
506 assert_eq!(message(502, long.as_bytes()), format!("502 {long}"));
507 }
508
509 #[test]
510 fn exposes_request_id_and_retry_after() {
511 let mut headers = HeaderMap::new();
512 headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("req_1"));
513 headers.insert("retry-after", HeaderValue::from_static("7"));
514 let err = ApiError::from_response(StatusCode::TOO_MANY_REQUESTS, headers, b"{}");
515 assert_eq!(err.request_id(), Some("req_1"));
516 assert_eq!(err.retry_after(), Some(Duration::from_secs(7)));
517 assert_eq!(err.kind(), ApiErrorKind::RateLimit);
518
519 let wrapped = Error::from(err);
520 assert_eq!(wrapped.request_id(), Some("req_1"));
521 assert_eq!(wrapped.status(), Some(StatusCode::TOO_MANY_REQUESTS));
522 assert!(!wrapped.is_connection());
523 }
524
525 #[test]
526 fn timeouts_are_connection_errors() {
527 let err = Error::Timeout {
528 timeout: Duration::from_millis(1000),
529 };
530 assert!(err.is_timeout());
531 assert!(err.is_connection());
532 assert_eq!(err.to_string(), "Request timed out after 1000ms.");
533 }
534}