feat: attempt to add more details to reqwest errors

This commit is contained in:
Jędrzej Stuczyński
2025-09-09 14:47:44 +01:00
parent dfa4563d22
commit 34aaa60a44
6 changed files with 204 additions and 85 deletions
@@ -756,15 +756,8 @@ where
let mut nym_api_urls = config.get_nym_api_endpoints();
nym_api_urls.shuffle(&mut thread_rng());
let mut builder =
nym_http_api_client::Client::builder(nym_api_urls[0].clone()).map_err(|e| {
ClientCoreError::NymApiQueryFailure {
source:
nym_validator_client::nym_api::error::NymAPIError::GenericRequestFailure(
e.to_string(),
),
}
})?;
let mut builder = nym_http_api_client::Client::builder(nym_api_urls[0].clone())
.map_err(|source| ClientCoreError::NymApiQueryFailure { source })?;
if let Some(user_agent) = user_agent {
builder = builder.with_user_agent(user_agent);
@@ -774,11 +767,7 @@ where
builder
.build()
.map_err(|e| ClientCoreError::NymApiQueryFailure {
source: nym_validator_client::nym_api::error::NymAPIError::GenericRequestFailure(
e.to_string(),
),
})
.map_err(|source| ClientCoreError::NymApiQueryFailure { source })
}
async fn determine_key_rotation_state(
@@ -273,9 +273,9 @@ pub trait NymApiClientExt: ApiClient {
if !metadata.consistency_check(&res.metadata) {
// Create a custom error for inconsistent metadata
return Err(NymAPIError::EndpointFailure {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error: "Inconsistent paged metadata".to_string(),
return Err(NymAPIError::InternalResponseInconsistency {
url: self.api_url().clone(),
details: "Inconsistent paged metadata".to_string(),
});
}
@@ -314,9 +314,9 @@ pub trait NymApiClientExt: ApiClient {
.await?;
if !metadata.consistency_check(&res.metadata) {
return Err(NymAPIError::EndpointFailure {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error: "Inconsistent paged metadata".to_string(),
return Err(NymAPIError::InternalResponseInconsistency {
url: self.api_url().clone(),
details: "Inconsistent paged metadata".to_string(),
});
}
@@ -357,9 +357,9 @@ pub trait NymApiClientExt: ApiClient {
.await?;
if !metadata.consistency_check(&res.metadata) {
return Err(NymAPIError::EndpointFailure {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error: "Inconsistent paged metadata".to_string(),
return Err(NymAPIError::InternalResponseInconsistency {
url: self.api_url().clone(),
details: "Inconsistent paged metadata".to_string(),
});
}
+148 -55
View File
@@ -51,7 +51,7 @@
//! Up,
//! }
//!
//! # type Err = HttpClientError<String>;
//! # type Err = HttpClientError;
//! # async fn run() -> Result<(), Err> {
//! // This will POST a body of `{"lang":"rust","body":"json"}`
//! let mut map = HashMap::new();
@@ -140,6 +140,7 @@ pub use inventory;
pub use reqwest;
pub use reqwest::ClientBuilder as ReqwestClientBuilder;
pub use reqwest::StatusCode;
use std::error::Error;
pub mod registry;
@@ -244,43 +245,125 @@ impl SerializationFormat {
}
}
#[allow(missing_docs)]
#[derive(Debug)]
pub struct ReqwestErrorWrapper(reqwest::Error);
impl Display for ReqwestErrorWrapper {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.0.is_connect() {
write!(f, "failed to connect: ")?;
}
if self.0.is_timeout() {
write!(f, "timed out: ")?;
}
if self.0.is_redirect() {
if let Some(final_stop) = self.0.url() {
write!(f, "redirect loop at {final_stop}: ")?;
}
}
self.0.fmt(f)?;
if let Some(status_code) = self.0.status() {
write!(f, " status: {status_code}")?;
} else {
write!(f, " unknown status code")?;
}
if let Some(source) = self.0.source() {
write!(f, " source: {source}")?;
} else {
write!(f, " unknown lower-level error source")?;
}
Ok(())
}
}
impl std::error::Error for ReqwestErrorWrapper {}
/// The Errors that may occur when creating or using an HTTP client.
#[derive(Debug, Error)]
#[allow(clippy::large_enum_variant)] // TODO: box relevant variants
#[allow(missing_docs)]
pub enum HttpClientError {
#[error("there was an issue with the REST request: {source}")]
ReqwestClientError {
#[from]
#[error("failed to construct inner reqwest client: {source}")]
ReqwestBuildError {
#[source]
source: reqwest::Error,
},
#[error("failed to parse {raw} as a valid URL: {source}")]
MalformedUrl {
raw: String,
#[source]
source: reqwest::Error,
},
#[error("failed to send request for {url}: {source}")]
RequestSendFailure {
url: reqwest::Url,
#[source]
source: ReqwestErrorWrapper,
},
#[error("failed to read response body from {url}: {source}")]
ResponseReadFailure {
url: reqwest::Url,
headers: HeaderMap,
status: StatusCode,
#[source]
source: ReqwestErrorWrapper,
},
#[error("failed to deserialize received response: {source}")]
ResponseDeserialisationFailure { source: serde_json::Error },
#[error("provided url is malformed: {source}")]
MalformedUrl {
UrlParseFailure {
#[from]
source: url::ParseError,
},
#[error("the requested resource could not be found")]
NotFound,
#[error("the requested resource could not be found at {url}")]
NotFound { url: reqwest::Url },
#[error("request failed with error message: {0}")]
GenericRequestFailure(String),
#[error("attempted to use domain fronting and clone a request containing stream data")]
AttemptedToCloneStreamRequest,
#[error("the request failed with status '{status}'. no additional error message provided")]
RequestFailure { status: StatusCode },
// #[error("request failed with error message: {0}")]
// GenericRequestFailure(String),
//
#[error("the request for {url} failed with status '{status}'. no additional error message provided. response headers: {headers:?}")]
RequestFailure {
url: reqwest::Url,
status: StatusCode,
headers: HeaderMap,
},
#[error("the returned response was empty. status: '{status}'")]
EmptyResponse { status: StatusCode },
#[error(
"the returned response from {url} was empty. status: '{status}'. response headers: {headers:?}"
)]
EmptyResponse {
url: reqwest::Url,
status: StatusCode,
headers: HeaderMap,
},
#[error("failed to resolve request. status: '{status}', additional error message: {error}")]
EndpointFailure { status: StatusCode, error: String },
#[error("failed to resolve request for {url}. status: '{status}'. response headers: {headers:?}. additional error message: {error}")]
EndpointFailure {
url: reqwest::Url,
status: StatusCode,
headers: HeaderMap,
error: String,
},
#[error("failed to decode response body: {message} from {content}")]
ResponseDecodeFailure { message: String, content: String },
#[error("failed to resolve request to {url} due to data inconsistency: {details}")]
InternalResponseInconsistency { url: ::url::Url, details: String },
#[error("Failed to encode bincode: {0}")]
Bincode(#[from] bincode::Error),
@@ -298,24 +381,16 @@ pub enum HttpClientError {
RequestTimeout,
}
#[allow(missing_docs)]
impl HttpClientError {
/// Returns true if the error is a timeout.
pub fn is_timeout(&self) -> bool {
match self {
HttpClientError::ReqwestClientError { source } => source.is_timeout(),
#[cfg(target_arch = "wasm32")]
HttpClientError::RequestTimeout => true,
_ => false,
}
pub fn reqwest_client_build_error(source: reqwest::Error) -> Self {
HttpClientError::ReqwestBuildError { source }
}
/// Returns the HTTP status code if available.
pub fn status_code(&self) -> Option<StatusCode> {
match self {
HttpClientError::RequestFailure { status } => Some(*status),
HttpClientError::EmptyResponse { status } => Some(*status),
HttpClientError::EndpointFailure { status, .. } => Some(*status),
_ => None,
pub fn request_send_error(url: reqwest::Url, source: reqwest::Error) -> Self {
HttpClientError::RequestSendFailure {
url,
source: ReqwestErrorWrapper(source),
}
}
}
@@ -629,7 +704,9 @@ impl ClientBuilder {
builder = builder.dns_resolver(Arc::new(HickoryDnsResolver::default()));
}
builder.build()?
builder
.build()
.map_err(HttpClientError::reqwest_client_build_error)?
};
let client = Client {
@@ -895,14 +972,15 @@ impl ApiClientCore for Client {
// try_clone may fail if the body is a stream in which case using retries is not advised.
let r = request
.try_clone()
.ok_or(HttpClientError::GenericRequestFailure(
"failed to send request".to_string(),
))?;
.ok_or(HttpClientError::AttemptedToCloneStreamRequest)?;
// apply any changes based on the current state of the client wrt. hosts,
// fronting domains, etc.
let mut req = r.build()?;
let mut req = r
.build()
.map_err(HttpClientError::reqwest_client_build_error)?;
self.apply_hosts_to_req(&mut req);
let url = req.url().clone();
#[cfg(target_arch = "wasm32")]
let response: Result<Response, HttpClientError> = {
@@ -919,7 +997,7 @@ impl ApiClientCore for Client {
match response {
Ok(resp) => return Ok(resp),
Err(e) => {
Err(err) => {
// if we have multiple urls, update to the next
self.update_host();
@@ -931,21 +1009,20 @@ impl ApiClientCore for Client {
front.retry_enable();
if !was_enabled && front.is_enabled() {
tracing::info!(
"Domain fronting activated after connection failure: {}",
e
"Domain fronting activated after connection failure: {err}",
);
}
}
if attempts < self.retry_limit {
warn!("Retrying request due to http error: {}", e);
warn!("Retrying request due to http error: {err}");
attempts += 1;
continue;
}
// if we have exhausted our attempts, return the error
#[allow(clippy::useless_conversion)] // conversion considered useless in wasm
return Err(e.into());
return Err(HttpClientError::request_send_error(url, err));
}
}
}
@@ -1301,36 +1378,52 @@ where
T: DeserializeOwned,
{
let status = res.status();
tracing::trace!("Status: {} (success: {})", &status, status.is_success());
let headers = res.headers().clone();
let url = res.url().clone();
tracing::trace!("status: {status} (success: {})", status.is_success());
tracing::trace!("headers: {headers:?}");
if !allow_empty {
if let Some(0) = res.content_length() {
return Err(HttpClientError::EmptyResponse { status });
return Err(HttpClientError::EmptyResponse {
url,
status,
headers,
});
}
}
let headers = res.headers().clone();
tracing::trace!("headers: {:?}", headers);
if res.status().is_success() {
// internally reqwest is first retrieving bytes and then performing parsing via serde_json
// (and similarly does the same thing for text())
let full = res.bytes().await?;
let full = res
.bytes()
.await
.map_err(|source| HttpClientError::ResponseReadFailure {
url,
headers: headers.clone(),
status,
source: ReqwestErrorWrapper(source),
})?;
decode_raw_response(&headers, full)
} else if res.status() == StatusCode::NOT_FOUND {
Err(HttpClientError::NotFound)
Err(HttpClientError::NotFound { url })
} else {
let Ok(plaintext) = res.text().await else {
return Err(HttpClientError::RequestFailure { status });
return Err(HttpClientError::RequestFailure {
url,
status,
headers,
});
};
if let Ok(request_error) = serde_json::from_str(&plaintext) {
Err(HttpClientError::EndpointFailure {
status,
error: request_error,
})
} else {
Err(HttpClientError::GenericRequestFailure(plaintext))
}
Err(HttpClientError::EndpointFailure {
url,
status,
headers,
error: plaintext,
})
}
}
+1 -1
View File
@@ -744,7 +744,7 @@ version = "0.1.0"
dependencies = [
"cosmwasm-std",
"quote",
"syn 1.0.109",
"syn 2.0.98",
]
[[package]]
@@ -26,7 +26,10 @@ pub fn new_client(
base_url: impl IntoUrl,
bearer_token: impl Into<String>,
) -> Result<VpnApiClient, VpnApiClientError> {
let url = base_url.into_url()?;
let raw = base_url.as_str().to_string();
let url = base_url
.into_url()
.map_err(|source| VpnApiClientError::MalformedUrl { raw, source })?;
Ok(VpnApiClient {
inner: Client::builder(url)?
.with_user_agent(format!(
@@ -99,7 +102,12 @@ impl NymVpnApiClient for VpnApiClient {
.inner
.create_get_request(path, NO_PARAMS)?
.bearer_auth(&self.bearer_token)
.send();
.build()
.map_err(VpnApiClientError::reqwest_client_build_error)?;
let url = req.url().clone();
let req = reqwest::Client::new().execute(req);
// the only reason for that target lock is so that I could call this method from an ephemeral test
// running in non-wasm mode (since I wanted to use tokio)
@@ -110,8 +118,9 @@ impl NymVpnApiClient for VpnApiClient {
.map_err(|_timeout| HttpClientError::RequestTimeout)??;
#[cfg(not(target_arch = "wasm32"))]
let res = req.await?;
let res = req
.await
.map_err(|source| VpnApiClientError::request_send_error(url, source))?;
parse_response(res, false).await
}
@@ -131,7 +140,12 @@ impl NymVpnApiClient for VpnApiClient {
.inner
.create_post_request(path, params, json_body)?
.bearer_auth(&self.bearer_token)
.send();
.build()
.map_err(VpnApiClientError::reqwest_client_build_error)?;
let url = req.url().clone();
let req = reqwest::Client::new().execute(req);
// the only reason for that target lock is so that I could call this method from an ephemeral test
// running in non-wasm mode (since I wanted to use tokio)
@@ -142,7 +156,9 @@ impl NymVpnApiClient for VpnApiClient {
.map_err(|_timeout| HttpClientError::RequestTimeout)??;
#[cfg(not(target_arch = "wasm32"))]
let res = req.await?;
let res = req
.await
.map_err(|source| VpnApiClientError::request_send_error(url, source))?;
parse_response(res, false).await
}
+21
View File
@@ -3384,6 +3384,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "inventory"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e"
dependencies = [
"rustversion",
]
[[package]]
name = "io-uring"
version = "0.7.10"
@@ -4216,9 +4225,11 @@ dependencies = [
"encoding_rs",
"hickory-resolver",
"http 1.3.1",
"inventory",
"itertools 0.14.0",
"mime",
"nym-bin-common",
"nym-http-api-client-macro",
"nym-http-api-common",
"once_cell",
"reqwest 0.12.15",
@@ -4232,6 +4243,16 @@ dependencies = [
"wasmtimer",
]
[[package]]
name = "nym-http-api-client-macro"
version = "0.1.0"
dependencies = [
"proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "nym-http-api-common"
version = "0.1.0"