mithril_aggregator_client/
error.rs

1use anyhow::anyhow;
2use reqwest::{Response, StatusCode, header};
3use thiserror::Error;
4
5use mithril_common::StdError;
6use mithril_common::entities::{ClientError, ServerError};
7
8use crate::JSON_CONTENT_TYPE;
9
10/// Error structure for the Aggregator Client.
11#[derive(Error, Debug)]
12pub enum AggregatorHttpClientError {
13    /// Error raised when querying the aggregator returned a 5XX error.
14    #[error("Internal error of the Aggregator")]
15    RemoteServerTechnical(#[source] StdError),
16
17    /// Error raised when querying the aggregator returned a 4XX error.
18    #[error("Invalid request to the Aggregator")]
19    RemoteServerLogical(#[source] StdError),
20
21    /// Could not reach aggregator.
22    #[error("Remote server unreachable")]
23    RemoteServerUnreachable(#[source] StdError),
24
25    /// Unhandled status code
26    #[error("Unhandled status code: {0}, response text: {1}")]
27    UnhandledStatusCode(StatusCode, String),
28
29    /// Could not parse response.
30    #[error("Json parsing failed")]
31    JsonParseFailed(#[source] StdError),
32
33    /// Failed to join the query endpoint to the aggregator url
34    #[error("Invalid endpoint")]
35    InvalidEndpoint(#[source] StdError),
36
37    /// No signer registration round opened yet
38    #[error("A signer registration round is not opened yet, please try again later")]
39    RegistrationRoundNotYetOpened(#[source] StdError),
40}
41
42impl AggregatorHttpClientError {
43    /// Create an `AggregatorClientError` from a response.
44    ///
45    /// This method is meant to be used after handling domain-specific cases leaving only
46    /// 4xx or 5xx status codes.
47    /// Otherwise, it will return an `UnhandledStatusCode` error.
48    pub async fn from_response(response: Response) -> Self {
49        let error_code = response.status();
50
51        if error_code.is_client_error() {
52            let root_cause = Self::get_root_cause(response).await;
53            Self::RemoteServerLogical(anyhow!(root_cause))
54        } else if error_code.is_server_error() {
55            let root_cause = Self::get_root_cause(response).await;
56            match error_code.as_u16() {
57                550 => Self::RegistrationRoundNotYetOpened(anyhow!(root_cause)),
58                _ => Self::RemoteServerTechnical(anyhow!(root_cause)),
59            }
60        } else {
61            let response_text = response.text().await.unwrap_or_default();
62            Self::UnhandledStatusCode(error_code, response_text)
63        }
64    }
65
66    pub(crate) async fn get_root_cause(response: Response) -> String {
67        let error_code = response.status();
68        let canonical_reason = error_code.canonical_reason().unwrap_or_default().to_lowercase();
69        let is_json = response
70            .headers()
71            .get(header::CONTENT_TYPE)
72            .is_some_and(|ct| JSON_CONTENT_TYPE == ct);
73
74        if is_json {
75            let json_value: serde_json::Value = response.json().await.unwrap_or_default();
76
77            if let Ok(client_error) = serde_json::from_value::<ClientError>(json_value.clone()) {
78                format!(
79                    "{}: {}: {}",
80                    canonical_reason, client_error.label, client_error.message
81                )
82            } else if let Ok(server_error) =
83                serde_json::from_value::<ServerError>(json_value.clone())
84            {
85                format!("{}: {}", canonical_reason, server_error.message)
86            } else if json_value.is_null() {
87                canonical_reason.to_string()
88            } else {
89                format!("{canonical_reason}: {json_value}")
90            }
91        } else {
92            let response_text = response.text().await.unwrap_or_default();
93            format!("{canonical_reason}: {response_text}")
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use http::response::Builder as HttpResponseBuilder;
101    use serde_json::json;
102
103    use super::*;
104
105    macro_rules! assert_error_text_contains {
106        ($error: expr, $expect_contains: expr) => {
107            let error = &$error;
108            assert!(
109                error.contains($expect_contains),
110                "Expected error message to contain '{}'\ngot '{error:?}'",
111                $expect_contains,
112            );
113        };
114    }
115
116    fn build_text_response<T: Into<String>>(status_code: StatusCode, body: T) -> Response {
117        HttpResponseBuilder::new()
118            .status(status_code)
119            .body(body.into())
120            .unwrap()
121            .into()
122    }
123
124    fn build_json_response<T: serde::Serialize>(status_code: StatusCode, body: &T) -> Response {
125        HttpResponseBuilder::new()
126            .status(status_code)
127            .header(header::CONTENT_TYPE, JSON_CONTENT_TYPE)
128            .body(serde_json::to_string(&body).unwrap())
129            .unwrap()
130            .into()
131    }
132
133    #[tokio::test]
134    async fn test_4xx_errors_are_handled_as_remote_server_logical() {
135        let response = build_text_response(StatusCode::BAD_REQUEST, "error text");
136        let handled_error = AggregatorHttpClientError::from_response(response).await;
137
138        assert!(
139            matches!(
140                handled_error,
141                AggregatorHttpClientError::RemoteServerLogical(..)
142            ),
143            "Expected error to be RemoteServerLogical\ngot '{handled_error:?}'",
144        );
145    }
146
147    #[tokio::test]
148    async fn test_5xx_errors_are_handled_as_remote_server_technical() {
149        let response = build_text_response(StatusCode::INTERNAL_SERVER_ERROR, "error text");
150        let handled_error = AggregatorHttpClientError::from_response(response).await;
151
152        assert!(
153            matches!(
154                handled_error,
155                AggregatorHttpClientError::RemoteServerTechnical(..)
156            ),
157            "Expected error to be RemoteServerLogical\ngot '{handled_error:?}'",
158        );
159    }
160
161    #[tokio::test]
162    async fn test_550_error_is_handled_as_registration_round_not_yet_opened() {
163        let response = build_text_response(StatusCode::from_u16(550).unwrap(), "Not yet available");
164        let handled_error = AggregatorHttpClientError::from_response(response).await;
165
166        assert!(
167            matches!(
168                handled_error,
169                AggregatorHttpClientError::RegistrationRoundNotYetOpened(..)
170            ),
171            "Expected error to be RegistrationRoundNotYetOpened\ngot '{handled_error:?}'",
172        );
173    }
174
175    #[tokio::test]
176    async fn test_non_4xx_or_5xx_errors_are_handled_as_unhandled_status_code_and_contains_response_text()
177     {
178        let response = build_text_response(StatusCode::OK, "ok text");
179        let handled_error = AggregatorHttpClientError::from_response(response).await;
180
181        assert!(
182            matches!(
183                handled_error,
184                AggregatorHttpClientError::UnhandledStatusCode(..) if format!("{handled_error:?}").contains("ok text")
185            ),
186            "Expected error to be UnhandledStatusCode with 'ok text' in error text\ngot '{handled_error:?}'",
187        );
188    }
189
190    #[tokio::test]
191    async fn test_root_cause_of_non_json_response_contains_response_plain_text() {
192        let error_text = "An error occurred; please try again later.";
193        let response = build_text_response(StatusCode::EXPECTATION_FAILED, error_text);
194
195        assert_error_text_contains!(
196            AggregatorHttpClientError::get_root_cause(response).await,
197            "expectation failed: An error occurred; please try again later."
198        );
199    }
200
201    #[tokio::test]
202    async fn test_root_cause_of_json_formatted_client_error_response_contains_error_label_and_message()
203     {
204        let client_error = ClientError::new("label", "message");
205        let response = build_json_response(StatusCode::BAD_REQUEST, &client_error);
206
207        assert_error_text_contains!(
208            AggregatorHttpClientError::get_root_cause(response).await,
209            "bad request: label: message"
210        );
211    }
212
213    #[tokio::test]
214    async fn test_root_cause_of_json_formatted_server_error_response_contains_error_label_and_message()
215     {
216        let server_error = ServerError::new("message");
217        let response = build_json_response(StatusCode::BAD_REQUEST, &server_error);
218
219        assert_error_text_contains!(
220            AggregatorHttpClientError::get_root_cause(response).await,
221            "bad request: message"
222        );
223    }
224
225    #[tokio::test]
226    async fn test_root_cause_of_unknown_formatted_json_response_contains_json_key_value_pairs() {
227        let response = build_json_response(
228            StatusCode::INTERNAL_SERVER_ERROR,
229            &json!({ "second": "unknown", "first": "foreign" }),
230        );
231
232        assert_error_text_contains!(
233            AggregatorHttpClientError::get_root_cause(response).await,
234            r#"internal server error: {"first":"foreign","second":"unknown"}"#
235        );
236    }
237
238    #[tokio::test]
239    async fn test_root_cause_with_invalid_json_response_still_contains_response_status_name() {
240        let response = HttpResponseBuilder::new()
241            .status(StatusCode::BAD_REQUEST)
242            .header(header::CONTENT_TYPE, JSON_CONTENT_TYPE)
243            .body(r#"{"invalid":"unexpected dot", "key": "value".}"#)
244            .unwrap()
245            .into();
246
247        let root_cause = AggregatorHttpClientError::get_root_cause(response).await;
248
249        assert_error_text_contains!(root_cause, "bad request");
250        assert!(
251            !root_cause.contains("bad request: "),
252            "Expected error message should not contain additional information \ngot '{root_cause:?}'"
253        );
254    }
255}