Skip to main content

alien_client_core/
request_utils.rs

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
8/// Extract request body as string from a reqwest::Request if available
9fn 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
16/// JSON key under which the captured request body lives in an error's `context` snapshot. The
17/// `AlienErrorData` derive builds `context` from the raw field name, so this must match the
18/// `http_request_text` field of [`ErrorData::HttpResponseError`] verbatim. The redaction tests below
19/// serialize the whole chain and assert the secret is gone, so a drift here fails loudly.
20const HTTP_REQUEST_TEXT_CONTEXT_KEY: &str = "http_request_text";
21
22/// Strips the captured HTTP request body from every layer of an error chain.
23///
24/// Cloud "create" requests carry secrets in the request body (e.g. a DB master password). On a
25/// non-2xx response the transport records that body in [`ErrorData::HttpResponseError`], in both the
26/// typed payload and the `context` JSON snapshot. A non-internal `.context(...)` wrapper does not
27/// sanitize its source, and the source chain is serialized verbatim into durable state and status
28/// responses — so the body could leak.
29///
30/// This scrubs both representations across the head and full `source` chain, keeping status, response
31/// text, URL, and chain intact. Response text is kept deliberately: RDS / Cloud SQL / Flexible Server
32/// error bodies don't echo the submitted password back, so it stays as a diagnostic — a future caller
33/// wiring this to an API that *does* reflect request fields in its error responses would need to scrub
34/// that too. Order-independent: works whether the HTTP error is still the head (AWS: redaction before
35/// mapping) or already wrapped into the source (GCP/Azure: transport maps first). Apply to the raw
36/// transport result of any request whose body contains a secret.
37pub fn redact_request_body<T>(result: Result<T>) -> Result<T> {
38    result.map_err(|mut e| {
39        // Head: drop the body from the typed payload when the head itself is the HTTP error...
40        if let Some(ErrorData::HttpResponseError {
41            http_request_text, ..
42        }) = e.error.as_mut()
43        {
44            *http_request_text = None;
45        }
46        // ...and from the head's `context` snapshot.
47        scrub_request_body(e.context.as_mut());
48        // Walk the source chain (each layer is type-erased to `GenericError`, so its typed payload no
49        // longer holds the body — only its `context` snapshot can) and scrub every layer.
50        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
59/// Removes the captured request body from a single error's `context` snapshot, if present.
60fn 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
66/// Helper to build request and extract body before sending
67fn 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
81/// Handle an HTTP response by checking status and parsing JSON on success
82pub 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    // Parse the JSON response using serde_path_to_error for better error messages
112    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
130/// Handle an HTTP response by checking status and parsing XML on success
131pub 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    // Parse the XML response using serde_path_to_error for better error messages
161    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
180/// Handle an HTTP response by checking status without parsing the body
181pub 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/// Extension trait for `reqwest::RequestBuilder` to add JSON and XML response handling
214#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
215#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
216pub trait RequestBuilderExt {
217    /// Enable retries with an exponential back-off strategy.
218    ///
219    /// Example:
220    /// ```ignore
221    /// let obj: MyType = client
222    ///     .get("https://api.example.com/obj")
223    ///     .with_retry()                // <- enable retries
224    ///     .send_json()                // <- deserialize JSON body
225    ///     .await?;
226    /// ```
227    fn with_retry(self) -> RetriableRequestBuilder;
228
229    /// Send the request and parse the response as JSON
230    async fn send_json<T: DeserializeOwned + 'static>(self) -> Result<T>;
231
232    /// Send the request and parse the response as XML
233    async fn send_xml<T: DeserializeOwned + 'static>(self) -> Result<T>;
234
235    /// Send the request without parsing the response body
236    async fn send_no_response(self) -> Result<()>;
237
238    /// Send the request and return the raw response for custom handling
239    async fn send_raw(self) -> Result<reqwest::Response>;
240}
241
242/// A `reqwest::RequestBuilder` wrapper that automatically retries failed
243/// requests using an exponential back-off strategy powered by the `backon`
244/// crate. Use [`RequestBuilderExt::with_retry`] to construct one.
245pub struct RetriableRequestBuilder {
246    inner: reqwest::RequestBuilder,
247    backoff: ExponentialBuilder,
248}
249
250impl RetriableRequestBuilder {
251    /// Overrides the default back-off settings.
252    pub fn backoff(mut self, backoff: ExponentialBuilder) -> Self {
253        self.backoff = backoff;
254        self
255    }
256
257    /// Determine if a given error is retry-able using the retryable field.
258    fn is_retryable_error(e: &AlienError<ErrorData>) -> bool {
259        e.retryable
260    }
261
262    /// Creates a default exponential back-off (max 3 attempts, up to 20s).
263    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    /// Execute the request, applying retries, and parse the body as JSON.
271    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    /// Execute the request, applying retries, and parse the body as XML.
315    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    /// Execute the request, applying retries, without parsing the response body.
359    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    /// Execute the request, applying retries, and return the raw response
403    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    /// An `HttpResponseError` as the transport produces it for a failed create: its captured request
580    /// body carries the master password, in both the typed payload and the derived `context` snapshot.
581    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    /// AWS path: redaction runs while the HTTP error is still the head. The secret must not survive in
596    /// any serialized field — typed payload or `context` — while diagnostics are kept.
597    #[test]
598    fn redacts_request_body_when_http_error_is_head() {
599        // Precondition: the unredacted error genuinely carries the secret, so this test can fail.
600        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        // Non-secret diagnostics are retained.
607        assert!(json.contains("409"), "status dropped: {json}");
608        assert!(
609            json.contains("DBClusterAlreadyExists"),
610            "response text dropped: {json}"
611        );
612    }
613
614    /// GCP/Azure path: the transport maps the HTTP error to a non-internal head before redaction runs,
615    /// so the body now lives only in the `source` chain's `context`. The non-internal head does NOT
616    /// sanitize its source by itself (asserted as a precondition), so the whole-chain walk is
617    /// load-bearing here.
618    #[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        // The chain and its non-secret diagnostics are otherwise intact.
638        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}