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/// Helper to build request and extract body before sending
17fn build_and_extract_body(
18    builder: reqwest::RequestBuilder,
19) -> Result<(reqwest::Client, reqwest::Request, Option<String>)> {
20    let (client, req_result) = builder.build_split();
21    let request = req_result
22        .into_alien_error()
23        .context(ErrorData::HttpRequestFailed {
24            message: "Failed to build request".to_string(),
25        })?;
26
27    let body_string = extract_request_body_string(&request);
28    Ok((client, request, body_string))
29}
30
31/// Handle an HTTP response by checking status and parsing JSON on success
32pub async fn handle_json_response<T: DeserializeOwned>(
33    response: reqwest::Response,
34    request_body: Option<String>,
35) -> Result<T> {
36    let status = response.status();
37    let url = response.url().to_string();
38    let response_text =
39        response
40            .text()
41            .await
42            .into_alien_error()
43            .context(ErrorData::HttpRequestFailed {
44                message: "Failed to read response body".to_string(),
45            })?;
46
47    if !status.is_success() {
48        return Err(AlienError::new(ErrorData::HttpResponseError {
49            message: format!(
50                "Request failed with HTTP {}: {}",
51                status.as_u16(),
52                status.canonical_reason().unwrap_or("Unknown error")
53            ),
54            url,
55            http_status: status.as_u16(),
56            http_request_text: request_body,
57            http_response_text: Some(response_text),
58        }));
59    }
60
61    // Parse the JSON response using serde_path_to_error for better error messages
62    let jd = &mut serde_json::Deserializer::from_str(&response_text);
63    let parsed_response: T = serde_path_to_error::deserialize(jd).map_err(|err| {
64        AlienError::new(ErrorData::HttpResponseError {
65            message: format!(
66                "Invalid JSON response at field '{}': {}",
67                err.path(),
68                err.inner()
69            ),
70            url,
71            http_status: status.as_u16(),
72            http_request_text: request_body,
73            http_response_text: Some(response_text),
74        })
75    })?;
76
77    Ok(parsed_response)
78}
79
80/// Handle an HTTP response by checking status and parsing XML on success
81pub async fn handle_xml_response<T: DeserializeOwned>(
82    response: reqwest::Response,
83    request_body: Option<String>,
84) -> Result<T> {
85    let status = response.status();
86    let url = response.url().to_string();
87    let response_text =
88        response
89            .text()
90            .await
91            .into_alien_error()
92            .context(ErrorData::HttpRequestFailed {
93                message: "Failed to read response body".to_string(),
94            })?;
95
96    if !status.is_success() {
97        return Err(AlienError::new(ErrorData::HttpResponseError {
98            message: format!(
99                "Request failed with HTTP {}: {}",
100                status.as_u16(),
101                status.canonical_reason().unwrap_or("Unknown error")
102            ),
103            url,
104            http_status: status.as_u16(),
105            http_request_text: request_body,
106            http_response_text: Some(response_text),
107        }));
108    }
109
110    // Parse the XML response using serde_path_to_error for better error messages
111    let mut xml_deserializer = quick_xml::de::Deserializer::from_str(&response_text);
112    let parsed_response: T =
113        serde_path_to_error::deserialize(&mut xml_deserializer).map_err(|err| {
114            AlienError::new(ErrorData::HttpResponseError {
115                message: format!(
116                    "Invalid XML 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 without parsing the body
131pub async fn handle_no_response(
132    response: reqwest::Response,
133    request_body: Option<String>,
134) -> Result<()> {
135    let status = response.status();
136    let url = response.url().to_string();
137
138    if !status.is_success() {
139        let response_text =
140            response
141                .text()
142                .await
143                .into_alien_error()
144                .context(ErrorData::HttpRequestFailed {
145                    message: "Failed to read error response body".to_string(),
146                })?;
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    Ok(())
161}
162
163/// Extension trait for `reqwest::RequestBuilder` to add JSON and XML response handling
164#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
165#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
166pub trait RequestBuilderExt {
167    /// Enable retries with an exponential back-off strategy.
168    ///
169    /// Example:
170    /// ```ignore
171    /// let obj: MyType = client
172    ///     .get("https://api.example.com/obj")
173    ///     .with_retry()                // <- enable retries
174    ///     .send_json()                // <- deserialize JSON body
175    ///     .await?;
176    /// ```
177    fn with_retry(self) -> RetriableRequestBuilder;
178
179    /// Send the request and parse the response as JSON
180    async fn send_json<T: DeserializeOwned + 'static>(self) -> Result<T>;
181
182    /// Send the request and parse the response as XML
183    async fn send_xml<T: DeserializeOwned + 'static>(self) -> Result<T>;
184
185    /// Send the request without parsing the response body
186    async fn send_no_response(self) -> Result<()>;
187
188    /// Send the request and return the raw response for custom handling
189    async fn send_raw(self) -> Result<reqwest::Response>;
190}
191
192/// A `reqwest::RequestBuilder` wrapper that automatically retries failed
193/// requests using an exponential back-off strategy powered by the `backon`
194/// crate. Use [`RequestBuilderExt::with_retry`] to construct one.
195pub struct RetriableRequestBuilder {
196    inner: reqwest::RequestBuilder,
197    backoff: ExponentialBuilder,
198}
199
200impl RetriableRequestBuilder {
201    /// Overrides the default back-off settings.
202    pub fn backoff(mut self, backoff: ExponentialBuilder) -> Self {
203        self.backoff = backoff;
204        self
205    }
206
207    /// Determine if a given error is retry-able using the retryable field.
208    fn is_retryable_error(e: &AlienError<ErrorData>) -> bool {
209        e.retryable
210    }
211
212    /// Creates a default exponential back-off (max 3 attempts, up to 20s).
213    fn default_backoff() -> ExponentialBuilder {
214        ExponentialBuilder::default()
215            .with_max_times(3)
216            .with_max_delay(Duration::from_secs(20))
217            .with_jitter()
218    }
219
220    /// Execute the request, applying retries, and parse the body as JSON.
221    pub async fn send_json<T: DeserializeOwned + Send + 'static>(self) -> Result<T> {
222        let backoff = self.backoff;
223        let builder = self.inner;
224
225        let retryable = move || {
226            let attempt_builder = builder.try_clone();
227            async move {
228                let attempt_builder = attempt_builder.ok_or_else(|| {
229                    AlienError::new(ErrorData::GenericError {
230                        message: "Request retry preparation failed".into(),
231                    })
232                })?;
233
234                let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
235                let new_builder = reqwest::RequestBuilder::from_parts(client, request);
236
237                #[cfg(target_arch = "wasm32")]
238                {
239                    let resp = new_builder.send().await.into_alien_error().context(
240                        ErrorData::HttpRequestFailed {
241                            message: "Network error during HTTP request".to_string(),
242                        },
243                    )?;
244                    handle_json_response(resp, body_string).await
245                }
246                #[cfg(not(target_arch = "wasm32"))]
247                {
248                    let resp = new_builder.send().await.into_alien_error().context(
249                        ErrorData::HttpRequestFailed {
250                            message: "Network error during HTTP request".to_string(),
251                        },
252                    )?;
253                    handle_json_response(resp, body_string).await
254                }
255            }
256        };
257
258        retryable
259            .retry(backoff)
260            .when(Self::is_retryable_error)
261            .await
262    }
263
264    /// Execute the request, applying retries, and parse the body as XML.
265    pub async fn send_xml<T: DeserializeOwned + Send + 'static>(self) -> Result<T> {
266        let backoff = self.backoff;
267        let builder = self.inner;
268
269        let retryable = move || {
270            let attempt_builder = builder.try_clone();
271            async move {
272                let attempt_builder = attempt_builder.ok_or_else(|| {
273                    AlienError::new(ErrorData::GenericError {
274                        message: "Request retry preparation failed".into(),
275                    })
276                })?;
277
278                let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
279                let new_builder = reqwest::RequestBuilder::from_parts(client, request);
280
281                #[cfg(target_arch = "wasm32")]
282                {
283                    let resp = new_builder.send().await.into_alien_error().context(
284                        ErrorData::HttpRequestFailed {
285                            message: "Network error during HTTP request".to_string(),
286                        },
287                    )?;
288                    handle_xml_response(resp, body_string).await
289                }
290                #[cfg(not(target_arch = "wasm32"))]
291                {
292                    let resp = new_builder.send().await.into_alien_error().context(
293                        ErrorData::HttpRequestFailed {
294                            message: "Network error during HTTP request".to_string(),
295                        },
296                    )?;
297                    handle_xml_response(resp, body_string).await
298                }
299            }
300        };
301
302        retryable
303            .retry(backoff)
304            .when(Self::is_retryable_error)
305            .await
306    }
307
308    /// Execute the request, applying retries, without parsing the response body.
309    pub async fn send_no_response(self) -> Result<()> {
310        let backoff = self.backoff;
311        let builder = self.inner;
312
313        let retryable = move || {
314            let attempt_builder = builder.try_clone();
315            async move {
316                let attempt_builder = attempt_builder.ok_or_else(|| {
317                    AlienError::new(ErrorData::GenericError {
318                        message: "Request retry preparation failed".into(),
319                    })
320                })?;
321
322                let (client, request, body_string) = build_and_extract_body(attempt_builder)?;
323                let new_builder = reqwest::RequestBuilder::from_parts(client, request);
324
325                #[cfg(target_arch = "wasm32")]
326                {
327                    let resp = new_builder.send().await.into_alien_error().context(
328                        ErrorData::HttpRequestFailed {
329                            message: "Network error during HTTP request".to_string(),
330                        },
331                    )?;
332                    handle_no_response(resp, body_string).await
333                }
334                #[cfg(not(target_arch = "wasm32"))]
335                {
336                    let resp = new_builder.send().await.into_alien_error().context(
337                        ErrorData::HttpRequestFailed {
338                            message: "Network error during HTTP request".to_string(),
339                        },
340                    )?;
341                    handle_no_response(resp, body_string).await
342                }
343            }
344        };
345
346        retryable
347            .retry(backoff)
348            .when(Self::is_retryable_error)
349            .await
350    }
351
352    /// Execute the request, applying retries, and return the raw response
353    pub async fn send_raw(self) -> Result<reqwest::Response> {
354        let backoff = self.backoff;
355        let builder = self.inner;
356
357        let retryable = move || {
358            let attempt_builder = builder.try_clone();
359            async move {
360                let attempt_builder = attempt_builder.ok_or_else(|| {
361                    AlienError::new(ErrorData::GenericError {
362                        message: "Request retry preparation failed".into(),
363                    })
364                })?;
365
366                let (client, request, _body_string) = build_and_extract_body(attempt_builder)?;
367                let new_builder = reqwest::RequestBuilder::from_parts(client, request);
368
369                #[cfg(target_arch = "wasm32")]
370                {
371                    new_builder.send().await.into_alien_error().context(
372                        ErrorData::HttpRequestFailed {
373                            message: "Network error during HTTP request".to_string(),
374                        },
375                    )
376                }
377                #[cfg(not(target_arch = "wasm32"))]
378                {
379                    new_builder.send().await.into_alien_error().context(
380                        ErrorData::HttpRequestFailed {
381                            message: "Network error during HTTP request".to_string(),
382                        },
383                    )
384                }
385            }
386        };
387
388        retryable
389            .retry(backoff)
390            .when(Self::is_retryable_error)
391            .await
392    }
393}
394
395#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
396#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
397impl RequestBuilderExt for reqwest::RequestBuilder {
398    fn with_retry(self) -> RetriableRequestBuilder {
399        RetriableRequestBuilder {
400            inner: self,
401            backoff: RetriableRequestBuilder::default_backoff(),
402        }
403    }
404
405    async fn send_json<T: DeserializeOwned + 'static>(self) -> Result<T> {
406        let (client, request, body_string) = build_and_extract_body(self)?;
407        let builder = reqwest::RequestBuilder::from_parts(client, request);
408
409        #[cfg(target_arch = "wasm32")]
410        {
411            let resp =
412                builder
413                    .send()
414                    .await
415                    .into_alien_error()
416                    .context(ErrorData::HttpRequestFailed {
417                        message: "Network error during HTTP request".to_string(),
418                    })?;
419            handle_json_response(resp, body_string).await
420        }
421        #[cfg(not(target_arch = "wasm32"))]
422        {
423            let resp =
424                builder
425                    .send()
426                    .await
427                    .into_alien_error()
428                    .context(ErrorData::HttpRequestFailed {
429                        message: "Network error during HTTP request".to_string(),
430                    })?;
431            handle_json_response(resp, body_string).await
432        }
433    }
434
435    async fn send_xml<T: DeserializeOwned + 'static>(self) -> Result<T> {
436        let (client, request, body_string) = build_and_extract_body(self)?;
437        let builder = reqwest::RequestBuilder::from_parts(client, request);
438
439        #[cfg(target_arch = "wasm32")]
440        {
441            let resp =
442                builder
443                    .send()
444                    .await
445                    .into_alien_error()
446                    .context(ErrorData::HttpRequestFailed {
447                        message: "Network error during HTTP request".to_string(),
448                    })?;
449            handle_xml_response(resp, body_string).await
450        }
451        #[cfg(not(target_arch = "wasm32"))]
452        {
453            let resp =
454                builder
455                    .send()
456                    .await
457                    .into_alien_error()
458                    .context(ErrorData::HttpRequestFailed {
459                        message: "Network error during HTTP request".to_string(),
460                    })?;
461            handle_xml_response(resp, body_string).await
462        }
463    }
464
465    async fn send_no_response(self) -> Result<()> {
466        let (client, request, body_string) = build_and_extract_body(self)?;
467        let builder = reqwest::RequestBuilder::from_parts(client, request);
468
469        #[cfg(target_arch = "wasm32")]
470        {
471            let resp =
472                builder
473                    .send()
474                    .await
475                    .into_alien_error()
476                    .context(ErrorData::HttpRequestFailed {
477                        message: "Network error during HTTP request".to_string(),
478                    })?;
479            handle_no_response(resp, body_string).await
480        }
481        #[cfg(not(target_arch = "wasm32"))]
482        {
483            let resp =
484                builder
485                    .send()
486                    .await
487                    .into_alien_error()
488                    .context(ErrorData::HttpRequestFailed {
489                        message: "Network error during HTTP request".to_string(),
490                    })?;
491            handle_no_response(resp, body_string).await
492        }
493    }
494
495    async fn send_raw(self) -> Result<reqwest::Response> {
496        let (client, request, _body_string) = build_and_extract_body(self)?;
497        let builder = reqwest::RequestBuilder::from_parts(client, request);
498
499        #[cfg(target_arch = "wasm32")]
500        {
501            builder
502                .send()
503                .await
504                .into_alien_error()
505                .context(ErrorData::HttpRequestFailed {
506                    message: "Network error during HTTP request".to_string(),
507                })
508        }
509        #[cfg(not(target_arch = "wasm32"))]
510        {
511            builder
512                .send()
513                .await
514                .into_alien_error()
515                .context(ErrorData::HttpRequestFailed {
516                    message: "Network error during HTTP request".to_string(),
517                })
518        }
519    }
520}