Skip to main content

polyoxide_binance/usdm/
request.rs

1//! The send loop every route goes through.
2
3use std::marker::PhantomData;
4
5use polyoxide_core::{retry_after_header, truncate_for_log, ApiError, HttpClient};
6use reqwest::{Response, StatusCode};
7use serde::de::DeserializeOwned;
8
9use crate::{
10    error::{retry_after_secs, BinanceError},
11    weight::{Cost, Route, WeightBudget, DEFAULT_BAN},
12};
13
14/// The header carrying the IP's weight used in the current minute.
15pub const USED_WEIGHT_HEADER: &str = "x-mbx-used-weight-1m";
16
17/// A REST request that knows its weight. Call [`send`](Self::send).
18#[must_use = "a request does nothing until it is sent"]
19pub struct WeightedRequest<T> {
20    http: HttpClient,
21    budget: WeightBudget,
22    route: Route,
23    query: Vec<(&'static str, String)>,
24    _marker: PhantomData<fn() -> T>,
25}
26
27impl<T> WeightedRequest<T> {
28    pub(crate) fn new(http: &HttpClient, budget: &WeightBudget, route: Route) -> Self {
29        Self {
30            http: http.clone(),
31            budget: budget.clone(),
32            route,
33            query: Vec::new(),
34            _marker: PhantomData,
35        }
36    }
37
38    /// Sets a query parameter, replacing an earlier value for the same key.
39    pub(crate) fn query(mut self, key: &'static str, value: impl ToString) -> Self {
40        let value = value.to_string();
41        match self.query.iter_mut().find(|(k, _)| *k == key) {
42            Some(slot) => slot.1 = value,
43            None => self.query.push((key, value)),
44        }
45        self
46    }
47
48    /// Replaces the route, for a parameter that changes the weight.
49    pub(crate) fn route(mut self, route: Route) -> Self {
50        self.route = route;
51        self
52    }
53
54    /// What sending this request will cost.
55    pub fn cost(&self) -> Cost {
56        self.route.cost()
57    }
58}
59
60impl<T: DeserializeOwned> WeightedRequest<T> {
61    /// Waits for the budget, sends, and decodes the response.
62    ///
63    /// A `429` starts a cooldown every request on the budget waits out, then
64    /// this request is retried on core's schedule; with no retry left and no
65    /// `Retry-After`, the cooldown runs to the next UTC minute. A `418` starts a
66    /// cooldown for its `Retry-After`, or 2 minutes, and is not retried.
67    ///
68    /// The wait has no deadline of its own: a request can wait out a minute's
69    /// spent budget or a ban, which Binance documents at up to 3 days. Wrap the
70    /// call in `tokio::time::timeout` where a caller needs a deadline.
71    pub async fn send(self) -> Result<T, BinanceError> {
72        let text = self.send_text().await?;
73        serde_json::from_str(&text).map_err(|err| {
74            tracing::error!(
75                "Failed to decode {}: {err}: {}",
76                self.route.path(),
77                truncate_for_log(&text)
78            );
79            BinanceError::from(ApiError::from(err))
80        })
81    }
82
83    async fn send_text(&self) -> Result<String, BinanceError> {
84        let path = self.route.path();
85        let url = self.http.base_url.join(path)?;
86        let cost = self.route.cost();
87        let mut attempt = 0u32;
88
89        loop {
90            // The permit first, then the budget: the charge then stays next to
91            // its send, which keeps the weight in flight under the budget's
92            // reserve. Charging first would let charged requests queue for a
93            // permit across a minute boundary, where their headers are dropped.
94            let permit = self.http.acquire_concurrency().await;
95            let charge = self.budget.acquire(cost).await;
96
97            let mut request = self.http.client.get(url.clone());
98            if !self.query.is_empty() {
99                request = request.query(&self.query);
100            }
101            let response = request.send().await?;
102            let status = response.status();
103            let retry_after = retry_after_header(&response);
104
105            // Before anything else, and whatever the status: a refused request
106            // still spent weight, and the server's count is the truth.
107            if let Some(used) = used_weight(&response) {
108                self.budget.record_used(charge, used);
109            }
110
111            if status == StatusCode::IM_A_TEAPOT {
112                let ban = retry_after_secs(retry_after.as_deref()).unwrap_or(DEFAULT_BAN);
113                self.budget.begin_cooldown(ban);
114                // The body is Binance's -1003 text, which names when the ban
115                // ends; the error's fields have no room for it, so it is logged.
116                let body = response.text().await.unwrap_or_default();
117                tracing::warn!(
118                    "418 on {path}: IP banned, every request held {ban:?}: {}",
119                    truncate_for_log(&body)
120                );
121                return Err(BinanceError::from_response_parts(
122                    status.as_u16(),
123                    retry_after.as_deref(),
124                    &body,
125                ));
126            }
127            if status == StatusCode::TOO_MANY_REQUESTS {
128                // Retry-After only ever extends the wait, as in core. With no
129                // retry left and no Retry-After, every request still holds until
130                // the weight window resets: sending again into a spent minute is
131                // how a 429 becomes a 418 ban.
132                let retry = self
133                    .http
134                    .should_retry(status, attempt, retry_after.as_deref());
135                let asked = retry_after_secs(retry_after.as_deref());
136                let cooldown = match (retry, asked) {
137                    (None, None) => self.budget.hold_until_next_minute(),
138                    _ => {
139                        let cooldown = retry.unwrap_or_default().max(asked.unwrap_or_default());
140                        self.budget.begin_cooldown(cooldown);
141                        cooldown
142                    }
143                };
144                if retry.is_some() {
145                    attempt += 1;
146                    tracing::warn!("429 on {path}, retry {attempt} after {cooldown:?}");
147                    drop(permit);
148                    continue;
149                }
150                tracing::warn!("429 on {path}, no retry left: every request held {cooldown:?}");
151            }
152
153            if !status.is_success() {
154                let body = response.text().await.unwrap_or_default();
155                return Err(BinanceError::from_response_parts(
156                    status.as_u16(),
157                    retry_after.as_deref(),
158                    &body,
159                ));
160            }
161            return Ok(response.text().await?);
162        }
163    }
164}
165
166fn used_weight(response: &Response) -> Option<u32> {
167    response
168        .headers()
169        .get(USED_WEIGHT_HEADER)?
170        .to_str()
171        .ok()?
172        .trim()
173        .parse()
174        .ok()
175}