1use reqwest::StatusCode;
4use serde_json::Value;
5use std::fmt;
6
7#[derive(Debug, thiserror::Error)]
9#[non_exhaustive]
10pub enum Error {
11 #[error("configuration error: {0}")]
13 Config(String),
14
15 #[error("invalid request: {0}")]
17 InvalidRequest(String),
18
19 #[error(transparent)]
21 Api(ApiError),
22
23 #[error("connection error: {0}")]
26 Connection(String),
27
28 #[error("failed to decode response body: {0}")]
30 Decode(String),
31}
32
33impl Error {
34 pub fn status(&self) -> Option<StatusCode> {
36 match self {
37 Error::Api(e) => Some(e.status),
38 _ => None,
39 }
40 }
41
42 pub fn is_authentication(&self) -> bool {
44 self.status() == Some(StatusCode::UNAUTHORIZED)
45 }
46
47 pub fn is_rate_limited(&self) -> bool {
50 matches!(self.status().map(|s| s.as_u16()), Some(429) | Some(529))
51 }
52
53 pub fn is_validation(&self) -> bool {
56 matches!(self.status().map(|s| s.as_u16()), Some(400) | Some(422))
57 }
58}
59
60#[derive(Debug)]
62pub struct ApiError {
63 pub status: StatusCode,
65 pub message: String,
67 pub body: Option<Value>,
69}
70
71impl fmt::Display for ApiError {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 write!(f, "API error (status {}): {}", self.status, self.message)
74 }
75}
76
77impl std::error::Error for ApiError {}
78
79impl ApiError {
80 pub(crate) fn from_response(status: StatusCode, raw: &str) -> Self {
85 let body: Option<Value> = serde_json::from_str(raw).ok();
86 let message = body
87 .as_ref()
88 .and_then(|b| b.get("detail"))
89 .map(extract_detail_message)
90 .filter(|m| !m.is_empty())
91 .unwrap_or_else(|| {
92 if raw.is_empty() {
93 status
94 .canonical_reason()
95 .unwrap_or("unknown error")
96 .to_owned()
97 } else {
98 raw.chars().take(512).collect()
99 }
100 });
101 ApiError {
102 status,
103 message,
104 body,
105 }
106 }
107}
108
109fn extract_detail_message(detail: &Value) -> String {
110 match detail {
111 Value::String(s) => s.clone(),
112 Value::Object(map) => map
113 .get("message")
114 .and_then(Value::as_str)
115 .map(str::to_owned)
116 .unwrap_or_else(|| detail.to_string()),
117 Value::Array(items) => items
118 .iter()
119 .map(|item| {
120 let loc = item
121 .get("loc")
122 .and_then(Value::as_array)
123 .map(|parts| {
124 parts
125 .iter()
126 .filter_map(|p| {
127 p.as_str()
128 .map(str::to_owned)
129 .or_else(|| p.as_number().map(|n| n.to_string()))
130 })
131 .collect::<Vec<_>>()
132 .join(".")
133 })
134 .unwrap_or_default();
135 let msg = item.get("msg").and_then(Value::as_str).unwrap_or("invalid");
136 if loc.is_empty() {
137 msg.to_owned()
138 } else {
139 format!("{loc}: {msg}")
140 }
141 })
142 .collect::<Vec<_>>()
143 .join("; "),
144 other => other.to_string(),
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn parses_object_detail() {
154 let err = ApiError::from_response(
155 StatusCode::UNAUTHORIZED,
156 r#"{"detail":{"error_type":"authentication_error","message":"Cannot authenticate with the server. Please check your API key and try again."}}"#,
157 );
158 assert_eq!(
159 err.message,
160 "Cannot authenticate with the server. Please check your API key and try again."
161 );
162 assert_eq!(err.status, StatusCode::UNAUTHORIZED);
163 }
164
165 #[test]
166 fn parses_string_detail() {
167 let err = ApiError::from_response(
168 StatusCode::BAD_REQUEST,
169 r#"{"detail":"Noul question must have criteria or instructions: q"}"#,
170 );
171 assert_eq!(
172 err.message,
173 "Noul question must have criteria or instructions: q"
174 );
175 }
176
177 #[test]
178 fn parses_fastapi_validation_list() {
179 let err = ApiError::from_response(
180 StatusCode::UNPROCESSABLE_ENTITY,
181 r#"{"detail":[{"loc":["body","questions"],"msg":"field required","type":"value_error.missing"}]}"#,
182 );
183 assert_eq!(err.message, "body.questions: field required");
184 }
185
186 #[test]
187 fn falls_back_to_raw_body() {
188 let err = ApiError::from_response(StatusCode::INTERNAL_SERVER_ERROR, "oops");
189 assert_eq!(err.message, "oops");
190 assert!(err.body.is_none());
191 }
192}