Skip to content

Commit ed87bdc

Browse files
committed
refactor: standardize http errors handling in signer aggregator_client
* Always include the status code text in the issued error, this should add more context when the response text is empty. * When the response contains json: try to parse ideally as a `ClientError` or `ServerError` to use their properties, if of an unknown type it will output all the json key/value pairs as a fallback.
1 parent 2d6d17b commit ed87bdc

File tree

3 files changed

+215
-24
lines changed

3 files changed

+215
-24
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mithril-signer/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ tikv-jemallocator = { version = "0.6.0", optional = true }
4747

4848
[dev-dependencies]
4949
criterion = { version = "0.5.1", features = ["html_reports", "async_tokio"] }
50+
http = "1.1.0"
5051
httpmock = "0.7.0"
5152
mithril-common = { path = "../mithril-common" }
5253
mockall = "0.13.0"

mithril-signer/src/services/aggregator_client.rs

Lines changed: 213 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
use anyhow::anyhow;
22
use async_trait::async_trait;
3+
use reqwest::header::{self, HeaderValue};
34
use reqwest::{self, Client, Proxy, RequestBuilder, Response, StatusCode};
45
use slog::{debug, Logger};
56
use std::{io, sync::Arc, time::Duration};
67
use thiserror::Error;
78

89
use mithril_common::{
910
api_version::APIVersionProvider,
10-
entities::{Epoch, ProtocolMessage, SignedEntityType, Signer, SingleSignatures},
11+
entities::{
12+
ClientError, Epoch, ProtocolMessage, ServerError, SignedEntityType, Signer,
13+
SingleSignatures,
14+
},
1115
logging::LoggerExtensions,
1216
messages::{
1317
AggregatorFeaturesMessage, EpochSettingsMessage, TryFromMessageAdapter, TryToMessageAdapter,
@@ -21,6 +25,8 @@ use crate::message_adapters::{
2125
};
2226
use crate::services::SignaturePublisher;
2327

28+
const JSON_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/json");
29+
2430
/// Error structure for the Aggregator Client.
2531
#[derive(Error, Debug)]
2632
pub enum AggregatorClientError {
@@ -36,6 +42,10 @@ pub enum AggregatorClientError {
3642
#[error("remote server unreachable")]
3743
RemoteServerUnreachable(#[source] StdError),
3844

45+
/// Unhandled status code
46+
#[error("unhandled status code: {0}, response text: {1}")]
47+
UnhandledStatusCode(StatusCode, String),
48+
3949
/// Could not parse response.
4050
#[error("json parsing failed")]
4151
JsonParseFailed(#[source] StdError),
@@ -69,6 +79,65 @@ impl AggregatorClientError {
6979
}
7080
}
7181

82+
impl AggregatorClientError {
83+
/// Create an `AggregatorClientError` from a response.
84+
///
85+
/// This method is meant to be used after handling domain-specific cases leaving only
86+
/// 4xx or 5xx status codes.
87+
/// Otherwise, it will return an `UnhandledStatusCode` error.
88+
pub async fn from_response(response: Response) -> Self {
89+
let error_code = response.status();
90+
91+
if error_code.is_client_error() {
92+
let root_cause = Self::get_root_cause(response).await;
93+
Self::RemoteServerLogical(anyhow!(root_cause))
94+
} else if error_code.is_server_error() {
95+
let root_cause = Self::get_root_cause(response).await;
96+
Self::RemoteServerTechnical(anyhow!(root_cause))
97+
} else {
98+
let response_text = response.text().await.unwrap_or_default();
99+
Self::UnhandledStatusCode(error_code, response_text)
100+
}
101+
}
102+
103+
async fn get_root_cause(response: Response) -> String {
104+
let error_code = response.status();
105+
let canonical_reason = error_code.canonical_reason().unwrap_or_default();
106+
let is_json = response
107+
.headers()
108+
.get(header::CONTENT_TYPE)
109+
.is_some_and(|ct| JSON_CONTENT_TYPE == ct);
110+
111+
if is_json {
112+
let json_value: serde_json::Value = response.json().await.unwrap_or_default();
113+
114+
if let Ok(client_error) = serde_json::from_value::<ClientError>(json_value.clone()) {
115+
format!(
116+
"{}: {}: {}",
117+
canonical_reason.to_lowercase(),
118+
client_error.label,
119+
client_error.message
120+
)
121+
} else if let Ok(server_error) =
122+
serde_json::from_value::<ServerError>(json_value.clone())
123+
{
124+
format!(
125+
"{}: {}",
126+
canonical_reason.to_lowercase(),
127+
server_error.message
128+
)
129+
} else if json_value.is_null() {
130+
canonical_reason.to_lowercase().to_string()
131+
} else {
132+
format!("{}: {}", canonical_reason.to_lowercase(), json_value)
133+
}
134+
} else {
135+
let response_text = response.text().await.unwrap_or_default();
136+
format!("{}: {}", canonical_reason.to_lowercase(), response_text)
137+
}
138+
}
139+
}
140+
72141
/// Trait for mocking and testing a `AggregatorClient`
73142
#[cfg_attr(test, mockall::automock)]
74143
#[async_trait]
@@ -216,10 +285,7 @@ impl AggregatorClient for AggregatorHTTPClient {
216285
Err(err) => Err(AggregatorClientError::JsonParseFailed(anyhow!(err))),
217286
},
218287
StatusCode::PRECONDITION_FAILED => Err(self.handle_api_error(&response)),
219-
_ => Err(AggregatorClientError::RemoteServerTechnical(anyhow!(
220-
"{}",
221-
response.text().await.unwrap_or_default()
222-
))),
288+
_ => Err(AggregatorClientError::from_response(response).await),
223289
},
224290
Err(err) => Err(AggregatorClientError::RemoteServerUnreachable(anyhow!(err))),
225291
}
@@ -245,13 +311,7 @@ impl AggregatorClient for AggregatorHTTPClient {
245311
Ok(response) => match response.status() {
246312
StatusCode::CREATED => Ok(()),
247313
StatusCode::PRECONDITION_FAILED => Err(self.handle_api_error(&response)),
248-
StatusCode::BAD_REQUEST => Err(AggregatorClientError::RemoteServerLogical(
249-
anyhow!("bad request: {}", response.text().await.unwrap_or_default()),
250-
)),
251-
_ => Err(AggregatorClientError::RemoteServerTechnical(anyhow!(
252-
"{}",
253-
response.text().await.unwrap_or_default()
254-
))),
314+
_ => Err(AggregatorClientError::from_response(response).await),
255315
},
256316
Err(err) => Err(AggregatorClientError::RemoteServerUnreachable(anyhow!(err))),
257317
}
@@ -285,16 +345,10 @@ impl AggregatorClient for AggregatorHTTPClient {
285345
Ok(())
286346
}
287347
StatusCode::PRECONDITION_FAILED => Err(self.handle_api_error(&response)),
288-
StatusCode::BAD_REQUEST => Err(AggregatorClientError::RemoteServerLogical(
289-
anyhow!("bad request: {}", response.text().await.unwrap_or_default()),
290-
)),
291348
StatusCode::CONFLICT => Err(AggregatorClientError::RemoteServerLogical(anyhow!(
292349
"already registered single signatures"
293350
))),
294-
_ => Err(AggregatorClientError::RemoteServerTechnical(anyhow!(
295-
"{}",
296-
response.text().await.unwrap_or_default()
297-
))),
351+
_ => Err(AggregatorClientError::from_response(response).await),
298352
},
299353
Err(err) => Err(AggregatorClientError::RemoteServerUnreachable(anyhow!(err))),
300354
}
@@ -317,10 +371,7 @@ impl AggregatorClient for AggregatorHTTPClient {
317371
.await
318372
.map_err(|e| AggregatorClientError::JsonParseFailed(anyhow!(e)))?),
319373
StatusCode::PRECONDITION_FAILED => Err(self.handle_api_error(&response)),
320-
_ => Err(AggregatorClientError::RemoteServerTechnical(anyhow!(
321-
"{}",
322-
response.text().await.unwrap_or_default()
323-
))),
374+
_ => Err(AggregatorClientError::from_response(response).await),
324375
},
325376
Err(err) => Err(AggregatorClientError::RemoteServerUnreachable(anyhow!(err))),
326377
}
@@ -426,10 +477,11 @@ pub(crate) mod dumb {
426477

427478
#[cfg(test)]
428479
mod tests {
480+
use http::response::Builder as HttpResponseBuilder;
429481
use httpmock::prelude::*;
430482
use serde_json::json;
431483

432-
use mithril_common::entities::{ClientError, Epoch};
484+
use mithril_common::entities::Epoch;
433485
use mithril_common::era::{EraChecker, SupportedEra};
434486
use mithril_common::messages::TryFromMessageAdapter;
435487
use mithril_common::test_utils::fake_data;
@@ -494,6 +546,34 @@ mod tests {
494546
});
495547
}
496548

