Skip to main content

polyoxide_core/
send.rs

1//! [`HttpClient::send`]: core's one send loop.
2
3use reqwest::Response;
4use serde::de::DeserializeOwned;
5use url::Url;
6
7use crate::client::HttpClient;
8use crate::error::{ApiError, ErrorResponse};
9use crate::hooks::{
10    Authenticator, Cost, DynAuthenticator, Outcome, RequestMeta, RequestParts, ResponseMeta,
11    RetryPolicy, Throttle,
12};
13
14impl HttpClient {
15    /// Send `parts`, retrying as the client's policy decides.
16    ///
17    /// Each attempt takes a concurrency permit, waits while the throttle
18    /// charges it ([`Throttle::acquire`], with `costs`), is signed by `auth`
19    /// when one is given, and is sent, within `parts.timeout` when it is set.
20    /// The throttle then sees the response
21    /// ([`Throttle::observe`]), the policy decides what follows
22    /// ([`RetryPolicy::decide`]), and a hold the decision carries is applied
23    /// ([`Throttle::hold`]) whether or not the request is retried.
24    ///
25    /// A retry releases the permit and sleeps the longer of the policy's wait
26    /// and the loop's own floor, [`RetryConfig::retry_delay`] for the attempt.
27    /// The floor belongs to the loop, so no policy can retry sooner. The loop
28    /// never retries past [`RetryConfig::max_retries`], whatever the policy
29    /// says.
30    ///
31    /// Each retry logs a `WARN` under the `polyoxide_core` target,
32    /// `Retriable status <code> on <path>, retry <n> after <ms>ms`, where
33    /// `<ms>` is the longer of the retry's sleep and the hold its decision
34    /// set, since the retry waits out both. A hold that is not a retry logs
35    /// one too: `Status <code> on <path>, not retried: every request held
36    /// <ms>ms`.
37    ///
38    /// Returns the response the policy is done with, whatever its status; one
39    /// it fails, or would retry with no retry left, is an error.
40    ///
41    /// # Errors
42    ///
43    /// - [`ApiError::Response`] when the policy fails the last response.
44    /// - [`ApiError::Url`] when `parts.path` does not join the base URL.
45    /// - [`ApiError::Refused`] when the throttle refuses a cost, or the
46    ///   signing error when `auth` fails. Nothing is sent.
47    /// - [`ApiError::Network`] when no response arrives. That attempt is
48    ///   neither observed nor retried.
49    ///
50    /// [`RetryConfig::retry_delay`]: crate::RetryConfig::retry_delay
51    /// [`RetryConfig::max_retries`]: crate::RetryConfig::max_retries
52    pub async fn send(
53        &self,
54        parts: RequestParts,
55        costs: &[Cost],
56        auth: Option<&DynAuthenticator<'_>>,
57    ) -> Result<Response, ApiError> {
58        let url = self.base_url.join(&parts.path)?;
59        let mut attempt = 0u32;
60
61        loop {
62            let permit = self.acquire_concurrency().await;
63            let request = RequestMeta {
64                method: &parts.method,
65                path: &parts.path,
66                query: &parts.query,
67                costs,
68            };
69            let charge = self.throttle.acquire(&request).await?;
70
71            // Signed afresh on every attempt, so a timestamp in it is current.
72            let mut signed = parts.clone();
73            if let Some(auth) = auth {
74                auth.sign(&mut signed, attempt).await?;
75            }
76            let response = self.request(&url, signed).send().await?;
77
78            let status = response.status();
79            let info = self.retry_config.attempt_info(attempt);
80            let meta = ResponseMeta {
81                status,
82                headers: response.headers(),
83            };
84            self.throttle.observe(&charge, &meta, &info);
85            let decision = self.policy.decide(&meta, &info, &self.retry_config);
86
87            // Before the retry decision, and whatever it is: a hold is a fact
88            // about the host, and a request with no retry left still has to
89            // publish it to every request sharing this throttle.
90            if let Some(hold) = decision.hold {
91                self.throttle.hold(hold);
92            }
93
94            match decision.outcome {
95                Outcome::Retry(wait) if info.retries_left > 0 => {
96                    let floor = self.retry_config.retry_delay(attempt, meta.retry_after());
97                    let sleep = floor.max(wait);
98                    attempt += 1;
99                    // The retry also waits out the hold this decision set, so
100                    // the logged wait is the longer of the two.
101                    tracing::warn!(
102                        "Retriable status {} on {}, retry {} after {}ms",
103                        status,
104                        parts.path,
105                        attempt,
106                        decision
107                            .hold
108                            .map_or(sleep, |hold| hold.max(sleep))
109                            .as_millis()
110                    );
111                    drop(permit);
112                    tokio::time::sleep(sleep).await;
113                }
114                Outcome::Done | Outcome::Fail | Outcome::Retry(_) => {
115                    // A hold outlives this request, so it is logged even when
116                    // nothing is retried (DRIFT R10).
117                    if let Some(hold) = decision.hold {
118                        tracing::warn!(
119                            "Status {} on {}, not retried: every request held {}ms",
120                            status,
121                            parts.path,
122                            hold.as_millis()
123                        );
124                    }
125                    // `Done` hands the response back whatever its status. A
126                    // `Fail`, or a retry with none left, is the error AD-8
127                    // names: the status, headers, body and `Retry-After`.
128                    return match decision.outcome {
129                        Outcome::Done => Ok(response),
130                        Outcome::Fail | Outcome::Retry(_) => {
131                            // Read outside the permit, as a caller reads a
132                            // 2xx body after the loop drops it, so a slow
133                            // error body holds no slot.
134                            drop(permit);
135                            Err(ErrorResponse::read(response).await.into())
136                        }
137                    };
138                }
139            }
140        }
141    }
142
143    /// One attempt's request, built from its signed parts.
144    fn request(&self, url: &Url, parts: RequestParts) -> reqwest::RequestBuilder {
145        let mut request = self
146            .client
147            .request(parts.method, url.clone())
148            .headers(parts.headers);
149        if !parts.query.is_empty() {
150            request = request.query(&parts.query);
151        }
152        if let Some(body) = parts.body {
153            request = request.body(body);
154        }
155        if let Some(timeout) = parts.timeout {
156            request = request.timeout(timeout);
157        }
158        request
159    }
160}
161
162/// Decode a response body as JSON, logging a failure once.
163///
164/// A failure logs one ERROR line under `polyoxide_core`,
165/// `Failed to decode {path}: {err}: {body}`, with the body truncated for the
166/// log, and returns the error for the caller to map into its own type.
167pub fn decode_json<T: DeserializeOwned>(path: &str, text: &str) -> Result<T, serde_json::Error> {
168    serde_json::from_str(text).map_err(|err| {
169        tracing::error!(
170            "Failed to decode {path}: {err}: {}",
171            crate::truncate_for_log(text)
172        );
173        err
174    })
175}