1use crate::error::{ErrorData, Result};
2use alien_error::{AlienError, Context, IntoAlienError};
3use async_trait::async_trait;
4use backon::{ExponentialBuilder, Retryable};
5use serde::de::DeserializeOwned;
6use std::time::Duration;
7
8fn extract_request_body_string(request: &reqwest::Request) -> Option<String> {
10 request
11 .body()
12 .and_then(|body| body.as_bytes())
13 .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
14}
15
16const HTTP_REQUEST_TEXT_CONTEXT_KEY: &str = "http_request_text";
21
22pub fn redact_request_body<T>(result: Result<T>) -> Result<T> {
38 result.map_err(|mut e| {
39 if let Some(ErrorData::HttpResponseError {
41 http_request_text, ..
42 }) = e.error.as_mut()
43 {
44 *http_request_text = None;
45 }
46 scrub_request_body(e.context.as_mut());
48 let mut layer = e.source.as_deref_mut();
51 while let Some(err) = layer {
52 scrub_request_body(err.context.as_mut());
53 layer = err.source.as_deref_mut();
54 }
55 e
56 })
57}
58
59fn scrub_request_body(context: Option<&mut serde_json::Value>) {
61 if let Some(serde_json::Value::Object(map)) = context {
62 map.remove(HTTP_REQUEST_TEXT_CONTEXT_KEY);
63 }
64}
65
66fn build_and_extract_body(
68 builder: reqwest::RequestBuilder,
69) -> Result<(reqwest::Client, reqwest::Request, Option<String>)> {
70 let (client, req_result) = builder.build_split();
71 let request = req_result
72 .into_alien_error()
73 .context(ErrorData::HttpRequestFailed {
74 message: "Failed to build request".to_string(),
75 })?;
76
77 let body_string = extract_request_body_string(&request);
78 Ok((client, request, body_string))
79}
80
81pub async fn handle_json_response<T: DeserializeOwned>(
83 response: reqwest::Response,
84 request_body: Option<String>,
85) -> Result<T> {
86 let status = response.status();
87 let url = response.url().to_string();
88 let response_text =
89 response
90 .text()
91 .await
92 .into_alien_error()
93 .context(ErrorData::HttpRequestFailed {
94 message: "Failed to read response body".to_string(),
95 })?;
96
97 if !status.is_success() {
98 return Err(AlienError::new(ErrorData::HttpResponseError {
99 message: format!(
100 "Request failed with HTTP {}: {}",
101 status.as_u16(),
102 status.canonical_reason().unwrap_or("Unknown error")
103 ),
104 url,
105 http_status: status.as_u16(),
106 http_request_text: request_body,
107 http_response_text: Some(response_text),
108 }));
109 }
110
111 let jd = &mut serde_json::Deserializer::from_str(&response_text);
113 let parsed_response: T = serde_path_to_error::deserialize(jd).map_err(|err| {
114 AlienError::new(ErrorData::HttpResponseError {
115 message: format!(
116 "Invalid JSON response at field '{}': {}",
117 err.path(),
118 err.inner()
119 ),
120 url,
121 http_status: status.as_u16(),
122 http_request_text: request_body,
123 http_response_text: Some(response_text),
124 })
125 })?;
126
127 Ok(parsed_response)
128}
129
130pub async fn handle_xml_response<T: DeserializeOwned>(
132 response: reqwest::Response,
133 request_body: Option<String>,
134) -> Result<T> {
135 let status = response.status();
136 let url = response.url().to_string();
137 let response_text =
138 response
139 .text()
140 .await
141 .into_alien_error()
142 .context(ErrorData::HttpRequestFailed {
143 message: "Failed to read response body".to_string(),
144 })?;
145
146 if !status.is_success() {
147 return Err(AlienError::new(ErrorData::HttpResponseError {
148 message: format!(
149 "Request failed with HTTP {}: {}",
150 status.as_u16(),
151 status.canonical_reason().unwrap_or("Unknown error")
152 ),
153 url,
154 http_status: status.as_u16(),
155 http_request_text: request_body,
156 http_response_text: Some(response_text),
157 }));
158 }
159
160 let mut xml_deserializer = quick_xml::de::Deserializer::from_str(&response_text);
162 let parsed_response: T =
163 serde_path_to_error::deserialize(&mut xml_deserializer).map_err(|err| {
164 AlienError::new(ErrorData::HttpResponseError {
165 message: format!(
166 "Invalid XML response at field '{}': {}",
167 err.path(),
168 err.inner()
169 ),
170 url,
171 http_status: status.as_u16(),
172 http_request_text: request_body,
173 http_response_text: Some(response_text),
174 })
175 })?;
176
177 Ok(parsed_response)
178}
179
180pub async fn handle_no_response(
182 response: reqwest::Response,
183 request_body: Option<String>,
184) -> Result<()> {
185 let status = response.status();
186 let url = response.url().to_string();
187
188 if !status.is_success() {
189 let response_text =
190 response
191 .text()
192 .await
193 .into_alien_error()
194 .context(ErrorData::HttpRequestFailed {
195 message: "Failed to read error response body".to_string(),
196 })?;
197 return Err(AlienError::new(ErrorData::HttpResponseError {
198 message: format!(
199 "Request failed with HTTP {}: {}",
200 status.as_u16(),
201 status.canonical_reason().unwrap_or("Unknown error")
202 ),
203 url,
204 http_status: status.as_u16(),
205 http_request_text: request_body,
206 http_response_text: Some(response_text),
207 }));
208 }
209
210 Ok(())
211}
212
213#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
215#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
216pub trait RequestBuilderExt {
217 fn with_retry(self) -> RetriableRequestBuilder;
228
229 async fn send_json<T: DeserializeOwned + 'static>(self) -> Result<T>;
231
232 async fn send_xml<T: DeserializeOwned + 'static>(self) -> Result<T>;
234
235 async fn send_no_response(self) -> Result<()>;
237
238 async fn send_raw(self) -> Result<reqwest::Response>;
240}
241
242pub struct RetriableRequestBuilder {
246 inner: reqwest::RequestBuilder,
247 backoff: ExponentialBuilder,
248}
249
250impl RetriableRequestBuilder {
251 pub fn backoff(mut self, backoff: ExponentialBuilder) -> Self {
253 self.backoff = backoff;
254 self
255 }
256
257 fn is_retryable_error(e: &AlienError<ErrorData>) -> bool {
259 e.retryable
260 }
261
262 fn default_backoff() -> ExponentialBuilder {
264 ExponentialBuilder::default()
265 .with_max_times(3)
266 .with_max_delay(Duration::from_secs(20))
267 .with_jitter()
268 }
269
270 pub async fn send_json<T: DeserializeOwned + Send + 'static>(self) -> Result<T> {
272 let backoff = self.backoff;
273 let builder = self.inner;
274
275 let retryable = move || {
276 let attempt_builder = builder.try_clone();
277 async move {
278 let attempt_builder = attempt_builder.ok_or_else(|| {
279 AlienError::new(ErrorData::GenericError {
280 message: "Request retry preparation failed".into(),
281 })
282 })?;
283
284 let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
285 let new_builder = reqwest::RequestBuilder::from_parts(client, request);
286
287 #[cfg(target_arch = "wasm32")]
288 {
289 let resp = new_builder.send().await.into_alien_error().context(
290 ErrorData::HttpRequestFailed {
291 message: "Network error during HTTP request".to_string(),
292 },
293 )?;
294 handle_json_response(resp, body_string).await
295 }
296 #[cfg(not(target_arch = "wasm32"))]
297 {
298 let resp = new_builder.send().await.into_alien_error().context(
299 ErrorData::HttpRequestFailed {
300 message: "Network error during HTTP request".to_string(),
301 },
302 )?;
303 handle_json_response(resp, body_string).await
304 }
305 }
306 };
307
308 retryable
309 .retry(backoff)
310 .when(Self::is_retryable_error)
311 .await
312 }
313
314 pub async fn send_xml<T: DeserializeOwned + Send + 'static>(self) -> Result<T> {
316 let backoff = self.backoff;
317 let builder = self.inner;
318
319 let retryable = move || {
320 let attempt_builder = builder.try_clone();
321 async move {
322 let attempt_builder = attempt_builder.ok_or_else(|| {
323 AlienError::new(ErrorData::GenericError {
324 message: "Request retry preparation failed".into(),
325 })
326 })?;
327
328 let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
329 let new_builder = reqwest::RequestBuilder::from_parts(client, request);
330
331 #[cfg(target_arch = "wasm32")]
332 {
333 let resp = new_builder.send().await.into_alien_error().context(
334 ErrorData::HttpRequestFailed {
335 message: "Network error during HTTP request".to_string(),
336 },
337 )?;
338 handle_xml_response(resp, body_string).await
339 }
340 #[cfg(not(target_arch = "wasm32"))]
341 {
342 let resp = new_builder.send().await.into_alien_error().context(
343 ErrorData::HttpRequestFailed {
344 message: "Network error during HTTP request".to_string(),
345 },
346 )?;
347 handle_xml_response(resp, body_string).await
348 }
349 }
350 };
351
352 retryable
353 .retry(backoff)
354 .when(Self::is_retryable_error)
355 .await
356 }
357
358 pub async fn send_no_response(self) -> Result<()> {
360 let backoff = self.backoff;
361 let builder = self.inner;
362
363 let retryable = move || {
364 let attempt_builder = builder.try_clone();
365 async move {
366 let attempt_builder = attempt_builder.ok_or_else(|| {
367 AlienError::new(ErrorData::GenericError {
368 message: "Request retry preparation failed".into(),
369 })
370 })?;
371
372 let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
373 let new_builder = reqwest::RequestBuilder::from_parts(client, request);
374
375 #[cfg(target_arch = "wasm32")]
376 {
377 let resp = new_builder.send().await.into_alien_error().context(
378 ErrorData::HttpRequestFailed {
379 message: "Network error during HTTP request".to_string(),
380 },
381 )?;
382 handle_no_response(resp, body_string).await
383 }
384 #[cfg(not(target_arch = "wasm32"))]
385 {
386 let resp = new_builder.send().await.into_alien_error().context(
387 ErrorData::HttpRequestFailed {
388 message: "Network error during HTTP request".to_string(),
389 },
390 )?;
391 handle_no_response(resp, body_string).await
392 }
393 }
394 };
395
396 retryable
397 .retry(backoff)
398 .when(Self::is_retryable_error)
399 .await
400 }
401
402 pub async fn send_raw(self) -> Result<reqwest::Response> {
404 let backoff = self.backoff;
405 let builder = self.inner;
406
407 let retryable = move || {
408 let attempt_builder = builder.try_clone();
409 async move {
410 let attempt_builder = attempt_builder.ok_or_else(|| {
411 AlienError::new(ErrorData::GenericError {
412 message: "Request retry preparation failed".into(),
413 })
414 })?;
415
416 let (client, request, _body_string) = build_and_extract_body(attempt_builder)?;
417 let new_builder = reqwest::RequestBuilder::from_parts(client, request);
418
419 #[cfg(target_arch = "wasm32")]
420 {
421 new_builder.send().await.into_alien_error().context(
422 ErrorData::HttpRequestFailed {
423 message: "Network error during HTTP request".to_string(),
424 },
425 )
426 }
427 #[cfg(not(target_arch = "wasm32"))]
428 {
429 new_builder.send().await.into_alien_error().context(
430 ErrorData::HttpRequestFailed {
431 message: "Network error during HTTP request".to_string(),
432 },
433 )
434 }
435 }
436 };
437
438 retryable
439 .retry(backoff)
440 .when(Self::is_retryable_error)
441 .await
442 }
443}
444
445#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
446#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
447impl RequestBuilderExt for reqwest::RequestBuilder {
448 fn with_retry(self) -> RetriableRequestBuilder {
449 RetriableRequestBuilder {
450 inner: self,
451 backoff: RetriableRequestBuilder::default_backoff(),
452 }
453 }
454
455 async fn send_json<T: DeserializeOwned + 'static>(self) -> Result<T> {
456 let (client, request, body_string) = build_and_extract_body(self)?;
457 let builder = reqwest::RequestBuilder::from_parts(client, request);
458
459 #[cfg(target_arch = "wasm32")]
460 {
461 let resp =
462 builder
463 .send()
464 .await
465 .into_alien_error()
466 .context(ErrorData::HttpRequestFailed {
467 message: "Network error during HTTP request".to_string(),
468 })?;
469 handle_json_response(resp, body_string).await
470 }
471 #[cfg(not(target_arch = "wasm32"))]
472 {
473 let resp =
474 builder
475 .send()
476 .await
477 .into_alien_error()
478 .context(ErrorData::HttpRequestFailed {
479 message: "Network error during HTTP request".to_string(),
480 })?;
481 handle_json_response(resp, body_string).await
482 }
483 }
484
485 async fn send_xml<T: DeserializeOwned + 'static>(self) -> Result<T> {
486 let (client, request, body_string) = build_and_extract_body(self)?;
487 let builder = reqwest::RequestBuilder::from_parts(client, request);
488
489 #[cfg(target_arch = "wasm32")]
490 {
491 let resp =
492 builder
493 .send()
494 .await
495 .into_alien_error()
496 .context(ErrorData::HttpRequestFailed {
497 message: "Network error during HTTP request".to_string(),
498 })?;
499 handle_xml_response(resp, body_string).await
500 }
501 #[cfg(not(target_arch = "wasm32"))]
502 {
503 let resp =
504 builder
505 .send()
506 .await
507 .into_alien_error()
508 .context(ErrorData::HttpRequestFailed {
509 message: "Network error during HTTP request".to_string(),
510 })?;
511 handle_xml_response(resp, body_string).await
512 }
513 }
514
515 async fn send_no_response(self) -> Result<()> {
516 let (client, request, body_string) = build_and_extract_body(self)?;
517 let builder = reqwest::RequestBuilder::from_parts(client, request);
518
519 #[cfg(target_arch = "wasm32")]
520 {
521 let resp =
522 builder
523 .send()
524 .await
525 .into_alien_error()
526 .context(ErrorData::HttpRequestFailed {
527 message: "Network error during HTTP request".to_string(),
528 })?;
529 handle_no_response(resp, body_string).await
530 }
531 #[cfg(not(target_arch = "wasm32"))]
532 {
533 let resp =
534 builder
535 .send()
536 .await
537 .into_alien_error()
538 .context(ErrorData::HttpRequestFailed {
539 message: "Network error during HTTP request".to_string(),
540 })?;
541 handle_no_response(resp, body_string).await
542 }
543 }
544
545 async fn send_raw(self) -> Result<reqwest::Response> {
546 let (client, request, _body_string) = build_and_extract_body(self)?;
547 let builder = reqwest::RequestBuilder::from_parts(client, request);
548
549 #[cfg(target_arch = "wasm32")]
550 {
551 builder
552 .send()
553 .await
554 .into_alien_error()
555 .context(ErrorData::HttpRequestFailed {
556 message: "Network error during HTTP request".to_string(),
557 })
558 }
559 #[cfg(not(target_arch = "wasm32"))]
560 {
561 builder
562 .send()
563 .await
564 .into_alien_error()
565 .context(ErrorData::HttpRequestFailed {
566 message: "Network error during HTTP request".to_string(),
567 })
568 }
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use alien_error::ContextError;
576
577 const SECRET: &str = "Sup3rSecret-MasterPassword!";
578
579 fn http_error_with_secret_body() -> AlienError<ErrorData> {
582 AlienError::new(ErrorData::HttpResponseError {
583 message: "Request failed with HTTP 409: Conflict".to_string(),
584 url: "https://rds.amazonaws.com/".to_string(),
585 http_status: 409,
586 http_request_text: Some(format!(
587 "Action=CreateDBCluster&MasterUserPassword={SECRET}&Engine=aurora-postgresql"
588 )),
589 http_response_text: Some(
590 "<Error><Code>DBClusterAlreadyExists</Code></Error>".to_string(),
591 ),
592 })
593 }
594
595 #[test]
598 fn redacts_request_body_when_http_error_is_head() {
599 let raw = serde_json::to_string(&http_error_with_secret_body()).unwrap();
601 assert!(raw.contains(SECRET), "fixture should carry the secret");
602
603 let err = redact_request_body::<()>(Err(http_error_with_secret_body())).unwrap_err();
604 let json = serde_json::to_string(&err).expect("serialize redacted error");
605 assert!(!json.contains(SECRET), "request body leaked: {json}");
606 assert!(json.contains("409"), "status dropped: {json}");
608 assert!(
609 json.contains("DBClusterAlreadyExists"),
610 "response text dropped: {json}"
611 );
612 }
613
614 #[test]
619 fn redacts_request_body_when_http_error_is_wrapped_in_source() {
620 let wrapped = http_error_with_secret_body().context(ErrorData::RemoteResourceConflict {
621 resource_type: "DBCluster".to_string(),
622 resource_name: "stack-db".to_string(),
623 message: "already exists".to_string(),
624 });
625 let before = serde_json::to_string(&wrapped).unwrap();
626 assert!(
627 before.contains(SECRET),
628 "precondition: a non-internal head must NOT sanitize its source on its own"
629 );
630
631 let err = redact_request_body::<()>(Err(wrapped)).unwrap_err();
632 let json = serde_json::to_string(&err).expect("serialize redacted error");
633 assert!(
634 !json.contains(SECRET),
635 "request body leaked through source chain: {json}"
636 );
637 assert!(
639 json.contains("REMOTE_RESOURCE_CONFLICT"),
640 "head dropped: {json}"
641 );
642 assert!(
643 json.contains("DBClusterAlreadyExists"),
644 "response text dropped: {json}"
645 );
646 }
647}