mithril_aggregator_client/
error.rs1use 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#[derive(Error, Debug)]
12pub enum AggregatorHttpClientError {
13 #[error("Internal error of the Aggregator")]
15 RemoteServerTechnical(#[source] StdError),
16
17 #[error("Invalid request to the Aggregator")]
19 RemoteServerLogical(#[source] StdError),
20
21 #[error("Remote server unreachable")]
23 RemoteServerUnreachable(#[source] StdError),
24
25 #[error("Unhandled status code: {0}, response text: {1}")]
27 UnhandledStatusCode(StatusCode, String),
28
29 #[error("Json parsing failed")]
31 JsonParseFailed(#[source] StdError),
32
33 #[error("Invalid endpoint")]
35 InvalidEndpoint(#[source] StdError),
36
37 #[error("A signer registration round is not opened yet, please try again later")]
39 RegistrationRoundNotYetOpened(#[source] StdError),
40}
41
42impl AggregatorHttpClientError {
43 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}