use crate::error::{ErrorData, Result};
use alien_error::{AlienError, Context, IntoAlienError};
use async_trait::async_trait;
use backon::{ExponentialBuilder, Retryable};
use serde::de::DeserializeOwned;
use std::time::Duration;
fn extract_request_body_string(request: &reqwest::Request) -> Option<String> {
request
.body()
.and_then(|body| body.as_bytes())
.map(|bytes| String::from_utf8_lossy(bytes).into_owned())
}
const HTTP_REQUEST_TEXT_CONTEXT_KEY: &str = "http_request_text";
pub fn redact_request_body<T>(result: Result<T>) -> Result<T> {
result.map_err(|mut e| {
if let Some(ErrorData::HttpResponseError {
http_request_text, ..
}) = e.error.as_mut()
{
*http_request_text = None;
}
scrub_request_body(e.context.as_mut());
let mut layer = e.source.as_deref_mut();
while let Some(err) = layer {
scrub_request_body(err.context.as_mut());
layer = err.source.as_deref_mut();
}
e
})
}
fn scrub_request_body(context: Option<&mut serde_json::Value>) {
if let Some(serde_json::Value::Object(map)) = context {
map.remove(HTTP_REQUEST_TEXT_CONTEXT_KEY);
}
}
fn build_and_extract_body(
builder: reqwest::RequestBuilder,
) -> Result<(reqwest::Client, reqwest::Request, Option<String>)> {
let (client, req_result) = builder.build_split();
let request = req_result
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Failed to build request".to_string(),
})?;
let body_string = extract_request_body_string(&request);
Ok((client, request, body_string))
}
pub async fn handle_json_response<T: DeserializeOwned>(
response: reqwest::Response,
request_body: Option<String>,
) -> Result<T> {
let status = response.status();
let url = response.url().to_string();
let response_text =
response
.text()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Failed to read response body".to_string(),
})?;
if !status.is_success() {
return Err(AlienError::new(ErrorData::HttpResponseError {
message: format!(
"Request failed with HTTP {}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown error")
),
url,
http_status: status.as_u16(),
http_request_text: request_body,
http_response_text: Some(response_text),
}));
}
let jd = &mut serde_json::Deserializer::from_str(&response_text);
let parsed_response: T = serde_path_to_error::deserialize(jd).map_err(|err| {
AlienError::new(ErrorData::HttpResponseError {
message: format!(
"Invalid JSON response at field '{}': {}",
err.path(),
err.inner()
),
url,
http_status: status.as_u16(),
http_request_text: request_body,
http_response_text: Some(response_text),
})
})?;
Ok(parsed_response)
}
pub async fn handle_xml_response<T: DeserializeOwned>(
response: reqwest::Response,
request_body: Option<String>,
) -> Result<T> {
let status = response.status();
let url = response.url().to_string();
let response_text =
response
.text()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Failed to read response body".to_string(),
})?;
if !status.is_success() {
return Err(AlienError::new(ErrorData::HttpResponseError {
message: format!(
"Request failed with HTTP {}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown error")
),
url,
http_status: status.as_u16(),
http_request_text: request_body,
http_response_text: Some(response_text),
}));
}
let mut xml_deserializer = quick_xml::de::Deserializer::from_str(&response_text);
let parsed_response: T =
serde_path_to_error::deserialize(&mut xml_deserializer).map_err(|err| {
AlienError::new(ErrorData::HttpResponseError {
message: format!(
"Invalid XML response at field '{}': {}",
err.path(),
err.inner()
),
url,
http_status: status.as_u16(),
http_request_text: request_body,
http_response_text: Some(response_text),
})
})?;
Ok(parsed_response)
}
pub async fn handle_no_response(
response: reqwest::Response,
request_body: Option<String>,
) -> Result<()> {
let status = response.status();
let url = response.url().to_string();
if !status.is_success() {
let response_text =
response
.text()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Failed to read error response body".to_string(),
})?;
return Err(AlienError::new(ErrorData::HttpResponseError {
message: format!(
"Request failed with HTTP {}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown error")
),
url,
http_status: status.as_u16(),
http_request_text: request_body,
http_response_text: Some(response_text),
}));
}
Ok(())
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait RequestBuilderExt {
fn with_retry(self) -> RetriableRequestBuilder;
async fn send_json<T: DeserializeOwned + 'static>(self) -> Result<T>;
async fn send_xml<T: DeserializeOwned + 'static>(self) -> Result<T>;
async fn send_no_response(self) -> Result<()>;
async fn send_raw(self) -> Result<reqwest::Response>;
}
pub struct RetriableRequestBuilder {
inner: reqwest::RequestBuilder,
backoff: ExponentialBuilder,
}
impl RetriableRequestBuilder {
pub fn backoff(mut self, backoff: ExponentialBuilder) -> Self {
self.backoff = backoff;
self
}
fn is_retryable_error(e: &AlienError<ErrorData>) -> bool {
e.retryable
}
fn default_backoff() -> ExponentialBuilder {
ExponentialBuilder::default()
.with_max_times(3)
.with_max_delay(Duration::from_secs(20))
.with_jitter()
}
pub async fn send_json<T: DeserializeOwned + Send + 'static>(self) -> Result<T> {
let backoff = self.backoff;
let builder = self.inner;
let retryable = move || {
let attempt_builder = builder.try_clone();
async move {
let attempt_builder = attempt_builder.ok_or_else(|| {
AlienError::new(ErrorData::GenericError {
message: "Request retry preparation failed".into(),
})
})?;
let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
let new_builder = reqwest::RequestBuilder::from_parts(client, request);
#[cfg(target_arch = "wasm32")]
{
let resp = new_builder.send().await.into_alien_error().context(
ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
},
)?;
handle_json_response(resp, body_string).await
}
#[cfg(not(target_arch = "wasm32"))]
{
let resp = new_builder.send().await.into_alien_error().context(
ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
},
)?;
handle_json_response(resp, body_string).await
}
}
};
retryable
.retry(backoff)
.when(Self::is_retryable_error)
.await
}
pub async fn send_xml<T: DeserializeOwned + Send + 'static>(self) -> Result<T> {
let backoff = self.backoff;
let builder = self.inner;
let retryable = move || {
let attempt_builder = builder.try_clone();
async move {
let attempt_builder = attempt_builder.ok_or_else(|| {
AlienError::new(ErrorData::GenericError {
message: "Request retry preparation failed".into(),
})
})?;
let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
let new_builder = reqwest::RequestBuilder::from_parts(client, request);
#[cfg(target_arch = "wasm32")]
{
let resp = new_builder.send().await.into_alien_error().context(
ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
},
)?;
handle_xml_response(resp, body_string).await
}
#[cfg(not(target_arch = "wasm32"))]
{
let resp = new_builder.send().await.into_alien_error().context(
ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
},
)?;
handle_xml_response(resp, body_string).await
}
}
};
retryable
.retry(backoff)
.when(Self::is_retryable_error)
.await
}
pub async fn send_no_response(self) -> Result<()> {
let backoff = self.backoff;
let builder = self.inner;
let retryable = move || {
let attempt_builder = builder.try_clone();
async move {
let attempt_builder = attempt_builder.ok_or_else(|| {
AlienError::new(ErrorData::GenericError {
message: "Request retry preparation failed".into(),
})
})?;
let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
let new_builder = reqwest::RequestBuilder::from_parts(client, request);
#[cfg(target_arch = "wasm32")]
{
let resp = new_builder.send().await.into_alien_error().context(
ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
},
)?;
handle_no_response(resp, body_string).await
}
#[cfg(not(target_arch = "wasm32"))]
{
let resp = new_builder.send().await.into_alien_error().context(
ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
},
)?;
handle_no_response(resp, body_string).await
}
}
};
retryable
.retry(backoff)
.when(Self::is_retryable_error)
.await
}
pub async fn send_raw(self) -> Result<reqwest::Response> {
let backoff = self.backoff;
let builder = self.inner;
let retryable = move || {
let attempt_builder = builder.try_clone();
async move {
let attempt_builder = attempt_builder.ok_or_else(|| {
AlienError::new(ErrorData::GenericError {
message: "Request retry preparation failed".into(),
})
})?;
let (client, request, _body_string) = build_and_extract_body(attempt_builder)?;
let new_builder = reqwest::RequestBuilder::from_parts(client, request);
#[cfg(target_arch = "wasm32")]
{
new_builder.send().await.into_alien_error().context(
ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
},
)
}
#[cfg(not(target_arch = "wasm32"))]
{
new_builder.send().await.into_alien_error().context(
ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
},
)
}
}
};
retryable
.retry(backoff)
.when(Self::is_retryable_error)
.await
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl RequestBuilderExt for reqwest::RequestBuilder {
fn with_retry(self) -> RetriableRequestBuilder {
RetriableRequestBuilder {
inner: self,
backoff: RetriableRequestBuilder::default_backoff(),
}
}
async fn send_json<T: DeserializeOwned + 'static>(self) -> Result<T> {
let (client, request, body_string) = build_and_extract_body(self)?;
let builder = reqwest::RequestBuilder::from_parts(client, request);
#[cfg(target_arch = "wasm32")]
{
let resp =
builder
.send()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
})?;
handle_json_response(resp, body_string).await
}
#[cfg(not(target_arch = "wasm32"))]
{
let resp =
builder
.send()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
})?;
handle_json_response(resp, body_string).await
}
}
async fn send_xml<T: DeserializeOwned + 'static>(self) -> Result<T> {
let (client, request, body_string) = build_and_extract_body(self)?;
let builder = reqwest::RequestBuilder::from_parts(client, request);
#[cfg(target_arch = "wasm32")]
{
let resp =
builder
.send()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
})?;
handle_xml_response(resp, body_string).await
}
#[cfg(not(target_arch = "wasm32"))]
{
let resp =
builder
.send()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
})?;
handle_xml_response(resp, body_string).await
}
}
async fn send_no_response(self) -> Result<()> {
let (client, request, body_string) = build_and_extract_body(self)?;
let builder = reqwest::RequestBuilder::from_parts(client, request);
#[cfg(target_arch = "wasm32")]
{
let resp =
builder
.send()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
})?;
handle_no_response(resp, body_string).await
}
#[cfg(not(target_arch = "wasm32"))]
{
let resp =
builder
.send()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
})?;
handle_no_response(resp, body_string).await
}
}
async fn send_raw(self) -> Result<reqwest::Response> {
let (client, request, _body_string) = build_and_extract_body(self)?;
let builder = reqwest::RequestBuilder::from_parts(client, request);
#[cfg(target_arch = "wasm32")]
{
builder
.send()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
})
}
#[cfg(not(target_arch = "wasm32"))]
{
builder
.send()
.await
.into_alien_error()
.context(ErrorData::HttpRequestFailed {
message: "Network error during HTTP request".to_string(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use alien_error::ContextError;
const SECRET: &str = "Sup3rSecret-MasterPassword!";
fn http_error_with_secret_body() -> AlienError<ErrorData> {
AlienError::new(ErrorData::HttpResponseError {
message: "Request failed with HTTP 409: Conflict".to_string(),
url: "https://rds.amazonaws.com/".to_string(),
http_status: 409,
http_request_text: Some(format!(
"Action=CreateDBCluster&MasterUserPassword={SECRET}&Engine=aurora-postgresql"
)),
http_response_text: Some(
"<Error><Code>DBClusterAlreadyExists</Code></Error>".to_string(),
),
})
}
#[test]
fn redacts_request_body_when_http_error_is_head() {
let raw = serde_json::to_string(&http_error_with_secret_body()).unwrap();
assert!(raw.contains(SECRET), "fixture should carry the secret");
let err = redact_request_body::<()>(Err(http_error_with_secret_body())).unwrap_err();
let json = serde_json::to_string(&err).expect("serialize redacted error");
assert!(!json.contains(SECRET), "request body leaked: {json}");
assert!(json.contains("409"), "status dropped: {json}");
assert!(
json.contains("DBClusterAlreadyExists"),
"response text dropped: {json}"
);
}
#[test]
fn redacts_request_body_when_http_error_is_wrapped_in_source() {
let wrapped = http_error_with_secret_body().context(ErrorData::RemoteResourceConflict {
resource_type: "DBCluster".to_string(),
resource_name: "stack-db".to_string(),
message: "already exists".to_string(),
});
let before = serde_json::to_string(&wrapped).unwrap();
assert!(
before.contains(SECRET),
"precondition: a non-internal head must NOT sanitize its source on its own"
);
let err = redact_request_body::<()>(Err(wrapped)).unwrap_err();
let json = serde_json::to_string(&err).expect("serialize redacted error");
assert!(
!json.contains(SECRET),
"request body leaked through source chain: {json}"
);
assert!(
json.contains("REMOTE_RESOURCE_CONFLICT"),
"head dropped: {json}"
);
assert!(
json.contains("DBClusterAlreadyExists"),
"response text dropped: {json}"
);
}
}