Skip to main content

cratestack_client_rust/
error.rs

1use cratestack_core::{CoolError, CoolErrorResponse};
2use reqwest::StatusCode;
3
4pub type HeaderPair<'a> = (&'a str, &'a str);
5pub type QueryPair<'a> = (&'a str, &'a str);
6
7/// Opaque wrapper around `reqwest::Error` that doesn't expose the type
8/// in public match arms, preserving semver hygiene.
9#[derive(Debug)]
10pub struct TransportError {
11    inner: Box<reqwest::Error>,
12}
13
14impl TransportError {
15    /// Access the underlying `reqwest::Error`.
16    ///
17    /// Named `reqwest_error` rather than `source` so it doesn't collide
18    /// with (and get silently shadowed by) `std::error::Error::source`,
19    /// which returns a different type (`Option<&(dyn Error + 'static)>`)
20    /// — see the trait impl below for the chain-walking accessor.
21    pub fn reqwest_error(&self) -> &reqwest::Error {
22        &self.inner
23    }
24
25    /// Consume this error and extract the underlying `reqwest::Error`.
26    pub fn into_source(self) -> reqwest::Error {
27        *self.inner
28    }
29}
30
31impl std::fmt::Display for TransportError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        write!(f, "{}", self.inner)
34    }
35}
36
37impl std::error::Error for TransportError {
38    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
39        Some(self.inner.as_ref())
40    }
41}
42
43impl From<reqwest::Error> for TransportError {
44    fn from(e: reqwest::Error) -> Self {
45        TransportError { inner: Box::new(e) }
46    }
47}
48
49#[derive(Debug, thiserror::Error)]
50#[non_exhaustive]
51pub enum ClientError {
52    #[error("transport error: {0}")]
53    Transport(#[source] TransportError),
54    #[error("codec error: {0}")]
55    Codec(#[from] CoolError),
56    #[error("state error: {0}")]
57    State(String),
58    #[error("invalid response: {0}")]
59    InvalidResponse(String),
60    #[error("bad input: {0}")]
61    BadInput(String),
62    #[error("remote call failed with status {status}: {message}")]
63    Remote {
64        status: StatusCode,
65        error: Option<CoolErrorResponse>,
66        message: String,
67    },
68}
69
70impl From<reqwest::Error> for ClientError {
71    fn from(e: reqwest::Error) -> Self {
72        ClientError::Transport(TransportError::from(e))
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    /// Connecting to a port nothing listens on is a reliable, offline way
81    /// to synthesize a genuine `reqwest::Error` (connection-refused on
82    /// loopback is near-instant and doesn't require real network access),
83    /// so tests can exercise the actual `TransportError`/`ClientError::
84    /// Transport` code path rather than only the untouched variants.
85    async fn synthesize_reqwest_error() -> reqwest::Error {
86        // `reqwest`'s `rustls-no-provider` feature requires a crypto
87        // provider installed before the first `Client` is built (#440) —
88        // mirrors `client/core.rs`'s `ensure_crypto_provider`.
89        let _ = rustls::crypto::ring::default_provider().install_default();
90
91        reqwest::Client::new()
92            .get("http://127.0.0.1:1")
93            .send()
94            .await
95            .expect_err("connecting to a closed loopback port should fail")
96    }
97
98    #[tokio::test]
99    async fn transport_error_display_and_accessor_forward_to_reqwest() {
100        let reqwest_err = synthesize_reqwest_error().await;
101        let expected_message = reqwest_err.to_string();
102
103        let transport = TransportError::from(reqwest_err);
104
105        assert_eq!(transport.to_string(), expected_message);
106        assert_eq!(transport.reqwest_error().to_string(), expected_message);
107    }
108
109    #[tokio::test]
110    async fn transport_error_into_source_round_trips_the_reqwest_error() {
111        let reqwest_err = synthesize_reqwest_error().await;
112        let expected_message = reqwest_err.to_string();
113
114        let recovered = TransportError::from(reqwest_err).into_source();
115
116        assert_eq!(recovered.to_string(), expected_message);
117    }
118
119    #[tokio::test]
120    async fn client_error_transport_chains_via_std_error_source() {
121        let reqwest_err = synthesize_reqwest_error().await;
122        let expected_message = reqwest_err.to_string();
123
124        let client_err = ClientError::from(reqwest_err);
125
126        // `From<reqwest::Error>` must land in the `Transport` variant.
127        assert!(matches!(client_err, ClientError::Transport(_)));
128        // The outer `Display` must include the inner reqwest message.
129        assert!(client_err.to_string().contains(&expected_message));
130
131        // Any caller walking the error chain via the std trait (anyhow,
132        // tracing-error, generic e.source() logging) must still reach the
133        // underlying reqwest::Error — this is the behavior that regressed
134        // when `#[from] reqwest::Error` was replaced by the opaque
135        // `TransportError` wrapper without wiring `#[source]`/`impl Error`.
136        let source = std::error::Error::source(&client_err)
137            .expect("ClientError::Transport should chain to the reqwest::Error via source()");
138        assert_eq!(source.to_string(), expected_message);
139    }
140
141    #[test]
142    fn client_error_variants() {
143        let transport_err = ClientError::State("state error".to_string());
144        assert!(matches!(transport_err, ClientError::State(_)));
145
146        let invalid_resp = ClientError::InvalidResponse("invalid".to_string());
147        assert!(matches!(invalid_resp, ClientError::InvalidResponse(_)));
148
149        let bad_input = ClientError::BadInput("bad".to_string());
150        assert!(matches!(bad_input, ClientError::BadInput(_)));
151    }
152
153    #[test]
154    fn client_error_displays_correctly() {
155        let err = ClientError::State("test error".to_string());
156        assert_eq!(err.to_string(), "state error: test error");
157
158        let err = ClientError::InvalidResponse("invalid".to_string());
159        assert_eq!(err.to_string(), "invalid response: invalid");
160    }
161}