datapress_client/error.rs
1//! Error types for the DataPress client.
2
3use serde_json::Value as JsonValue;
4
5/// Result alias used throughout the crate.
6pub type Result<T> = std::result::Result<T, ClientError>;
7
8/// Everything that can go wrong talking to a DataPress server.
9#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum ClientError {
12 /// The server returned a non-2xx status.
13 ///
14 /// `payload` is populated when the body parsed as JSON (DataPress
15 /// errors are `{"error": "..."}`), so callers can match on the
16 /// structured message without re-parsing `body`.
17 #[error("HTTP {status}: {message}")]
18 Http {
19 /// HTTP status code (e.g. `404`, `400`, `503`).
20 status: u16,
21 /// Best-effort human-readable message (the `error` field when the
22 /// body was JSON, otherwise a truncated copy of the raw body).
23 message: String,
24 /// Raw response body.
25 body: String,
26 /// Parsed JSON body, when the response was `application/json`.
27 payload: Option<JsonValue>,
28 },
29
30 /// A transport-level failure (DNS, connect, timeout, TLS, …).
31 #[error("transport error: {}", transport_detail(.0))]
32 Transport(#[from] reqwest::Error),
33
34 /// The response body could not be decoded as the expected type.
35 #[error("decode error: {0}")]
36 Decode(String),
37
38 /// The server answered with JSON where Arrow IPC was requested, or
39 /// vice-versa.
40 #[error("unexpected content type: {0}")]
41 UnexpectedContentType(String),
42
43 /// An Arrow IPC stream could not be decoded.
44 #[cfg(feature = "arrow")]
45 #[error("arrow decode error: {0}")]
46 Arrow(#[from] arrow::error::ArrowError),
47
48 /// The base URL was not a valid URL.
49 #[error("invalid base url: {0}")]
50 InvalidBaseUrl(String),
51}
52
53/// Flatten a [`reqwest::Error`] into its full cause chain.
54///
55/// `reqwest::Error`'s own `Display` only prints the outermost layer (e.g.
56/// "error sending request for url (…)"), hiding the actionable root cause
57/// ("operation timed out", "connection closed before message completed",
58/// "tcp connect error: Connection refused", …). This walks `source()` and
59/// appends each distinct layer so the message is self-explanatory.
60fn transport_detail(err: &reqwest::Error) -> String {
61 use std::error::Error;
62 let mut msg = err.to_string();
63 let mut source = err.source();
64 while let Some(cause) = source {
65 let text = cause.to_string();
66 if !text.is_empty() && !msg.contains(&text) {
67 msg.push_str(": ");
68 msg.push_str(&text);
69 }
70 source = cause.source();
71 }
72 msg
73}
74
75impl ClientError {
76 /// Build an [`ClientError::Http`] from a status and raw body,
77 /// extracting the `error` field when the body is JSON.
78 pub(crate) fn from_response(status: u16, body: String) -> Self {
79 let payload = serde_json::from_str::<JsonValue>(&body).ok();
80 let message = payload
81 .as_ref()
82 .and_then(|v| v.get("error"))
83 .and_then(|v| v.as_str())
84 .map(str::to_owned)
85 .unwrap_or_else(|| body.chars().take(200).collect());
86 ClientError::Http {
87 status,
88 message,
89 body,
90 payload,
91 }
92 }
93}