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
35pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
37pub const DEFAULT_MAX_RETRIES: u32 = 3;
39pub const DEFAULT_BASE_DELAY: Duration = Duration::from_secs(1);
41pub const DEFAULT_MAX_DELAY: Duration = Duration::from_secs(30);
49pub const DEFAULT_MAX_JITTER: Duration = Duration::from_millis(100);
52pub const DEFAULT_MAX_PAGES: usize = 10_000;
54pub const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 16 << 20;
56
57pub const MAX_RESPONSE_BODY_BYTES: usize = 50 << 20;
61
62const RETRYABLE_STATUSES: &[u16] = &[429, 500, 502, 503, 504];
65const ACCOUNT_FILTER_PARAMETER: &str = "filtered_account_id";
66const MAX_REDIRECTS: usize = 10;
69
70tokio::task_local! {
71 static DEADLINE: Option<Instant>;
75}
76
77#[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 pub(crate) refreshes: AtomicU64,
107 pub(crate) refreshing: tokio::sync::RwLock<()>,
113}
114
115#[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#[derive(Debug, Clone)]
127#[non_exhaustive]
128pub struct Response {
129 pub status: StatusCode,
131 pub headers: HeaderMap,
133 pub body: Bytes,
135 pub url: Url,
137 pub from_cache: bool,
140 pub empty: bool,
143}
144
145impl Response {
146 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 pub fn header(&self, name: &str) -> Option<&str> {
164 self.headers.get(name).and_then(|value| value.to_str().ok())
165 }
166}
167
168pub 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 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 #[must_use]
210 pub fn token_provider(self, provider: impl TokenProvider + 'static) -> ClientBuilder {
211 self.auth_strategy(BearerAuth::new(provider))
212 }
213
214 #[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 #[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 #[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 #[must_use]
242 pub fn timeout(mut self, timeout: Duration) -> ClientBuilder {
243 self.timeout = timeout;
244 self
245 }
246
247 #[must_use]
256 pub fn operation_timeout(mut self, limit: Duration) -> ClientBuilder {
257 self.operation_timeout = Some(limit);
258 self
259 }
260
261 #[must_use]
266 pub fn max_retries(mut self, max_retries: u32) -> ClientBuilder {
267 self.max_retries = max_retries;
268 self
269 }
270
271 #[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 #[must_use]
286 pub fn max_delay(mut self, max_delay: Duration) -> ClientBuilder {
287 self.max_delay = max_delay;
288 self
289 }
290
291 #[must_use]
294 pub fn max_jitter(mut self, max_jitter: Duration) -> ClientBuilder {
295 self.max_jitter = max_jitter;
296 self
297 }
298
299 #[must_use]
302 pub fn max_pages(mut self, max_pages: usize) -> ClientBuilder {
303 self.max_pages = max_pages;
304 self
305 }
306
307 #[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 #[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 #[must_use]
326 pub fn hooks(mut self, hooks: impl Hooks + 'static) -> ClientBuilder {
327 self.hooks = Arc::new(hooks);
328 self
329 }
330
331 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 pub fn builder(config: Config) -> ClientBuilder {
399 ClientBuilder::new(config)
400 }
401
402 #[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 pub fn config(&self) -> &Config {
413 &self.shared.config
414 }
415
416 pub fn base_url(&self) -> &Url {
418 &self.shared.base_url
419 }
420
421 pub fn account_id(&self) -> Option<i64> {
423 self.account_id
424 }
425
426 pub fn max_pages(&self) -> usize {
428 self.shared.max_pages
429 }
430
431 pub(crate) fn http(&self) -> &dyn HttpClient {
436 self.shared.http.as_ref()
437 }
438
439 pub fn operation(&self, route: &'static Route, params: &[&dyn Display]) -> Operation {
443 Operation::for_route(route, params)
444 }
445
446 pub fn request(&self, method: Method, path: impl Into<String>) -> Operation {
450 Operation::raw(method, path.into())
451 }
452
453 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 pub async fn send_unit(&self, operation: Operation) -> Result<(), Error> {
464 self.execute(operation).await.map(|_| ())
465 }
466
467 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[allow(clippy::too_many_lines)] 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 let mut cached = None;
770
771 loop {
772 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 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 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 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 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 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 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 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 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 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 async fn wait(&self, delay: Duration) {
1200 tokio::time::sleep((delay + self.jitter()).min(self.shared.max_delay)).await;
1201 }
1202
1203 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
1224struct 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 if self.state.is_some() {
1248 self.finished(Err(&Error::cancelled()));
1249 }
1250 }
1251}
1252
1253struct Budget {
1256 attempts: u32,
1257 retry_on: &'static [u16],
1258 delay: Duration,
1259}
1260
1261struct 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
1282struct 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
1326struct 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
1339async 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
1377fn 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
1392fn 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
1435pub(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
1447fn span_for(operation: &Operation) -> OperationSpan {
1450 if operation.quiet {
1451 OperationSpan::none()
1452 } else {
1453 OperationSpan::new(operation)
1454 }
1455}
1456
1457fn request_id(headers: &HeaderMap) -> Option<&str> {
1459 headers
1460 .get("x-request-id")
1461 .and_then(|value| value.to_str().ok())
1462}
1463
1464fn 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
1478fn 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
1491pub(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 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}