1use std::fmt;
9use std::time::Duration;
10
11use serde_json::Value;
12
13use crate::REQUEST_ID_HEADER;
14
15const MAX_RAW_BODY_IN_MESSAGE: usize = 200;
17
18#[derive(Debug, Clone)]
23pub struct ApiError {
24 pub status: u16,
26 pub kind: ApiErrorKind,
28 pub message: String,
30 pub body: Option<Value>,
33 pub headers: reqwest::header::HeaderMap,
35 pub request_id: Option<String>,
37 pub endpoint: String,
39 pub retry_after: Option<Duration>,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum ApiErrorKind {
47 BadRequest,
49 Authentication,
51 PermissionDenied,
53 NotFound,
55 UnprocessableEntity,
57 RateLimit,
59 InternalServer,
61 Other,
63}
64
65impl ApiErrorKind {
66 pub fn from_status(status: u16) -> Self {
68 match status {
69 400 => Self::BadRequest,
70 401 => Self::Authentication,
71 403 => Self::PermissionDenied,
72 404 => Self::NotFound,
73 422 => Self::UnprocessableEntity,
74 429 => Self::RateLimit,
75 s if (500..=599).contains(&s) => Self::InternalServer,
76 _ => Self::Other,
77 }
78 }
79}
80
81impl fmt::Display for ApiErrorKind {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 let name = match self {
84 Self::BadRequest => "bad request",
85 Self::Authentication => "authentication",
86 Self::PermissionDenied => "permission denied",
87 Self::NotFound => "not found",
88 Self::UnprocessableEntity => "unprocessable entity",
89 Self::RateLimit => "rate limit",
90 Self::InternalServer => "internal server",
91 Self::Other => "other",
92 };
93 f.write_str(name)
94 }
95}
96
97impl fmt::Display for ApiError {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 write!(f, "{}: {} {}", self.endpoint, self.status, self.message)?;
100 if let Some(request_id) = &self.request_id {
101 write!(f, " (request_id={request_id})")?;
102 }
103 Ok(())
104 }
105}
106
107impl std::error::Error for ApiError {}
108
109pub(super) fn extract_message(body: &Value) -> Option<String> {
120 match body {
121 Value::String(s) => {
122 if s.is_empty() {
123 return None;
124 }
125 Some(truncate_chars(s))
126 }
127 Value::Object(map) => {
128 let error = map.get("error");
129 if let Some(Value::String(error)) = error {
130 return Some(error.clone());
131 }
132 if let Some(Value::Object(error)) = error {
133 if let Some(Value::String(message)) = error.get("message") {
134 return Some(message.clone());
135 }
136 }
137 if let Some(Value::String(message)) = map.get("message") {
138 return Some(message.clone());
139 }
140 let detail = map.get("detail");
141 if let Some(Value::String(detail)) = detail {
142 return Some(detail.clone());
143 }
144 if let Some(Value::Object(detail)) = detail {
145 if let Some(Value::String(message)) = detail.get("message") {
146 return Some(message.clone());
147 }
148 }
149 if let Some(Value::Array(entries)) = detail {
150 let mut parts = Vec::new();
151 for entry in entries {
152 let Value::Object(entry) = entry else {
153 continue;
154 };
155 let Some(Value::String(msg)) = entry.get("msg") else {
156 continue;
157 };
158 let path = match entry.get("loc") {
159 Some(Value::Array(location)) => location
160 .iter()
161 .filter(|item| **item != Value::String("body".into()))
162 .map(|item| match item {
163 Value::String(s) => s.clone(),
164 other => other.to_string(),
165 })
166 .collect::<Vec<_>>()
167 .join("."),
168 _ => String::new(),
169 };
170 parts.push(if path.is_empty() {
171 msg.clone()
172 } else {
173 format!("{path}: {msg}")
174 });
175 }
176 if !parts.is_empty() {
177 return Some(parts.join("; "));
178 }
179 }
180 None
181 }
182 _ => None,
183 }
184}
185
186fn truncate_chars(raw: &str) -> String {
189 if raw.chars().count() > MAX_RAW_BODY_IN_MESSAGE {
190 let mut truncated: String = raw.chars().take(MAX_RAW_BODY_IN_MESSAGE).collect();
191 truncated.push('…');
192 truncated
193 } else {
194 raw.to_owned()
195 }
196}
197
198pub(super) fn truncated_body(body: &Value) -> String {
201 let raw = match body {
202 Value::String(s) => s.clone(),
203 other => other.to_string(),
204 };
205 truncate_chars(&raw)
206}
207
208pub(super) fn api_error_message(body: &Option<Value>) -> String {
213 if let Some(body) = body {
214 if let Some(message) = extract_message(body) {
215 return message;
216 }
217 if matches!(body, Value::String(s) if s.is_empty()) || (body.is_null() && false) {
218 return "status code (no body)".into();
219 }
220 return truncated_body(body);
221 }
222 "status code (no body)".into()
223}
224
225#[derive(Debug)]
231#[non_exhaustive]
232pub enum Error {
233 Config(String),
236 InvalidRequest(String),
238 Api(Box<ApiError>),
240 Connection {
242 message: String,
244 source: Option<Box<dyn std::error::Error + Send + Sync>>,
246 },
247 Timeout {
249 timeout: Duration,
251 },
252 ResponseValidation {
254 status: u16,
256 field_path: String,
259 request_id: Option<String>,
261 endpoint: String,
263 },
264}
265
266impl fmt::Display for Error {
267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268 match self {
269 Self::Config(message) => write!(f, "configuration error: {message}"),
270 Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
271 Self::Api(error) => write!(f, "{error}"),
272 Self::Connection { message, .. } => write!(f, "connection error: {message}"),
273 Self::Timeout { timeout } => {
274 write!(f, "request timed out (timeout={:?})", timeout)
275 }
276 Self::ResponseValidation { field_path, .. } => {
277 write!(f, "invalid response data at '{field_path}'")
278 }
279 }
280 }
281}
282
283impl std::error::Error for Error {
284 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
285 match self {
286 Self::Api(error) => Some(error.as_ref()),
287 Self::Connection {
288 source: Some(source),
289 ..
290 } => Some(source.as_ref()),
291 _ => None,
292 }
293 }
294}
295
296impl From<ApiError> for Error {
297 fn from(error: ApiError) -> Self {
298 Self::Api(Box::new(error))
299 }
300}
301
302impl Error {
303 pub fn is_timeout(&self) -> bool {
305 matches!(self, Self::Timeout { .. })
306 }
307
308 pub fn is_connection(&self) -> bool {
311 matches!(self, Self::Connection { .. } | Self::Timeout { .. })
312 }
313
314 pub fn status(&self) -> Option<u16> {
316 match self {
317 Self::Api(error) => Some(error.status),
318 Self::ResponseValidation { status, .. } => Some(*status),
319 _ => None,
320 }
321 }
322
323 pub fn request_id(&self) -> Option<&str> {
325 match self {
326 Self::Api(error) => error.request_id.as_deref(),
327 Self::ResponseValidation { request_id, .. } => request_id.as_deref(),
328 _ => None,
329 }
330 }
331
332 pub fn as_api(&self) -> Option<&ApiError> {
334 match self {
335 Self::Api(error) => Some(error),
336 _ => None,
337 }
338 }
339}
340
341pub(super) fn request_id_of(headers: &reqwest::header::HeaderMap) -> Option<String> {
343 headers
344 .get(REQUEST_ID_HEADER)
345 .and_then(|value| value.to_str().ok())
346 .map(str::to_owned)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use serde_json::json;
353
354 fn api_error(status: u16, body: Value, request_id: Option<&str>) -> ApiError {
355 let mut headers = reqwest::header::HeaderMap::new();
356 if let Some(request_id) = request_id {
357 headers.insert(
358 REQUEST_ID_HEADER,
359 reqwest::header::HeaderValue::from_str(request_id).unwrap(),
360 );
361 }
362 let body = if body.is_null() { None } else { Some(body) };
363 ApiError {
364 status,
365 kind: ApiErrorKind::from_status(status),
366 message: api_error_message(&body),
367 body,
368 headers,
369 request_id: request_id.map(str::to_owned),
370 endpoint: "POST https://api.typesafe.ai/v1/systemone".into(),
371 retry_after: None,
372 }
373 }
374
375 #[test]
376 fn kind_per_status() {
377 for (status, kind) in [
378 (400, ApiErrorKind::BadRequest),
379 (401, ApiErrorKind::Authentication),
380 (403, ApiErrorKind::PermissionDenied),
381 (404, ApiErrorKind::NotFound),
382 (422, ApiErrorKind::UnprocessableEntity),
383 (429, ApiErrorKind::RateLimit),
384 (500, ApiErrorKind::InternalServer),
385 (503, ApiErrorKind::InternalServer),
386 (599, ApiErrorKind::InternalServer),
387 (418, ApiErrorKind::Other),
388 ] {
389 assert_eq!(ApiErrorKind::from_status(status), kind, "{status}");
390 }
391 }
392
393 #[test]
394 fn message_extraction_first_match_wins() {
395 let cases = [
396 (json!("plain string"), "plain string"),
397 (json!({"error": "an error"}), "an error"),
398 (
399 json!({"error": {"message": "nested"}, "message": "flat"}),
400 "nested",
401 ),
402 (json!({"message": "flat"}), "flat"),
403 (json!({"detail": "details"}), "details"),
404 (
405 json!({"detail": {"message": "detail message"}}),
406 "detail message",
407 ),
408 (
409 json!({"detail": [
410 {"loc": ["body", "questions", "urgency", "score", "criteria"], "msg": "Field required"},
411 {"loc": ["body", "state"], "msg": "Bad state"}
412 ]}),
413 "questions.urgency.score.criteria: Field required; state: Bad state",
414 ),
415 ];
416 for (body, expected) in cases {
417 assert_eq!(extract_message(&body).as_deref(), Some(expected), "{body}");
418 }
419 }
420
421 #[test]
422 fn plain_string_body_is_truncated_to_200_chars() {
423 let long = "é".repeat(250);
426 let message = extract_message(&Value::String(long.clone())).unwrap();
427 assert_eq!(message.chars().count(), 201);
428 assert!(message.ends_with('…'));
429 assert_eq!(message.chars().filter(|c| *c == 'é').count(), 200);
430 assert_eq!(message.len(), 200 * 2 + 3); let exact = "é".repeat(200);
434 let message = extract_message(&Value::String(exact.clone())).unwrap();
435 assert_eq!(message, exact);
436
437 let raw_html = format!("<html>{}</html>", "z".repeat(300));
440 let message = extract_message(&Value::String(raw_html)).unwrap();
441 assert_eq!(message.chars().count(), 201);
442 assert!(message.ends_with('…'));
443 }
444
445 #[test]
446 fn fastapi_detail_renders_loc_without_body() {
447 let error = api_error(
448 422,
449 json!({"detail": [
450 {"loc": ["body", "questions", "urgency", "score", "criteria"], "msg": "Field required", "type": "missing"}
451 ]}),
452 None,
453 );
454 assert_eq!(
455 error.message,
456 "questions.urgency.score.criteria: Field required"
457 );
458 }
459
460 #[test]
461 fn message_falls_back_to_raw_body_truncated() {
462 let long = "x".repeat(300);
463 let error = api_error(500, json!({"unexpected": long.clone()}), None);
464 assert_eq!(error.message.chars().count(), 201);
465 assert!(error.message.ends_with('…'));
466 assert!(error.message.starts_with("{\"unexpected\":\"xxx"));
467 let exact = "y".repeat(183);
470 let error = api_error(500, json!({"unexpected": exact}), None);
471 assert_eq!(
472 error.message,
473 "{\"unexpected\":\"".to_owned() + &"y".repeat(183) + "\"}"
474 );
475 let raw = "z".repeat(250);
477 let error = api_error(500, Value::String(raw.clone()), None);
478 assert_eq!(error.message, "z".repeat(200) + "…");
479 }
480
481 #[test]
482 fn empty_body_message() {
483 let error = api_error(404, Value::Null, None);
486 assert_eq!(error.message, "status code (no body)");
487 let error = api_error(404, json!("whoops"), None);
489 assert_eq!(error.message, "whoops");
490 }
491
492 #[test]
493 fn display_format() {
494 let error = api_error(429, json!({"error": "too fast"}), Some("req-123"));
495 assert_eq!(
496 error.to_string(),
497 "POST https://api.typesafe.ai/v1/systemone: 429 too fast (request_id=req-123)"
498 );
499 let error = api_error(500, json!("boom"), None);
500 assert_eq!(
501 error.to_string(),
502 "POST https://api.typesafe.ai/v1/systemone: 500 boom"
503 );
504 }
505
506 #[test]
507 fn error_helpers() {
508 let error = Error::from(api_error(429, json!("rl"), Some("req-1")));
509 assert_eq!(error.status(), Some(429));
510 assert_eq!(error.request_id(), Some("req-1"));
511 assert!(error.as_api().is_some());
512 assert!(!error.is_connection());
513 assert!(!error.is_timeout());
514
515 let error = Error::Timeout {
516 timeout: Duration::from_secs(2),
517 };
518 assert!(error.is_timeout());
519 assert!(error.is_connection());
520 assert_eq!(error.status(), None);
521
522 let error = Error::Connection {
523 message: "reset".into(),
524 source: None,
525 };
526 assert!(error.is_connection());
527 assert!(!error.is_timeout());
528 }
529}