Skip to main content

cratestack_client_rust/
error.rs

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