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}