Skip to main content

hey_sdk/
client.rs

1use std::fmt::Display;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::time::{Duration, Instant};
5
6use bytes::Bytes;
7use serde::de::DeserializeOwned;
8use tokio::sync::Mutex;
9use url::Url;
10
11use crate::auth::{AuthStrategy, BearerAuth, TokenProvider};
12use crate::cache::{CachedResponse, FileCache, ResponseCache, cache_key};
13use crate::config::Config;
14use crate::error::{Error, ErrorCode, retry_after_seconds};
15use crate::http::header::{
16    ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, IF_NONE_MATCH,
17    PROXY_AUTHORIZATION, USER_AGENT,
18};
19use crate::http::{
20    Body, HeaderMap, HeaderValue, HttpClient, Method, Request, Response as HttpResponse, StatusCode,
21};
22use crate::observability::{
23    Hooks, NoopHooks, OperationInfo, OperationState, RequestInfo, RequestResult,
24};
25use crate::operation::Operation;
26use crate::pagination::Page;
27use crate::route::Route;
28use crate::security::{is_same_origin, require_secure_endpoint};
29use crate::services::boxes::BoxKinds;
30#[cfg(feature = "tracing")]
31use crate::trace::label;
32use crate::trace::{AttemptSpan, OperationSpan};
33use crate::version::default_user_agent;
34
35/// How long the HTTP client the SDK ships gives an answer to arrive.
36pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
37/// How many times an idempotent operation is resent after a transient failure.
38pub const DEFAULT_MAX_RETRIES: u32 = 3;
39/// The wait before the first resend; each one after doubles it.
40pub const DEFAULT_BASE_DELAY: Duration = Duration::from_secs(1);
41/// The longest the client waits between attempts, however many it has made.
42///
43/// This is a deliberate divergence from Go, whose backoff doubles without a ceiling: by the
44/// fourth attempt there it is already eight seconds, and a caller who raised
45/// [`ClientBuilder::max_retries`] would be waiting minutes on a scope that is never coming
46/// back. The circuit breaker is what should give up on that scope; the backoff's job is to
47/// stop hammering, and thirty seconds does it. Move it with [`ClientBuilder::max_delay`].
48pub const DEFAULT_MAX_DELAY: Duration = Duration::from_secs(30);
49/// The most added at random to each wait, so resends from many clients do not land
50/// together.
51pub const DEFAULT_MAX_JITTER: Duration = Duration::from_millis(100);
52/// How many pages a walk reads before it stops.
53pub const DEFAULT_MAX_PAGES: usize = 10_000;
54/// The most a JSON or HTML answer may deliver before the client refuses to hold it.
55pub const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 16 << 20;
56
57/// The most the client buffers of an answer the configurable cap leaves alone: a blob, an
58/// export, whatever a form request answered. Only [`Client::download_blob`], which writes
59/// to the caller's destination as the bytes arrive, reads without a bound.
60pub const MAX_RESPONSE_BODY_BYTES: usize = 50 << 20;
61
62/// The statuses a request the model says nothing about — a path the caller wrote — is
63/// resent on. A modelled route is resent on the statuses its own policy names.
64const RETRYABLE_STATUSES: &[u16] = &[429, 500, 502, 503, 504];
65const ACCOUNT_FILTER_PARAMETER: &str = "filtered_account_id";
66/// How many redirects one request may go through before the client gives up on it, which
67/// is what reqwest allowed when it was the one following them.
68const MAX_REDIRECTS: usize = 10;
69
70tokio::task_local! {
71    /// The deadline the operation in progress on this task is held to, so that every
72    /// request a convenience or a walk makes inside it is held to the same one rather
73    /// than each starting a limit of its own.
74    static DEADLINE: Option<Instant>;
75}
76
77/// A HEY client: one authenticated identity, presenting mail from All Accounts unless
78/// derived for one linked account with [`Client::for_account`].
79///
80/// Clients are cheap to clone and share their connection pool, credentials and cache.
81#[derive(Clone)]
82pub struct Client {
83    pub(crate) shared: Arc<Shared>,
84    pub(crate) account_id: Option<i64>,
85    pub(crate) scope: Arc<ScopeState>,
86}
87
88pub(crate) struct Shared {
89    pub(crate) config: Config,
90    pub(crate) base_url: Url,
91    pub(crate) http: Arc<dyn HttpClient>,
92    pub(crate) auth: Arc<dyn AuthStrategy>,
93    pub(crate) user_agent: String,
94    pub(crate) max_retries: u32,
95    pub(crate) base_delay: Option<Duration>,
96    pub(crate) max_delay: Duration,
97    pub(crate) max_jitter: Duration,
98    pub(crate) max_pages: usize,
99    pub(crate) max_response_body_bytes: usize,
100    pub(crate) cache: Option<Arc<dyn ResponseCache>>,
101    pub(crate) hooks: Arc<dyn Hooks>,
102    pub(crate) operation_timeout: Option<Duration>,
103    /// How many times the credentials have been refreshed. A request remembers the count it
104    /// was signed under, so a 401 answered after someone else refreshed is resent on the
105    /// new credentials rather than refreshing again.
106    pub(crate) refreshes: AtomicU64,
107    /// One refresh at a time, and none while a request is being signed: the 401s a stale
108    /// credential earns all arrive together, and only the first of them should cost a
109    /// round trip to the token endpoint. Signing takes this for reading, so requests sign
110    /// concurrently; a refresh takes it for writing, so the count a request is signed
111    /// under is the count of the credentials it carries.
112    pub(crate) refreshing: tokio::sync::RwLock<()>,
113}
114
115/// What a client works out about the identity it presents and keeps for as long as it
116/// lives. A client derived with [`Client::for_account`] starts an empty one of its own,
117/// since none of it means the same thing under another account.
118#[derive(Default)]
119pub(crate) struct ScopeState {
120    pub(crate) default_sender_id: Mutex<Option<i64>>,
121    pub(crate) account_user_id: Mutex<Option<i64>>,
122    pub(crate) box_kinds: Mutex<Option<BoxKinds>>,
123}
124
125/// What came back from HEY, before it is decoded.
126#[derive(Debug, Clone)]
127#[non_exhaustive]
128pub struct Response {
129    /// What HEY answered.
130    pub status: StatusCode,
131    /// The headers that came with it.
132    pub headers: HeaderMap,
133    /// The body, read whole.
134    pub body: Bytes,
135    /// Where the answer came from, once any redirects were followed.
136    pub url: Url,
137    /// The body came out of the response cache: HEY answered 304 and the SDK read the
138    /// entry it was holding.
139    pub from_cache: bool,
140    /// The operation takes this status for an answer rather than a failure: a 404 that
141    /// means "nothing there", or the redirect a form request went out to collect.
142    pub empty: bool,
143}
144
145impl Response {
146    /// Decodes the body as JSON. A body that will not decode is an error that still says
147    /// what HEY answered: the status, and the request id when the answer named one.
148    pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
149        if self.body.is_empty() {
150            let error = Error::api(self.status.as_u16(), "empty response body");
151            Err(match self.header("x-request-id") {
152                Some(request_id) => error.with_request_id(request_id),
153                None => error,
154            })
155        } else {
156            serde_json::from_slice(&self.body).map_err(|error| {
157                Error::decoding(self.status.as_u16(), self.header("x-request-id"), error)
158            })
159        }
160    }
161
162    /// One header's value, when HEY sent it and it is text.
163    pub fn header(&self, name: &str) -> Option<&str> {
164        self.headers.get(name).and_then(|value| value.to_str().ok())
165    }
166}
167
168/// How a [`Client`] is put together: credentials, the HTTP client, the retry budget, the
169/// cache and the hooks, each with a default a caller can move.
170pub struct ClientBuilder {
171    config: Config,
172    auth: Option<Arc<dyn AuthStrategy>>,
173    http: Option<Arc<dyn HttpClient>>,
174    user_agent: String,
175    timeout: Duration,
176    max_retries: u32,
177    base_delay: Option<Duration>,
178    max_delay: Duration,
179    max_jitter: Duration,
180    max_pages: usize,
181    max_response_body_bytes: usize,
182    cache: Option<Arc<dyn ResponseCache>>,
183    pub(crate) hooks: Arc<dyn Hooks>,
184    operation_timeout: Option<Duration>,
185}
186
187impl ClientBuilder {
188    /// A builder for `config`, at the defaults and without credentials.
189    pub fn new(config: Config) -> ClientBuilder {
190        ClientBuilder {
191            config,
192            auth: None,
193            http: None,
194            user_agent: default_user_agent(),
195            timeout: DEFAULT_TIMEOUT,
196            max_retries: DEFAULT_MAX_RETRIES,
197            base_delay: None,
198            max_delay: DEFAULT_MAX_DELAY,
199            max_jitter: DEFAULT_MAX_JITTER,
200            max_pages: DEFAULT_MAX_PAGES,
201            max_response_body_bytes: DEFAULT_MAX_RESPONSE_BODY_BYTES,
202            cache: None,
203            hooks: Arc::new(NoopHooks),
204            operation_timeout: None,
205        }
206    }
207
208    /// Authenticates with a bearer token drawn from `provider` for each request.
209    #[must_use]
210    pub fn token_provider(self, provider: impl TokenProvider + 'static) -> ClientBuilder {
211        self.auth_strategy(BearerAuth::new(provider))
212    }
213
214    /// Authenticates however `strategy` does: the way in for anything but a bearer token.
215    #[must_use]
216    pub fn auth_strategy(mut self, strategy: impl AuthStrategy + 'static) -> ClientBuilder {
217        self.auth = Some(Arc::new(strategy));
218        self
219    }
220
221    /// Replaces the HTTP client every request goes out on, including the attachment bytes
222    /// that go to the storage service. The one supplied must not follow redirects; see
223    /// [`HttpClient`]. The timeout set on the builder is then ignored — a timeout belongs to
224    /// the client that can enforce it.
225    #[must_use]
226    pub fn http_client(mut self, http: impl HttpClient + 'static) -> ClientBuilder {
227        self.http = Some(Arc::new(http));
228        self
229    }
230
231    /// What the client calls itself in `User-Agent`.
232    #[must_use]
233    pub fn user_agent(mut self, user_agent: impl Into<String>) -> ClientBuilder {
234        self.user_agent = user_agent.into();
235        self
236    }
237
238    /// How long the HTTP client the SDK ships gives an answer to arrive. It has no effect on
239    /// one supplied with [`ClientBuilder::http_client`]. This bounds one request on the
240    /// wire; the whole of an operation is bounded by [`ClientBuilder::operation_timeout`].
241    #[must_use]
242    pub fn timeout(mut self, timeout: Duration) -> ClientBuilder {
243        self.timeout = timeout;
244        self
245    }
246
247    /// The most an operation may take from the call to its answer, everything the client
248    /// waits for included: waiting at the gate, fetching credentials, every attempt, every
249    /// wait between them, the resend after a refresh, and reading the body. Past it the
250    /// operation ends as a retryable network error, and whatever it was doing is dropped —
251    /// a permit it held goes back, and the hooks hear it end. Decoding the answer into the
252    /// caller's type comes after, on the caller's own thread, and is not waited for. None
253    /// by default: an operation may then take as long as its attempts and waits add up to,
254    /// each attempt bounded only by the HTTP client's own [`ClientBuilder::timeout`].
255    #[must_use]
256    pub fn operation_timeout(mut self, limit: Duration) -> ClientBuilder {
257        self.operation_timeout = Some(limit);
258        self
259    }
260
261    /// The most times any operation is resent after a transient failure. A modelled
262    /// route is resent as many times as its own policy allows and no more; this only
263    /// lowers that. A path the caller wrote, which no policy covers, is resent this many
264    /// times when its method is idempotent.
265    #[must_use]
266    pub fn max_retries(mut self, max_retries: u32) -> ClientBuilder {
267        self.max_retries = max_retries;
268        self
269    }
270
271    /// The least the client waits before the first resend. A modelled route starts from
272    /// the delay its own policy names when that is longer; a path the caller wrote starts
273    /// from this, or from [`DEFAULT_BASE_DELAY`] when it is not set. Each wait after the
274    /// first is double the one before. [`ClientBuilder::max_delay`] holds every wait down,
275    /// this one included.
276    #[must_use]
277    pub fn base_delay(mut self, base_delay: Duration) -> ClientBuilder {
278        self.base_delay = Some(base_delay);
279        self
280    }
281
282    /// The most the client waits between attempts, jitter included, whatever the policy,
283    /// the backoff or [`ClientBuilder::base_delay`] asks for. The wait a `Retry-After`
284    /// names is honoured as given.
285    #[must_use]
286    pub fn max_delay(mut self, max_delay: Duration) -> ClientBuilder {
287        self.max_delay = max_delay;
288        self
289    }
290
291    /// The most added at random to each wait, so resends from many clients do not land
292    /// together.
293    #[must_use]
294    pub fn max_jitter(mut self, max_jitter: Duration) -> ClientBuilder {
295        self.max_jitter = max_jitter;
296        self
297    }
298
299    /// How many pages [`Client::each_page`] reads before it stops. Zero is refused by
300    /// [`ClientBuilder::build`].
301    #[must_use]
302    pub fn max_pages(mut self, max_pages: usize) -> ClientBuilder {
303        self.max_pages = max_pages;
304        self
305    }
306
307    /// The most a JSON or HTML answer may deliver before the client refuses to hold it.
308    /// Zero asks for the default: the cap cannot be lifted, only moved.
309    #[must_use]
310    pub fn max_response_body_bytes(mut self, bytes: usize) -> ClientBuilder {
311        self.max_response_body_bytes = bytes;
312        self
313    }
314
315    /// Caches JSON reads by `ETag`. Without this, `config.cache_enabled` decides whether a
316    /// [`FileCache`] in `config.cache_dir` is used.
317    #[must_use]
318    pub fn cache(mut self, cache: impl ResponseCache + 'static) -> ClientBuilder {
319        self.cache = Some(Arc::new(cache));
320        self
321    }
322
323    /// Reports every operation and every request the client makes. Several sets of hooks
324    /// go on as one with [`crate::observability::ChainHooks`].
325    #[must_use]
326    pub fn hooks(mut self, hooks: impl Hooks + 'static) -> ClientBuilder {
327        self.hooks = Arc::new(hooks);
328        self
329    }
330
331    /// The client, or a usage error for a builder without credentials, with no timeout, or
332    /// with no pages to read.
333    pub fn build(self) -> Result<Client, Error> {
334        let base_url = parse_base_url(&self.config.base_url)?;
335        let auth = self
336            .auth
337            .ok_or_else(|| Error::usage("a token provider or auth strategy is required"))?;
338        if self.timeout.is_zero() {
339            return Err(Error::usage("timeout must be greater than zero"));
340        }
341        if self.max_pages == 0 {
342            return Err(Error::usage("max pages must be greater than zero"));
343        }
344        if self.operation_timeout.is_some_and(|limit| limit.is_zero()) {
345            return Err(Error::usage("operation timeout must be greater than zero"));
346        }
347        if self
348            .operation_timeout
349            .is_some_and(|limit| Instant::now().checked_add(limit).is_none())
350        {
351            return Err(Error::usage(
352                "operation timeout is too long to keep time by",
353            ));
354        }
355        let http = match self.http {
356            Some(http) => http,
357            None => shipped_http_client(self.timeout)?,
358        };
359        let cache =
360            match (self.cache, self.config.cache_enabled) {
361                (Some(cache), _) => Some(cache),
362                (None, true) => Some(Arc::new(FileCache::new(self.config.cache_dir.clone()))
363                    as Arc<dyn ResponseCache>),
364                (None, false) => None,
365            };
366        let max_response_body_bytes = match self.max_response_body_bytes {
367            0 => DEFAULT_MAX_RESPONSE_BODY_BYTES,
368            bytes => bytes,
369        };
370        let shared = Shared {
371            config: self.config,
372            base_url,
373            http,
374            auth,
375            user_agent: self.user_agent,
376            max_retries: self.max_retries,
377            base_delay: self.base_delay,
378            max_delay: self.max_delay,
379            max_jitter: self.max_jitter,
380            max_pages: self.max_pages,
381            max_response_body_bytes,
382            cache,
383            hooks: self.hooks,
384            operation_timeout: self.operation_timeout,
385            refreshes: AtomicU64::new(0),
386            refreshing: tokio::sync::RwLock::new(()),
387        };
388        Ok(Client {
389            shared: Arc::new(shared),
390            account_id: None,
391            scope: Arc::default(),
392        })
393    }
394}
395
396impl Client {
397    /// A [`ClientBuilder`] for `config`, at the defaults.
398    pub fn builder(config: Config) -> ClientBuilder {
399        ClientBuilder::new(config)
400    }
401
402    /// A client with the default settings and a bearer token, on the HTTP client the SDK
403    /// ships. Without the `reqwest` feature there is no such client, and a
404    /// [`ClientBuilder`] with an [`HttpClient`] of the application's own is the way in.
405    #[cfg(feature = "reqwest")]
406    #[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
407    pub fn new(config: Config, provider: impl TokenProvider + 'static) -> Result<Client, Error> {
408        Client::builder(config).token_provider(provider).build()
409    }
410
411    /// The configuration this client was built from.
412    pub fn config(&self) -> &Config {
413        &self.shared.config
414    }
415
416    /// Where HEY is, with the trailing slash every path is joined to.
417    pub fn base_url(&self) -> &Url {
418        &self.shared.base_url
419    }
420
421    /// The linked account this client presents, or `None` for All Accounts.
422    pub fn account_id(&self) -> Option<i64> {
423        self.account_id
424    }
425
426    /// How many pages a walk reads before it stops.
427    pub fn max_pages(&self) -> usize {
428        self.shared.max_pages
429    }
430
431    /// The HTTP client every request goes out on, for the one request the SDK makes outside
432    /// HEY: the attachment blob that goes to the storage service the direct upload named. It
433    /// shares the connection pool and the settings the caller configured, and carries no
434    /// credentials of its own — those go on per request.
435    pub(crate) fn http(&self) -> &dyn HttpClient {
436        self.shared.http.as_ref()
437    }
438
439    /// Starts a request for one of the modelled routes. Generated service methods call
440    /// this; reach for it directly only to add headers or query parameters they do not
441    /// expose.
442    pub fn operation(&self, route: &'static Route, params: &[&dyn Display]) -> Operation {
443        Operation::for_route(route, params)
444    }
445
446    /// Starts a request for a path the model does not cover. The path is relative to the
447    /// base URL and gets the same credentials, `.json` suffix, account scope and retry
448    /// treatment as a modelled one.
449    pub fn request(&self, method: Method, path: impl Into<String>) -> Operation {
450        Operation::raw(method, path.into())
451    }
452
453    /// Sends an operation and decodes its JSON body.
454    pub async fn send<T: DeserializeOwned>(&self, operation: Operation) -> Result<T, Error> {
455        let label = operation.label().to_string();
456        self.execute(operation)
457            .await?
458            .json()
459            .map_err(|error| error.about(&label))
460    }
461
462    /// Sends an operation whose answer carries no body worth reading.
463    pub async fn send_unit(&self, operation: Operation) -> Result<(), Error> {
464        self.execute(operation).await.map(|_| ())
465    }
466
467    /// Sends an operation and reads its body as text: the HTML page a route serves no
468    /// JSON for.
469    pub async fn send_text(&self, operation: Operation) -> Result<String, Error> {
470        let response = self.execute(operation).await?;
471        Ok(String::from_utf8_lossy(&response.body).into_owned())
472    }
473
474    /// Sends an operation that answers a status meaning "nothing there" with `None`.
475    pub async fn send_optional<T: DeserializeOwned>(
476        &self,
477        operation: Operation,
478    ) -> Result<Option<T>, Error> {
479        let label = operation.label().to_string();
480        let response = self.execute(operation).await?;
481        if response.empty {
482            Ok(None)
483        } else {
484            response
485                .json()
486                .map(Some)
487                .map_err(|error| error.about(&label))
488        }
489    }
490
491    /// Sends a paginated read and keeps the cursor HEY answered with.
492    pub async fn send_page<T: DeserializeOwned>(
493        &self,
494        operation: Operation,
495    ) -> Result<Page<T>, Error> {
496        let label = operation.label().to_string();
497        let info = operation.info.clone();
498        let route = operation.route;
499        let response = self.execute(operation).await?;
500        let value = response.json().map_err(|error| error.about(&label))?;
501        Ok(Page::new(value, &response, info, route))
502    }
503
504    /// Reads the page after the given one, or `None` when HEY named no next page. A
505    /// `Link` header pointing off the HEY origin is refused rather than followed. The read
506    /// announces itself as the operation the first page came from, so a whole walk shows
507    /// up as one thing rather than as a list read followed by anonymous requests, and it
508    /// is resent under that operation's retry policy.
509    pub async fn next_page<T: DeserializeOwned>(
510        &self,
511        page: &Page<T>,
512    ) -> Result<Option<Page<T>>, Error> {
513        match page.next_url() {
514            None => Ok(None),
515            Some(next) if !is_same_origin(next, &self.shared.base_url) => Err(Error::usage(
516                format!("pagination Link header points to a different origin: {next}"),
517            )),
518            Some(next) => {
519                let mut operation = Operation::at(Method::GET, next.clone());
520                operation.info(page.info().clone());
521                operation.route = page.route();
522                self.send_page(operation).await.map(Some)
523            }
524        }
525    }
526
527    /// Reads every page after the first, calling `visit` with each one. Stops early when
528    /// `visit` answers `false`. A walk that reaches the client's page limit with pages
529    /// still to read stops there and says so, as [`Error::pagination_capped`]: the pages
530    /// visited stand, and the caller knows they were not all of them.
531    pub async fn each_page<T: DeserializeOwned>(
532        &self,
533        first: Page<T>,
534        mut visit: impl FnMut(&Page<T>) -> bool,
535    ) -> Result<(), Error> {
536        self.within_limit(Box::pin(async move {
537            let mut page = first;
538            let mut count = 1;
539            while visit(&page) {
540                if !page.has_next() {
541                    break;
542                }
543                if count >= self.shared.max_pages {
544                    return Err(Error::pagination_capped(self.shared.max_pages));
545                }
546                match self.next_page(&page).await? {
547                    Some(next) => page = next,
548                    None => break,
549                }
550                count += 1;
551            }
552            Ok(())
553        }))
554        .await
555    }
556
557    /// Sends an operation: asks the hooks whether it may run, applies credentials and
558    /// account scope, retries transient failures when the operation is idempotent,
559    /// resends once after a refreshed 401, and answers a cached body on 304. Non-2xx
560    /// statuses become errors unless the operation treats them as empty.
561    pub async fn execute(&self, operation: Operation) -> Result<Response, Error> {
562        let deadline = self.deadline();
563        let span = span_for(&operation);
564        span.wrap(self.instrument(&operation, deadline, self.dispatch(&operation, &span)))
565            .await
566    }
567
568    /// Sends an operation and hands back the answer with its body unread, for a caller
569    /// that writes it somewhere rather than holding it. Everything up to the answer is
570    /// [`Client::execute`]'s doing — the gate, the credentials, the account scope, the
571    /// retries, the resend after a refreshed 401 — and nothing is resent once the answer
572    /// is in hand, since its bytes may already be on their way out. The deadline is the
573    /// caller's to hold, so the bytes it goes on to read can be held to the same one.
574    pub(crate) async fn stream(
575        &self,
576        operation: Operation,
577        deadline: Option<Instant>,
578    ) -> Result<HttpResponse<Body>, Error> {
579        let span = span_for(&operation);
580        span.wrap(self.instrument(&operation, deadline, self.streamed(&operation, &span)))
581            .await
582    }
583
584    /// When the operation in progress has to be over: the deadline of the operation this
585    /// task is already inside, when it is inside one, or else
586    /// [`ClientBuilder::operation_timeout`] from now; `None` when there is no limit.
587    pub(crate) fn deadline(&self) -> Option<Instant> {
588        match DEADLINE.try_with(|deadline| *deadline) {
589            Ok(inherited) => inherited,
590            Err(_) => self
591                .shared
592                .operation_timeout
593                .and_then(|limit| Instant::now().checked_add(limit)),
594        }
595    }
596
597    /// Holds some work to [`ClientBuilder::operation_timeout`] as one operation: every
598    /// request made inside it — a convenience's follow-up, a walk's later pages — shares
599    /// the one deadline rather than starting a limit of its own.
600    pub(crate) async fn within_limit<T>(
601        &self,
602        work: impl Future<Output = Result<T, Error>>,
603    ) -> Result<T, Error> {
604        let deadline = self.deadline();
605        DEADLINE
606            .scope(deadline, self.within_deadline(deadline, work))
607            .await
608    }
609
610    /// Holds some work to a deadline. Past it the work is dropped where it stands — which
611    /// is what makes the guards report the ends they owe, and the resilience layer give
612    /// back what the operation held — and the caller gets a network error naming the
613    /// limit.
614    pub(crate) async fn within_deadline<T>(
615        &self,
616        deadline: Option<Instant>,
617        work: impl Future<Output = Result<T, Error>>,
618    ) -> Result<T, Error> {
619        match (deadline, self.shared.operation_timeout) {
620            (Some(deadline), Some(limit)) => {
621                match tokio::time::timeout_at(deadline.into(), work).await {
622                    Ok(outcome) => outcome,
623                    Err(_) => Err(Error::timed_out(limit)),
624                }
625            }
626            _ => work.await,
627        }
628    }
629
630    /// Runs one operation inside the hook lifecycle every call shares. A quiet operation is
631    /// one request inside another and skips that lifecycle — see [`Operation::quiet`].
632    /// The `tracing` span around all of this is the caller's to put on, so that the gate and
633    /// the end hook are inside it too.
634    ///
635    /// The deadline is applied in here, to the gate and to the work, so that the hooks hear
636    /// an operation that ran out of time end with the same [`Error::timed_out`] the caller
637    /// gets. The end is reported from a drop guard rather than after the await all the
638    /// same, because the await may never return: a caller's own `tokio::time::timeout` or
639    /// `select!` can drop the future mid-flight, and a start with no end leaves the
640    /// bulkhead a permit short and the circuit breaker a call short for the life of the
641    /// client. Dropped that way, the operation ends as [`Error::cancelled`].
642    async fn instrument<T>(
643        &self,
644        operation: &Operation,
645        deadline: Option<Instant>,
646        work: impl Future<Output = Result<T, Error>>,
647    ) -> Result<T, Error> {
648        if operation.quiet {
649            self.within_deadline(deadline, work).await
650        } else {
651            let hooks = &self.shared.hooks;
652            self.within_deadline(deadline, hooks.on_operation_gate(&operation.info))
653                .await?;
654
655            let mut running = Running {
656                hooks,
657                info: &operation.info,
658                state: Some(hooks.on_operation_start(&operation.info)),
659                started: Instant::now(),
660            };
661            let outcome = self.within_deadline(deadline, work).await;
662            running.finished(outcome.as_ref().map(|_| ()));
663            outcome
664        }
665    }
666
667    /// Reads the answer the retry loop settled on, and tells the hooks how it turned out
668    /// once its body has been dealt with.
669    async fn dispatch(
670        &self,
671        operation: &Operation,
672        span: &OperationSpan,
673    ) -> Result<Response, Error> {
674        let url = self.url_for(operation)?;
675        let mut answered = self.attempt(operation, &url).await?;
676        let status = answered.response.status();
677        span.answered(status, request_id(answered.response.headers()));
678        let finished = self
679            .finish(
680                operation,
681                &url,
682                answered.url,
683                answered.response,
684                answered.cached,
685            )
686            .await;
687        answered.sending.end(&RequestResult {
688            status: Some(status),
689            duration: answered.duration,
690            error: finished.as_ref().err(),
691            from_cache: finished.as_ref().is_ok_and(|response| response.from_cache),
692            retryable: answered.retryable,
693            retry_after: answered.retry_after,
694        });
695        finished
696    }
697
698    /// Hands the answer over unread, once its status says there is a body worth reading.
699    async fn streamed(
700        &self,
701        operation: &Operation,
702        span: &OperationSpan,
703    ) -> Result<HttpResponse<Body>, Error> {
704        let url = self.url_for(operation)?;
705        let mut answered = self.attempt(operation, &url).await?;
706        let status = answered.response.status();
707        span.answered(status, request_id(answered.response.headers()));
708        let failure = (!status.is_success()).then(|| {
709            Error::from_response(status, &operation.method, answered.response.headers(), &[])
710        });
711        answered.sending.end(&RequestResult {
712            status: Some(status),
713            duration: answered.duration,
714            error: failure.as_ref(),
715            from_cache: false,
716            retryable: answered.retryable,
717            retry_after: answered.retry_after,
718        });
719        match failure {
720            Some(error) => Err(error),
721            None => Ok(answered.response),
722        }
723    }
724
725    /// What the retry loop may spend on one operation. A modelled route brings its own
726    /// policy from the model — how many sends it gets in all, which statuses earn another,
727    /// and how long the first wait is — and the client's settings only make that gentler:
728    /// [`ClientBuilder::max_retries`] caps the sends, [`ClientBuilder::base_delay`] holds
729    /// the wait up and [`ClientBuilder::max_delay`] holds it down. A route the model gives
730    /// no policy is sent once. A path the caller wrote has no policy to bring, so it runs
731    /// on the client's settings alone. Whatever the policy, an operation that is not
732    /// idempotent is sent once.
733    fn budget(&self, operation: &Operation) -> Budget {
734        let shared = &self.shared;
735        let ceiling = shared.max_retries.saturating_add(1);
736        let (attempts, retry_on, delay) = match operation.route.map(|route| &route.retry) {
737            Some(policy) if policy.max > 0 => (
738                policy.max.min(ceiling),
739                policy.retry_on,
740                Duration::from_millis(policy.base_delay_ms)
741                    .max(shared.base_delay.unwrap_or(Duration::ZERO)),
742            ),
743            Some(_) => (1, &[][..], DEFAULT_BASE_DELAY),
744            None => (
745                ceiling,
746                RETRYABLE_STATUSES,
747                shared.base_delay.unwrap_or(DEFAULT_BASE_DELAY),
748            ),
749        };
750        Budget {
751            attempts: if operation.idempotent { attempts } else { 1 },
752            retry_on,
753            delay: delay.min(shared.max_delay),
754        }
755    }
756
757    /// Sends the operation as many times as its retry budget and HEY's answers call for,
758    /// and hands back the answer it stopped on with the body still unread.
759    #[allow(clippy::too_many_lines)] // one loop, read as one: every way out of an attempt is in view
760    async fn attempt(&self, operation: &Operation, url: &Url) -> Result<Answered, Error> {
761        let hooks = &self.shared.hooks;
762        let budget = self.budget(operation);
763        let mut attempts = budget.attempts;
764        let mut attempt = 1;
765        let mut delay = budget.delay;
766        let mut refreshed = false;
767        // Looked up once and carried across the attempts: a resend would find the same
768        // entry, and the cache the SDK ships reads it off disk.
769        let mut cached = None;
770
771        loop {
772            // Signed and counted under the read half of the refresh lock, so no refresh
773            // lands between the two: the count says exactly which credentials went out.
774            let (request, signed_under) = {
775                let _signing = self.shared.refreshing.read().await;
776                let request = self.prepare(operation, url, &mut cached).await?;
777                (request, self.shared.refreshes.load(Ordering::Acquire))
778            };
779            let mut sending = Sending::start(
780                hooks.clone(),
781                RequestInfo {
782                    method: operation.method.clone(),
783                    url: url.clone(),
784                    attempt,
785                },
786            );
787            let started = Instant::now();
788            // The attempt span closes here, before any refresh or backoff: it is the send.
789            let sent = {
790                let span = AttemptSpan::new(attempt);
791                let sent = span
792                    .wrap(self.transmit(operation, url.clone(), request))
793                    .await;
794                if let Ok((_, response)) = &sent {
795                    span.answered(response.status());
796                }
797                sent
798            };
799            let duration = started.elapsed();
800
801            match sent {
802                Err(error) => {
803                    sending.end(&RequestResult {
804                        status: None,
805                        duration,
806                        error: Some(&error),
807                        from_cache: false,
808                        retryable: true,
809                        retry_after: None,
810                    });
811                    if attempt < attempts {
812                        crate::trace::debug!(operation = label(operation), attempt, error = %error.code(), "request failed, retrying");
813                        hooks.on_retry(&sending.info, attempt + 1, &error);
814                        self.wait(delay).await;
815                        delay = self.next_delay(delay);
816                        attempt += 1;
817                    } else {
818                        return Err(error);
819                    }
820                }
821                Ok((final_url, response)) => {
822                    let status = response.status();
823                    let retryable = budget.retry_on.contains(&status.as_u16());
824                    let retry_after = retry_after_asked(status, response.headers());
825                    if status == StatusCode::UNAUTHORIZED
826                        && !refreshed
827                        && self.refresh_credentials(signed_under).await
828                    {
829                        let cause = Error::auth("Token refreshed").retryable();
830                        sending.end(&RequestResult {
831                            status: Some(status),
832                            duration,
833                            error: Some(&cause),
834                            from_cache: false,
835                            retryable,
836                            retry_after,
837                        });
838                        crate::trace::debug!(
839                            operation = label(operation),
840                            "credentials refreshed, resending"
841                        );
842                        hooks.on_retry(&sending.info, attempt + 1, &cause);
843                        refreshed = true;
844                        attempt += 1;
845                        attempts = attempts.max(attempt);
846                    } else if retryable && attempt < attempts {
847                        let cause = Error::from_response(
848                            status,
849                            &operation.method,
850                            response.headers(),
851                            &[],
852                        );
853                        sending.end(&RequestResult {
854                            status: Some(status),
855                            duration,
856                            error: Some(&cause),
857                            from_cache: false,
858                            retryable,
859                            retry_after,
860                        });
861                        crate::trace::debug!(operation = label(operation), attempt, %status, "retryable status, retrying");
862                        hooks.on_retry(&sending.info, attempt + 1, &cause);
863                        match retry_after {
864                            Some(seconds)
865                                if status == StatusCode::TOO_MANY_REQUESTS && seconds > 0 =>
866                            {
867                                self.wait_as_asked(Duration::from_secs(seconds)).await;
868                            }
869                            _ => self.wait(delay).await,
870                        }
871                        delay = self.next_delay(delay);
872                        attempt += 1;
873                    } else {
874                        return Ok(Answered {
875                            url: final_url,
876                            response,
877                            cached: cached.take(),
878                            sending,
879                            duration,
880                            retryable,
881                            retry_after,
882                        });
883                    }
884                }
885            }
886        }
887    }
888
889    /// Answers a 401 with fresh credentials, once for all the requests the stale ones
890    /// earned it on. Refreshes go one at a time, and a request that was signed before the
891    /// last refresh is simply resent: the credentials it will pick up are already the new
892    /// ones, and asking the token endpoint again would only spend a round trip — or, with
893    /// a rotating refresh token, burn the one just issued. A refresh that fails leaves the
894    /// count where it was, so the next 401 asks again rather than trusting a failure.
895    ///
896    /// The refresh runs on a task of its own, which holds the turn, so a caller that gives
897    /// up waiting — its [`ClientBuilder::operation_timeout`] running out, say — does not
898    /// abandon a refresh the token endpoint may already have honoured: the provider still
899    /// gets to keep what it was handed, the count still moves, and the next caller waits
900    /// its turn rather than refreshing again over the top of it.
901    /// A caller gone before its refresh got the turn does not have one started on its
902    /// behalf: a queue of stale requests whose limits ran out while an earlier refresh
903    /// held the turn would otherwise each refresh in turn, for nobody.
904    async fn refresh_credentials(&self, signed_under: u64) -> bool {
905        let shared = self.shared.clone();
906        let interest = Interest::new();
907        let wanted = interest.wanted.clone();
908        let refresh = tokio::spawn(async move {
909            let _turn = shared.refreshing.write().await;
910            if shared.refreshes.load(Ordering::Acquire) != signed_under {
911                true
912            } else if !wanted.load(Ordering::Acquire) {
913                false
914            } else if shared.auth.refresh().await {
915                shared.refreshes.fetch_add(1, Ordering::AcqRel);
916                true
917            } else {
918                false
919            }
920        });
921        let refreshed = refresh.await.unwrap_or(false);
922        drop(interest);
923        refreshed
924    }
925
926    pub(crate) fn url_for(&self, operation: &Operation) -> Result<Url, Error> {
927        let mut url = if let Some(url) = &operation.url {
928            url.clone()
929        } else {
930            let mut path = operation.path.clone();
931            if operation.json_suffix {
932                path = with_json_extension(&path);
933            }
934            self.shared.base_url.join(path.trim_start_matches('/'))?
935        };
936        if !operation.query.is_empty() {
937            url.query_pairs_mut().extend_pairs(&operation.query);
938        }
939        if let Some(account_id) = self.account_id
940            && is_same_origin(&url, &self.shared.base_url)
941        {
942            let others: Vec<(String, String)> = url
943                .query_pairs()
944                .filter(|(name, _)| name != ACCOUNT_FILTER_PARAMETER)
945                .map(|(name, value)| (name.into_owned(), value.into_owned()))
946                .collect();
947            url.query_pairs_mut()
948                .clear()
949                .extend_pairs(others)
950                .append_pair(ACCOUNT_FILTER_PARAMETER, &account_id.to_string());
951        }
952        Ok(url)
953    }
954
955    /// Builds the request for one attempt, and looks the response cache up the first time
956    /// it is asked for a key. `cached` carries the entry — or the empty stand-in that says
957    /// "cacheable, nothing stored" — from one attempt to the next.
958    async fn prepare(
959        &self,
960        operation: &Operation,
961        url: &Url,
962        cached: &mut Option<(String, CachedResponse)>,
963    ) -> Result<Request<Bytes>, Error> {
964        let mut request = Request::builder()
965            .method(operation.method.clone())
966            .uri(url.as_str())
967            .body(Bytes::new())
968            .map_err(Error::from_std)?;
969        let headers = request.headers_mut();
970        headers.insert(USER_AGENT, header_value(&self.shared.user_agent)?);
971        headers.insert(ACCEPT, HeaderValue::from_static(operation.accept));
972        if let Some(body) = &operation.body {
973            headers.insert(CONTENT_TYPE, header_value(&body.content_type)?);
974            *request.body_mut() = body.bytes.clone();
975        }
976        self.shared.auth.authenticate(&mut request).await?;
977
978        let key = match self.cacheable(operation) {
979            None => None,
980            Some(cache) => match request
981                .headers()
982                .get(AUTHORIZATION)
983                .and_then(|value| value.to_str().ok())
984            {
985                None => None,
986                Some(credential) => {
987                    let key = cache_key(url.as_str(), credential);
988                    if cached.as_ref().is_none_or(|(held, _)| *held != key) {
989                        *cached = self.look_up(cache, &key).await;
990                    }
991                    Some(key)
992                }
993            },
994        };
995        // A refreshed credential gives the read a new key, and whatever was held under the
996        // old one belongs to somebody else's reading.
997        if key.is_none() {
998            *cached = None;
999        }
1000        if let Some((_, entry)) = cached.as_ref()
1001            && !entry.etag.is_empty()
1002        {
1003            let validator = header_value(&entry.etag)?;
1004            request.headers_mut().insert(IF_NONE_MATCH, validator);
1005        }
1006        Ok(request)
1007    }
1008
1009    /// What the cache holds for a key, as the attempt should carry it: the stored entry, an
1010    /// empty stand-in when there is nothing stored, and nothing at all when what is stored
1011    /// is longer than the client would hold — which is thrown away on the way past.
1012    async fn look_up(
1013        &self,
1014        cache: &Arc<dyn ResponseCache>,
1015        key: &str,
1016    ) -> Option<(String, CachedResponse)> {
1017        match cache_get(cache, key).await {
1018            Some(entry) if entry.body.len() <= self.shared.max_response_body_bytes => {
1019                Some((key.to_string(), entry))
1020            }
1021            Some(_) => {
1022                cache_invalidate(cache, key).await;
1023                None
1024            }
1025            None => Some((
1026                key.to_string(),
1027                CachedResponse {
1028                    etag: String::new(),
1029                    body: Bytes::new(),
1030                },
1031            )),
1032        }
1033    }
1034
1035    /// The cache the operation reads and writes, when there is one to use. Cached bodies
1036    /// are held per identity, so a request that goes out without credentials — an
1037    /// [`AuthStrategy`] that signs some other way, or none at all — is not cached: there
1038    /// would be nothing to tell one caller's copy from another's.
1039    fn cacheable(&self, operation: &Operation) -> Option<&Arc<dyn ResponseCache>> {
1040        if !operation.no_cache
1041            && operation.method == Method::GET
1042            && operation.accept == "application/json"
1043        {
1044            self.shared.cache.as_ref()
1045        } else {
1046            None
1047        }
1048    }
1049
1050    /// Sends one request and follows the redirects it is answered with, up to
1051    /// [`MAX_REDIRECTS`] hops, unless the operation is one that takes the redirect for its
1052    /// answer. Hands back the URL the answer came from along with the answer.
1053    ///
1054    /// Credentials stay on the origin they were meant for: a hop to another origin goes out
1055    /// without the `Authorization`, the way a browser would send it, which is how a blob
1056    /// request ends up at the storage service without HEY's token. A 301, 302 or 303 turns
1057    /// anything but a GET or HEAD into a GET without its body; a 307 or 308 keeps both.
1058    async fn transmit(
1059        &self,
1060        operation: &Operation,
1061        mut url: Url,
1062        mut request: Request<Bytes>,
1063    ) -> Result<(Url, HttpResponse<Body>), Error> {
1064        let mut hops = 0;
1065        loop {
1066            let outgoing = (
1067                request.method().clone(),
1068                request.headers().clone(),
1069                request.body().clone(),
1070            );
1071            let response = self.shared.http.send(request).await?;
1072            let next = if operation.capture_redirects {
1073                None
1074            } else {
1075                redirect_target(&url, &response)
1076            };
1077            match next {
1078                None => return Ok((url, response)),
1079                Some(_) if hops == MAX_REDIRECTS => {
1080                    return Err(Error::new(
1081                        ErrorCode::Network,
1082                        format!(
1083                            "{} redirected more than {MAX_REDIRECTS} times",
1084                            operation.label()
1085                        ),
1086                    )
1087                    .retryable());
1088                }
1089                Some(next) => {
1090                    require_secure_endpoint(&next)?;
1091                    request = redirected(outgoing, response.status(), &url, &next)?;
1092                    url = next;
1093                    hops += 1;
1094                }
1095            }
1096        }
1097    }
1098
1099    /// The most of an answer to this operation the client will hold. An answer it asked
1100    /// for as a document it goes on to parse is held to the configured cap; anything else
1101    /// — a blob, an export, whatever a form request answered — to the fixed
1102    /// [`MAX_RESPONSE_BODY_BYTES`]. What the server labels the answer does not come into
1103    /// it: a JSON body sent as a PNG is still capped, and an attachment that turns out to
1104    /// be text is still not.
1105    fn buffer_bound(&self, operation: &Operation) -> usize {
1106        if is_parsed(operation.accept) {
1107            self.shared.max_response_body_bytes
1108        } else {
1109            MAX_RESPONSE_BODY_BYTES
1110        }
1111    }
1112
1113    async fn finish(
1114        &self,
1115        operation: &Operation,
1116        url: &Url,
1117        final_url: Url,
1118        response: HttpResponse<Body>,
1119        cached: Option<(String, CachedResponse)>,
1120    ) -> Result<Response, Error> {
1121        let status = response.status();
1122        let headers = response.headers().clone();
1123
1124        if status == StatusCode::NOT_MODIFIED {
1125            return match cached {
1126                Some((_, entry)) if !entry.etag.is_empty() => Ok(Response {
1127                    status: StatusCode::OK,
1128                    headers,
1129                    body: entry.body,
1130                    url: final_url,
1131                    from_cache: true,
1132                    empty: false,
1133                }),
1134                _ => Err(Error::api(
1135                    304,
1136                    "304 received but no cached response available",
1137                )),
1138            };
1139        }
1140
1141        let bound = self.buffer_bound(operation);
1142        let body = match read_body(response.into_body(), bound, &operation.method, url.path()).await
1143        {
1144            Ok(body) => body,
1145            Err(refusal) if status.is_success() => return Err(refusal),
1146            // The status is what matters about a failure, and a body the client would not
1147            // read is no reason to lose it.
1148            Err(refusal) => {
1149                return Err(
1150                    Error::from_response(status, &operation.method, &headers, &[])
1151                        .refusing(refusal),
1152                );
1153            }
1154        };
1155
1156        if status.is_success() {
1157            if let (Some((key, _)), Some(cache)) = (cached, self.cacheable(operation))
1158                && let Some(etag) = headers.get("etag").and_then(|value| value.to_str().ok())
1159            {
1160                cache_set(
1161                    cache,
1162                    &key,
1163                    CachedResponse {
1164                        etag: etag.to_string(),
1165                        body: body.clone(),
1166                    },
1167                )
1168                .await;
1169            }
1170            Ok(Response {
1171                status,
1172                headers,
1173                body,
1174                url: final_url,
1175                from_cache: false,
1176                empty: false,
1177            })
1178        } else if operation.empty_on.contains(&status.as_u16()) {
1179            Ok(Response {
1180                status,
1181                headers,
1182                body,
1183                url: final_url,
1184                from_cache: false,
1185                empty: true,
1186            })
1187        } else {
1188            Err(Error::from_response(
1189                status,
1190                &operation.method,
1191                &headers,
1192                &body,
1193            ))
1194        }
1195    }
1196
1197    /// Sleeps the backoff's wait plus a little jitter, held under the longest wait the
1198    /// client allows.
1199    async fn wait(&self, delay: Duration) {
1200        tokio::time::sleep((delay + self.jitter()).min(self.shared.max_delay)).await;
1201    }
1202
1203    /// Sleeps the wait HEY asked for, which the client's ceiling does not shorten: the
1204    /// server said when it will answer again, and resending sooner only earns another
1205    /// refusal.
1206    async fn wait_as_asked(&self, delay: Duration) {
1207        tokio::time::sleep(delay + self.jitter()).await;
1208    }
1209
1210    fn jitter(&self) -> Duration {
1211        match self.shared.max_jitter.as_millis() {
1212            0 => Duration::ZERO,
1213            millis => Duration::from_millis(rand::random_range(
1214                0..u64::try_from(millis).unwrap_or(u64::MAX),
1215            )),
1216        }
1217    }
1218
1219    fn next_delay(&self, delay: Duration) -> Duration {
1220        (delay * 2).min(self.shared.max_delay)
1221    }
1222}
1223
1224/// An operation the hooks have been told the start of and are still owed the end of. It
1225/// reports the end whichever way the operation leaves: [`Running::finished`] with the
1226/// outcome, or the drop that comes instead when the caller abandons the future.
1227struct Running<'a> {
1228    hooks: &'a Arc<dyn Hooks>,
1229    info: &'a OperationInfo,
1230    state: Option<OperationState>,
1231    started: Instant,
1232}
1233
1234impl Running<'_> {
1235    fn finished(&mut self, outcome: Result<(), &Error>) {
1236        if let Some(state) = self.state.take() {
1237            self.hooks
1238                .on_operation_end(self.info, state, outcome, self.started.elapsed());
1239        }
1240    }
1241}
1242
1243impl Drop for Running<'_> {
1244    fn drop(&mut self) {
1245        // A state still here is one `finished` never took, which means the future was
1246        // dropped before the work returned.
1247        if self.state.is_some() {
1248            self.finished(Err(&Error::cancelled()));
1249        }
1250    }
1251}
1252
1253/// What one operation may spend on being resent: the sends it gets in all, the statuses
1254/// that earn another, and the wait before the first resend.
1255struct Budget {
1256    attempts: u32,
1257    retry_on: &'static [u16],
1258    delay: Duration,
1259}
1260
1261/// Whether the caller that asked for a refresh is still there to want it. Dropped when
1262/// that caller's future is — its limit running out, a `select!` taking another branch —
1263/// so a refresh that has not yet had its turn can stand down.
1264struct Interest {
1265    wanted: Arc<AtomicBool>,
1266}
1267
1268impl Interest {
1269    fn new() -> Interest {
1270        Interest {
1271            wanted: Arc::new(AtomicBool::new(true)),
1272        }
1273    }
1274}
1275
1276impl Drop for Interest {
1277    fn drop(&mut self) {
1278        self.wanted.store(false, Ordering::Release);
1279    }
1280}
1281
1282/// A request the hooks have been told the start of and are still owed the end of, the
1283/// way [`Running`] is for an operation. It reports the end from [`Sending::end`] with how
1284/// the request turned out, or from the drop that comes instead when the future is
1285/// abandoned mid-request — an operation limit running out, a caller's `select!` — so a
1286/// hook counting requests in flight is never left one short.
1287struct Sending {
1288    hooks: Arc<dyn Hooks>,
1289    info: RequestInfo,
1290    started: Instant,
1291    owed: bool,
1292}
1293
1294impl Sending {
1295    fn start(hooks: Arc<dyn Hooks>, info: RequestInfo) -> Sending {
1296        hooks.on_request_start(&info);
1297        Sending {
1298            hooks,
1299            info,
1300            started: Instant::now(),
1301            owed: true,
1302        }
1303    }
1304
1305    fn end(&mut self, result: &RequestResult<'_>) {
1306        self.owed = false;
1307        self.hooks.on_request_end(&self.info, result);
1308    }
1309}
1310
1311impl Drop for Sending {
1312    fn drop(&mut self) {
1313        if self.owed {
1314            self.end(&RequestResult {
1315                status: None,
1316                duration: self.started.elapsed(),
1317                error: Some(&Error::cancelled()),
1318                from_cache: false,
1319                retryable: false,
1320                retry_after: None,
1321            });
1322        }
1323    }
1324}
1325
1326/// One answer from HEY with its body unread: what the retry loop settled on, the URL it
1327/// came from once any redirects were followed, and what the hooks still have to be told
1328/// about it once the body has been dealt with.
1329struct Answered {
1330    url: Url,
1331    response: HttpResponse<Body>,
1332    cached: Option<(String, CachedResponse)>,
1333    sending: Sending,
1334    duration: Duration,
1335    retryable: bool,
1336    retry_after: Option<u64>,
1337}
1338
1339/// The cache is whatever the caller supplied, and the one the SDK ships keeps its entries in
1340/// files. So every read and write of it goes to the blocking pool: a file read on the
1341/// runtime's own thread stalls every other task sharing that thread. A cache that cannot be
1342/// reached — the pool shutting down under it — is a miss, which is what any other failure to
1343/// read it is too.
1344async fn cache_get(cache: &Arc<dyn ResponseCache>, key: &str) -> Option<CachedResponse> {
1345    let cache = cache.clone();
1346    let key = key.to_string();
1347    tokio::task::spawn_blocking(move || cache.get(&key))
1348        .await
1349        .ok()
1350        .flatten()
1351}
1352
1353async fn cache_set(cache: &Arc<dyn ResponseCache>, key: &str, response: CachedResponse) {
1354    let cache = cache.clone();
1355    let key = key.to_string();
1356    let _ = tokio::task::spawn_blocking(move || cache.set(&key, response)).await;
1357}
1358
1359async fn cache_invalidate(cache: &Arc<dyn ResponseCache>, key: &str) {
1360    let cache = cache.clone();
1361    let key = key.to_string();
1362    let _ = tokio::task::spawn_blocking(move || cache.invalidate(&key)).await;
1363}
1364
1365#[cfg(feature = "reqwest")]
1366fn shipped_http_client(timeout: Duration) -> Result<Arc<dyn HttpClient>, Error> {
1367    Ok(Arc::new(crate::http::ReqwestClient::with_timeout(timeout)?))
1368}
1369
1370#[cfg(not(feature = "reqwest"))]
1371fn shipped_http_client(_timeout: Duration) -> Result<Arc<dyn HttpClient>, Error> {
1372    Err(Error::usage(
1373        "no HTTP client: supply one with ClientBuilder::http_client, or enable the reqwest feature",
1374    ))
1375}
1376
1377/// Where a redirect points, when the answer is one and says where. A 3xx without a
1378/// `Location`, or with one that is not a URL, is handed back as the answer it is.
1379fn redirect_target(url: &Url, response: &HttpResponse<Body>) -> Option<Url> {
1380    let status = response.status();
1381    if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
1382        response
1383            .headers()
1384            .get("location")
1385            .and_then(|value| value.to_str().ok())
1386            .and_then(|location| url.join(location).ok())
1387    } else {
1388        None
1389    }
1390}
1391
1392/// The request to send to `next` on the way there from `from`: the same one, less the
1393/// credentials when the origin changes, and reduced to a GET when the status asks for it.
1394fn redirected(
1395    (method, mut headers, body): (Method, HeaderMap, Bytes),
1396    status: StatusCode,
1397    from: &Url,
1398    next: &Url,
1399) -> Result<Request<Bytes>, Error> {
1400    let keeps_method = method == Method::GET
1401        || method == Method::HEAD
1402        || status == StatusCode::TEMPORARY_REDIRECT
1403        || status == StatusCode::PERMANENT_REDIRECT;
1404    let (method, body) = if keeps_method {
1405        (method, body)
1406    } else {
1407        headers.remove(CONTENT_TYPE);
1408        headers.remove(CONTENT_LENGTH);
1409        (Method::GET, Bytes::new())
1410    };
1411    if !is_same_origin(next, from) {
1412        headers.remove(AUTHORIZATION);
1413        headers.remove(COOKIE);
1414        headers.remove(PROXY_AUTHORIZATION);
1415    }
1416    let mut request = Request::builder()
1417        .method(method)
1418        .uri(next.as_str())
1419        .body(body)
1420        .map_err(Error::from_std)?;
1421    *request.headers_mut() = headers;
1422    Ok(request)
1423}
1424
1425fn parse_base_url(base_url: &str) -> Result<Url, Error> {
1426    let mut url = Url::parse(base_url)
1427        .map_err(|error| Error::usage(format!("base URL {base_url}: {error}")))?;
1428    require_secure_endpoint(&url)?;
1429    if !url.path().ends_with('/') {
1430        url.set_path(&format!("{}/", url.path()));
1431    }
1432    Ok(url)
1433}
1434
1435/// HEY answers JSON to paths that end in `.json`. The model leaves the extension off
1436/// paths that end in a parameter, since Smithy cannot express `{id}.json`, so it is put
1437/// back here unless the last segment already carries an extension.
1438pub(crate) fn with_json_extension(path: &str) -> String {
1439    let last_segment = path.rsplit('/').next().unwrap_or_default();
1440    if path.is_empty() || path.ends_with('/') || last_segment.contains('.') {
1441        path.to_string()
1442    } else {
1443        format!("{path}.json")
1444    }
1445}
1446
1447/// The span an operation runs in: one of its own, or none for a quiet send, which is one
1448/// request inside another operation and runs in that operation's span.
1449fn span_for(operation: &Operation) -> OperationSpan {
1450    if operation.quiet {
1451        OperationSpan::none()
1452    } else {
1453        OperationSpan::new(operation)
1454    }
1455}
1456
1457/// HEY's own id for the request, when the answer names one.
1458fn request_id(headers: &HeaderMap) -> Option<&str> {
1459    headers
1460        .get("x-request-id")
1461        .and_then(|value| value.to_str().ok())
1462}
1463
1464/// The wait HEY asked for, on the two statuses that carry one.
1465fn retry_after_asked(status: StatusCode, headers: &HeaderMap) -> Option<u64> {
1466    if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {
1467        retry_after_seconds(headers)
1468    } else {
1469        None
1470    }
1471}
1472
1473fn header_value(value: &str) -> Result<HeaderValue, Error> {
1474    HeaderValue::from_str(value)
1475        .map_err(|_| Error::usage(format!("{value:?} is not a valid header value")))
1476}
1477
1478/// Whether the answer to a request that asked for this is a document the SDK buffers and
1479/// parses. Anything it did not ask for as JSON or HTML — a blob's `*/*`, an export's
1480/// `text/csv` — it streams or holds under its own bound instead.
1481fn is_parsed(accept: &str) -> bool {
1482    accept.is_empty()
1483        || accept.split(',').any(|part| {
1484            let media_type = part.split(';').next().unwrap_or_default().trim();
1485            media_type == "application/json"
1486                || media_type.ends_with("+json")
1487                || media_type == "text/html"
1488        })
1489}
1490
1491/// Reads a body up to the bound and refuses it on the first byte past. A body exactly at
1492/// the bound reads whole; one declared past it never starts.
1493pub(crate) async fn read_body(
1494    body: Body,
1495    limit: usize,
1496    method: &Method,
1497    path: &str,
1498) -> Result<Bytes, Error> {
1499    body.collect(limit, || Error::response_too_large(limit, method, path))
1500        .await
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use std::sync::Mutex;
1506
1507    use async_trait::async_trait;
1508    use serde_json::Value;
1509
1510    use super::*;
1511    use crate::auth::StaticTokenProvider;
1512
1513    /// An [`HttpClient`] with no network behind it: it answers each request from a closure
1514    /// and keeps what it was sent. This is the second implementation the trait exists for,
1515    /// so the client is exercised here with no `reqwest` in the picture.
1516    struct Canned {
1517        answer: Box<Answer>,
1518        sent: Mutex<Vec<(Method, String, HeaderMap)>>,
1519    }
1520
1521    type Answer = dyn Fn(&Request<Bytes>) -> HttpResponse<Body> + Send + Sync;
1522
1523    impl Canned {
1524        fn new(
1525            answer: impl Fn(&Request<Bytes>) -> HttpResponse<Body> + Send + Sync + 'static,
1526        ) -> Arc<Canned> {
1527            Arc::new(Canned {
1528                answer: Box::new(answer),
1529                sent: Mutex::new(Vec::new()),
1530            })
1531        }
1532
1533        fn sent(&self) -> Vec<(Method, String, HeaderMap)> {
1534            self.sent.lock().unwrap().clone()
1535        }
1536    }
1537
1538    #[async_trait]
1539    impl HttpClient for Arc<Canned> {
1540        async fn send(&self, request: Request<Bytes>) -> Result<HttpResponse<Body>, Error> {
1541            self.sent.lock().unwrap().push((
1542                request.method().clone(),
1543                request.uri().to_string(),
1544                request.headers().clone(),
1545            ));
1546            Ok((self.answer)(&request))
1547        }
1548    }
1549
1550    fn answer(status: u16, body: &'static str) -> HttpResponse<Body> {
1551        let mut response = HttpResponse::new(Body::from(body));
1552        *response.status_mut() = StatusCode::from_u16(status).unwrap();
1553        response
1554    }
1555
1556    fn redirect(location: &str) -> HttpResponse<Body> {
1557        let mut response = answer(302, "");
1558        response
1559            .headers_mut()
1560            .insert("location", HeaderValue::from_str(location).unwrap());
1561        response
1562    }
1563
1564    fn client_over(http: Arc<Canned>) -> Client {
1565        Client::builder(Config::default().with_base_url("https://hey.test"))
1566            .token_provider(StaticTokenProvider::new("secret"))
1567            .http_client(http)
1568            .max_retries(0)
1569            .build()
1570            .unwrap()
1571    }
1572
1573    #[tokio::test]
1574    async fn a_request_goes_out_on_the_supplied_http_client_with_credentials() {
1575        let http = Canned::new(|_| answer(200, r#"{"ok":true}"#));
1576        let client = client_over(http.clone());
1577
1578        let body: Value = client
1579            .send(client.request(Method::GET, "/boxes"))
1580            .await
1581            .unwrap();
1582
1583        assert_eq!(body, serde_json::json!({ "ok": true }));
1584        let sent = http.sent();
1585        assert_eq!(sent.len(), 1);
1586        assert_eq!(sent[0].0, Method::GET);
1587        assert_eq!(sent[0].1, "https://hey.test/boxes.json");
1588        assert_eq!(sent[0].2[AUTHORIZATION], "Bearer secret");
1589    }
1590
1591    #[tokio::test]
1592    async fn a_redirect_on_the_same_origin_is_followed_with_credentials() {
1593        let http = Canned::new(|request| {
1594            if request.uri().path() == "/old.json" {
1595                redirect("/new.json")
1596            } else {
1597                answer(200, r#"{"moved":true}"#)
1598            }
1599        });
1600        let client = client_over(http.clone());
1601
1602        let response = client
1603            .execute(client.request(Method::GET, "/old"))
1604            .await
1605            .unwrap();
1606
1607        assert_eq!(response.url.as_str(), "https://hey.test/new.json");
1608        assert_eq!(response.body, r#"{"moved":true}"#);
1609        let sent = http.sent();
1610        assert_eq!(sent.len(), 2);
1611        assert_eq!(sent[1].1, "https://hey.test/new.json");
1612        assert_eq!(sent[1].2[AUTHORIZATION], "Bearer secret");
1613    }
1614
1615    #[tokio::test]
1616    async fn an_html_read_asks_for_the_page_as_hey_serves_it() {
1617        let http = Canned::new(|_| {
1618            answer(
1619                200,
1620                r#"<section id="container_workflow_stage_5512"></section>"#,
1621            )
1622        });
1623        let client = client_over(http.clone());
1624
1625        let page = client.workflows().get_stage(8801, 5512).await.unwrap();
1626
1627        assert_eq!(
1628            page,
1629            r#"<section id="container_workflow_stage_5512"></section>"#
1630        );
1631        let sent = http.sent();
1632        assert_eq!(sent.len(), 1);
1633        assert_eq!(sent[0].1, "https://hey.test/workflows/8801/stages/5512");
1634        assert_eq!(sent[0].2[ACCEPT], "text/html");
1635        assert_eq!(sent[0].2[AUTHORIZATION], "Bearer secret");
1636    }
1637
1638    #[tokio::test]
1639    async fn a_redirect_off_the_origin_is_followed_without_credentials() {
1640        let http = Canned::new(|request| {
1641            if request.uri().host() == Some("hey.test") {
1642                redirect("https://storage.test/blobs/1")
1643            } else {
1644                answer(200, "the bytes")
1645            }
1646        });
1647        let client = client_over(http.clone());
1648
1649        let response = client.get_blob("/blobs/1").await.unwrap();
1650
1651        assert_eq!(response.body, "the bytes");
1652        let sent = http.sent();
1653        assert_eq!(sent.len(), 2);
1654        assert_eq!(sent[1].1, "https://storage.test/blobs/1");
1655        assert!(sent[1].2.get(AUTHORIZATION).is_none());
1656    }
1657
1658    #[tokio::test]
1659    async fn a_redirect_to_plain_http_elsewhere_is_refused() {
1660        let http = Canned::new(|_| redirect("http://evil.test/"));
1661        let client = client_over(http.clone());
1662
1663        let error = client.get("/anything").await.unwrap_err();
1664
1665        assert_eq!(error.code(), ErrorCode::Usage);
1666        assert_eq!(http.sent().len(), 1);
1667    }
1668
1669    #[tokio::test]
1670    async fn a_redirect_loop_is_given_up_on() {
1671        let http = Canned::new(|_| redirect("/again"));
1672        let client = client_over(http.clone());
1673
1674        let error = client.get("/again").await.unwrap_err();
1675
1676        assert_eq!(error.code(), ErrorCode::Network);
1677        assert_eq!(http.sent().len(), MAX_REDIRECTS + 1);
1678    }
1679
1680    #[tokio::test]
1681    async fn a_form_request_keeps_its_redirect_rather_than_following_it() {
1682        let http = Canned::new(|_| redirect("/workflows/8801"));
1683        let client = client_over(http.clone());
1684
1685        let created = client
1686            .post_form("/workflows", &[("workflow[name]", "Launch")])
1687            .await
1688            .unwrap();
1689
1690        assert_eq!(created.location.as_deref(), Some("/workflows/8801"));
1691        assert_eq!(http.sent().len(), 1);
1692    }
1693
1694    #[test]
1695    fn json_extension_is_added_only_where_missing() {
1696        assert_eq!(with_json_extension("/boxes/123"), "/boxes/123.json");
1697        assert_eq!(with_json_extension("/boxes.json"), "/boxes.json");
1698        assert_eq!(
1699            with_json_extension("/calendar/days/2026-03-04/journal_entry"),
1700            "/calendar/days/2026-03-04/journal_entry.json"
1701        );
1702        assert_eq!(
1703            with_json_extension("/rails/active_storage/direct_uploads.json"),
1704            "/rails/active_storage/direct_uploads.json"
1705        );
1706        assert_eq!(with_json_extension("/boxes/"), "/boxes/");
1707    }
1708
1709    #[test]
1710    fn base_url_must_be_https_or_local() {
1711        assert!(parse_base_url("https://app.hey.com").is_ok());
1712        assert!(parse_base_url("http://127.0.0.1:3000").is_ok());
1713        assert_eq!(
1714            parse_base_url("http://evil.example.com")
1715                .unwrap_err()
1716                .code(),
1717            crate::ErrorCode::Usage
1718        );
1719    }
1720}