549+
fn build_text_response<T: Into<String>>(status_code: StatusCode, body: T) -> Response {
550+
HttpResponseBuilder::new()
551+
.status(status_code)
552+
.body(body.into())
553+
.unwrap()
554+
.into()
555+
}
556+
557+
fn build_json_response<T: serde::Serialize>(status_code: StatusCode, body: &T) -> Response {
558+
HttpResponseBuilder::new()
559+
.status(status_code)
560+
.header(header::CONTENT_TYPE, JSON_CONTENT_TYPE)
561+
.body(serde_json::to_string(&body).unwrap())
562+
.unwrap()
563+
.into()
564+
}
565+
566+
macro_rules! assert_error_text_contains {
567+
($error: expr, $expect_contains: expr) => {
568+
let error = &$error;
569+
assert!(
570+
error.contains($expect_contains),
571+
"Expected error message to contain '{}'\ngot '{error:?}'",
572+
$expect_contains,
573+
);
574+
};
575+
}
576+
497577
#[tokio::test]
498578
async fn test_aggregator_features_ok_200() {
499579
let (server, client) = setup_server_and_client();
@@ -1001,4 +1081,113 @@ mod tests {
10011081
"unexpected error type: {error:?}"
10021082
);
10031083
}
1084+
1085+
#[tokio::test]
1086+
async fn test_4xx_errors_are_handled_as_remote_server_logical() {
1087+
let response = build_text_response(StatusCode::BAD_REQUEST, "error text");
1088+
let handled_error = AggregatorClientError::from_response(response).await;
1089+
1090+
assert!(
1091+
matches!(
1092+
handled_error,
1093+
AggregatorClientError::RemoteServerLogical(..)
1094+
),
1095+
"Expected error to be RemoteServerLogical\ngot '{handled_error:?}'",
1096+
);
1097+
}
1098+
1099+
#[tokio::test]
1100+
async fn test_5xx_errors_are_handled_as_remote_server_technical() {
1101+
let response = build_text_response(StatusCode::INTERNAL_SERVER_ERROR, "error text");
1102+
let handled_error = AggregatorClientError::from_response(response).await;
1103+
1104+
assert!(
1105+
matches!(
1106+
handled_error,
1107+
AggregatorClientError::RemoteServerTechnical(..)
1108+
),
1109+
"Expected error to be RemoteServerLogical\ngot '{handled_error:?}'",
1110+
);
1111+
}
1112+
1113+
#[tokio::test]
1114+
async fn test_non_4xx_or_5xx_errors_are_handled_as_unhandled_status_code_and_contains_response_text(
1115+
) {
1116+
let response = build_text_response(StatusCode::OK, "ok text");
1117+
let handled_error = AggregatorClientError::from_response(response).await;
1118+
1119+
assert!(
1120+
matches!(
1121+
handled_error,
1122+
AggregatorClientError::UnhandledStatusCode(..) if format!("{handled_error:?}").contains("ok text")
1123+
),
1124+
"Expected error to be UnhandledStatusCode with 'ok text' in error text\ngot '{handled_error:?}'",
1125+
);
1126+
}
1127+
1128+
#[tokio::test]
1129+
async fn test_root_cause_of_non_json_response_contains_response_plain_text() {
1130+
let error_text = "An error occurred; please try again later.";
1131+
let response = build_text_response(StatusCode::EXPECTATION_FAILED, error_text);
1132+
1133+
assert_error_text_contains!(
1134+
AggregatorClientError::get_root_cause(response).await,
1135+
"expectation failed: An error occurred; please try again later."
1136+
);
1137+
}
1138+
1139+
#[tokio::test]
1140+
async fn test_root_cause_of_json_formatted_client_error_response_contains_error_label_and_message(
1141+
) {
1142+
let client_error = ClientError::new("label", "message");
1143+
let response = build_json_response(StatusCode::BAD_REQUEST, &client_error);
1144+
1145+
assert_error_text_contains!(
1146+
AggregatorClientError::get_root_cause(response).await,
1147+
"bad request: label: message"
1148+
);
1149+
}
1150+
1151+
#[tokio::test]
1152+
async fn test_root_cause_of_json_formatted_server_error_response_contains_error_label_and_message(
1153+
) {
1154+
let server_error = ServerError::new("message");
1155+
let response = build_json_response(StatusCode::BAD_REQUEST, &server_error);
1156+
1157+
assert_error_text_contains!(
1158+
AggregatorClientError::get_root_cause(response).await,
1159+
"bad request: message"
1160+
);
1161+
}
1162+
1163+
#[tokio::test]
1164+
async fn test_root_cause_of_unknown_formatted_json_response_contains_json_key_value_pairs() {
1165+
let response = build_json_response(
1166+
StatusCode::INTERNAL_SERVER_ERROR,
1167+
&json!({ "second": "unknown", "first": "foreign" }),
1168+
);
1169+
1170+
assert_error_text_contains!(
1171+
AggregatorClientError::get_root_cause(response).await,
1172+
r#"internal server error: {"first":"foreign","second":"unknown"}"#
1173+
);
1174+
}
1175+
1176+
#[tokio::test]
1177+
async fn test_root_cause_with_invalid_json_response_still_contains_response_status_name() {
1178+
let response = HttpResponseBuilder::new()
1179+
.status(StatusCode::BAD_REQUEST)
1180+
.header(header::CONTENT_TYPE, JSON_CONTENT_TYPE)
1181+
.body(r#"{"invalid":"unexpected dot", "key": "value".}"#)
1182+
.unwrap()
1183+
.into();
1184+
1185+
let root_cause = AggregatorClientError::get_root_cause(response).await;
1186+
1187+
assert_error_text_contains!(root_cause, "bad request");
1188+
assert!(
1189+
!root_cause.contains("bad request: "),
1190+
"Expected error message should not contain additional information \ngot '{root_cause:?}'"
1191+
);
1192+
}
10041193
}

0 commit comments

Comments
 (0)