Skip to main content

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}