Skip to main content

polyoxide_core/
hooks.rs

1//! The hooks a venue gives [`HttpClient::send`](crate::HttpClient::send).
2//!
3//! The loop is core's; a venue supplies three hooks and composes its state
4//! inside them:
5//!
6//! - a [`Throttle`], which charges each attempt before it is sent, sees every
7//!   response, and holds every request when the server says to;
8//! - a [`RetryPolicy`], which decides from a response whether the request is
9//!   done, retried or failed, and whether everyone is held;
10//! - an [`Authenticator`], which signs each attempt afresh.
11//!
12//! Each attempt runs: concurrency permit, [`Throttle::acquire`],
13//! [`Authenticator::sign`], send, [`Throttle::observe`], [`RetryPolicy::decide`],
14//! then [`Throttle::hold`] when the decision carries a hold.
15
16use std::future::Future;
17use std::time::Duration;
18
19use http::header::{HeaderMap, RETRY_AFTER};
20use http::{Method, StatusCode};
21use polyoxide_venue::{Class, Classify};
22
23use crate::error::ApiError;
24use crate::rate_limit::RetryConfig;
25
26/// A throttle layer, named by the venue that declares it.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct LayerId(pub &'static str);
29
30impl std::fmt::Display for LayerId {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.write_str(self.0)
33    }
34}
35
36/// What a request costs one layer of a throttle.
37///
38/// The venue's request builder computes these from its route table. A layer
39/// that counts requests finds its own bucket from the method and path, and
40/// needs no entry here.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Cost {
43    /// The layer charged.
44    pub layer: LayerId,
45    /// Tokens the request costs that layer.
46    pub units: u32,
47    /// Whether `units` is the true cost. Only an exact cost may be refused
48    /// before sending; an inexact one is a floor, and the server decides.
49    pub exact: bool,
50}
51
52/// What one layer charged for one attempt.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct LayerCharge {
55    /// The layer charged.
56    pub layer: LayerId,
57    /// Tokens taken.
58    pub units: u32,
59    /// The window the tokens were taken from, for a layer that counts by
60    /// window (such as a venue's UTC minute).
61    pub window: Option<u64>,
62}
63
64/// Everything a throttle charged for one attempt, handed back to it in
65/// [`Throttle::observe`]. The loop never reads it.
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct Charge {
68    layers: Vec<LayerCharge>,
69}
70
71impl Charge {
72    /// A charge of nothing.
73    pub fn none() -> Self {
74        Self::default()
75    }
76
77    /// This charge, plus one layer's.
78    pub fn with(mut self, charge: LayerCharge) -> Self {
79        self.layers.push(charge);
80        self
81    }
82
83    /// Each layer's charge, in the order they were taken.
84    pub fn layers(&self) -> &[LayerCharge] {
85        &self.layers
86    }
87}
88
89/// A cost a layer can never hold, refused before anything is sent.
90///
91/// Not a throttle: no wait can satisfy it, so the request is never retried.
92/// Splitting it is the only remedy.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
94#[error("the {layer} layer holds at most {capacity} units, and this request costs {units}")]
95pub struct Refused {
96    /// The layer that refused.
97    pub layer: LayerId,
98    /// What the request would cost it.
99    pub units: u32,
100    /// The most the layer ever holds.
101    pub capacity: u32,
102}
103
104/// [`Class::InvalidRequest`]: the client refused a cost no layer can ever
105/// hold, and nothing was sent.
106impl Classify for Refused {
107    fn class(&self) -> Class {
108        Class::InvalidRequest
109    }
110}
111
112/// The request an attempt is about to send, as a throttle sees it.
113#[derive(Debug, Clone, Copy)]
114pub struct RequestMeta<'a> {
115    /// The HTTP method.
116    pub method: &'a Method,
117    /// The path, without the query.
118    pub path: &'a str,
119    /// The query parameters.
120    pub query: &'a [(String, String)],
121    /// What the request costs each layer that does not count requests.
122    pub costs: &'a [Cost],
123}
124
125/// A response, as a throttle and a policy see it.
126#[derive(Debug, Clone, Copy)]
127pub struct ResponseMeta<'a> {
128    /// The status.
129    pub status: StatusCode,
130    /// The headers.
131    pub headers: &'a HeaderMap,
132}
133
134impl ResponseMeta<'_> {
135    /// The `Retry-After` header, when present and valid UTF-8.
136    pub fn retry_after(&self) -> Option<&str> {
137        self.headers.get(RETRY_AFTER)?.to_str().ok()
138    }
139}
140
141/// Which attempt a response answers.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct AttemptInfo {
144    /// The attempt, from 0.
145    pub attempt: u32,
146    /// Retries the loop still allows after this attempt.
147    pub retries_left: u32,
148}
149
150/// A request before it is sent. The loop clones it for each attempt and
151/// hands the clone to [`Authenticator::sign`].
152#[derive(Debug, Clone)]
153pub struct RequestParts {
154    /// The HTTP method.
155    pub method: Method,
156    /// The path, resolved against the client's base URL.
157    pub path: String,
158    /// The query parameters.
159    pub query: Vec<(String, String)>,
160    /// The headers.
161    pub headers: HeaderMap,
162    /// The body, sent as given: bytes, so it need not be text.
163    pub body: Option<bytes::Bytes>,
164    /// How long each attempt may take, in place of the client's timeout.
165    /// `None` keeps the client's.
166    pub timeout: Option<Duration>,
167}
168
169impl RequestParts {
170    /// A request with no query, headers, body or timeout of its own.
171    pub fn new(method: Method, path: impl Into<String>) -> Self {
172        Self {
173            method,
174            path: path.into(),
175            query: Vec::new(),
176            headers: HeaderMap::new(),
177            body: None,
178            timeout: None,
179        }
180    }
181}
182
183/// What the loop does with a response.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum Outcome {
186    /// Return the response.
187    Done,
188    /// Send again, after at least this long. The loop never sleeps less than
189    /// its own backoff floor, and never retries past its `max_retries`.
190    Retry(Duration),
191    /// Return the response, which the caller decodes as an error.
192    Fail,
193}
194
195/// A policy's answer to one response.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub struct Decision {
198    /// What the loop does next.
199    pub outcome: Outcome,
200    /// Hold every request on the throttle for this long, whatever the outcome.
201    pub hold: Option<Duration>,
202}
203
204/// Charges each attempt, sees each response, and holds every request on it.
205///
206/// One throttle is shared by every client that shares a budget, and its hold
207/// with it.
208#[dynosaur::dynosaur(pub DynThrottle = dyn(box) Throttle)]
209pub trait Throttle: Send + Sync {
210    /// Wait until the request may be sent, and charge it.
211    ///
212    /// Waits out any hold before charging, and again if the hold moved while
213    /// this call waited on a bucket of its own.
214    ///
215    /// # Errors
216    ///
217    /// [`Refused`] when a cost exceeds what a layer can ever hold. Nothing is
218    /// sent, and the request is not retried.
219    fn acquire(
220        &self,
221        meta: &RequestMeta<'_>,
222    ) -> impl Future<Output = Result<Charge, Refused>> + Send;
223
224    /// See a response to an attempt `acquire` charged. Runs on every
225    /// response, before the policy decides. Records counts and tiers only.
226    fn observe(&self, charge: &Charge, response: &ResponseMeta<'_>, attempt: &AttemptInfo);
227
228    /// Hold every request on this throttle for `delay`. A hold only ever
229    /// extends.
230    fn hold(&self, delay: Duration);
231}
232
233/// Decides from a response what the loop does next.
234#[dynosaur::dynosaur(pub DynRetryPolicy = dyn(box) RetryPolicy)]
235pub trait RetryPolicy: Send + Sync {
236    /// Decide what follows `response`. `schedule` is the loop's own
237    /// [`RetryConfig`], for a policy that sizes a hold from it.
238    fn decide(
239        &self,
240        response: &ResponseMeta<'_>,
241        attempt: &AttemptInfo,
242        schedule: &RetryConfig,
243    ) -> Decision;
244}
245
246/// Signs an attempt before it is sent.
247#[dynosaur::dynosaur(pub DynAuthenticator = dyn(box) Authenticator)]
248pub trait Authenticator: Send + Sync {
249    /// Sign `parts`, adding headers or query parameters. Runs on every
250    /// attempt, so a timestamp in the signature is fresh each time.
251    ///
252    /// # Errors
253    ///
254    /// The [`ApiError`] the signing failed with. The request is not sent.
255    fn sign(
256        &self,
257        parts: &mut RequestParts,
258        attempt: u32,
259    ) -> impl Future<Output = Result<(), ApiError>> + Send;
260}
261
262/// A throttle that charges nothing and holds nothing: a client built without
263/// one.
264#[derive(Debug, Clone, Copy, Default)]
265pub struct NoThrottle;
266
267impl Throttle for NoThrottle {
268    async fn acquire(&self, _meta: &RequestMeta<'_>) -> Result<Charge, Refused> {
269        Ok(Charge::none())
270    }
271
272    fn observe(&self, _charge: &Charge, _response: &ResponseMeta<'_>, _attempt: &AttemptInfo) {}
273
274    fn hold(&self, _delay: Duration) {}
275}
276
277/// Core's policy: a `429` is retried and holds every request, a 2xx is done,
278/// and every other status fails.
279///
280/// The hold is the schedule's first delay, [`RetryConfig::retry_delay`] at
281/// attempt 0, whatever attempt saw the `429`; the attempt's own wait is the
282/// loop's floor. A `429` with no retry left still holds: a 429 is a fact
283/// about the host, and the request out of attempts still has to publish it.
284#[derive(Debug, Clone, Copy, Default)]
285pub struct DefaultRetryPolicy;
286
287impl RetryPolicy for DefaultRetryPolicy {
288    fn decide(
289        &self,
290        response: &ResponseMeta<'_>,
291        _attempt: &AttemptInfo,
292        schedule: &RetryConfig,
293    ) -> Decision {
294        if response.status == StatusCode::TOO_MANY_REQUESTS {
295            Decision {
296                outcome: Outcome::Retry(Duration::ZERO),
297                hold: Some(schedule.retry_delay(0, response.retry_after())),
298            }
299        } else if response.status.is_success() {
300            Decision {
301                outcome: Outcome::Done,
302                hold: None,
303            }
304        } else {
305            Decision {
306                outcome: Outcome::Fail,
307                hold: None,
308            }
309        }
310    }
311}