1use std::error::Error as StdError;
11use std::fmt;
12use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
13
14use ephemeris_token::Token;
15use facet::Facet;
16use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
17use reqwest::multipart::Form;
18use reqwest::{Method, RequestBuilder, Response, Url};
19use tokio::time;
20
21const DEFAULT_PAGE_LIMIT: usize = 1000;
24
25const MAX_RATE_LIMIT_WAIT: Duration = Duration::from_secs(60);
28
29const RATE_LIMIT_REMAINING: HeaderName = HeaderName::from_static("x-ratelimit-remaining");
30const RATE_LIMIT_RESET: HeaderName = HeaderName::from_static("x-ratelimit-reset");
31
32#[derive(Clone, Copy, Debug)]
34pub struct RetryPolicy {
35 pub attempts: u32,
37 pub initial_delay: Duration,
40}
41
42impl Default for RetryPolicy {
43 fn default() -> Self {
44 Self {
45 attempts: 4,
46 initial_delay: Duration::from_millis(250),
47 }
48 }
49}
50
51#[derive(Clone, Debug)]
55pub struct Http {
56 client: reqwest::Client,
57 base: Url,
58 token: Token,
59 retry: RetryPolicy,
60 default_headers: HeaderMap,
61 page_limit: usize,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct RequestLabel(String);
67
68impl RequestLabel {
69 #[must_use]
71 pub fn new(method: &Method, path: &str) -> Self {
72 Self(format!("{method} {path}"))
73 }
74}
75
76impl fmt::Display for RequestLabel {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.write_str(&self.0)
79 }
80}
81
82#[derive(Debug, thiserror::Error)]
84#[error("cannot build the HTTP client")]
85pub struct TransportBuildError(#[from] reqwest::Error);
86
87#[derive(Debug, thiserror::Error)]
92pub enum TransportError {
93 #[error("{path} does not join onto the transport base")]
95 InvalidPath {
96 path: String,
98 #[source]
100 source: url::ParseError,
101 },
102 #[error("{request} failed")]
104 Unreachable {
105 request: RequestLabel,
107 #[source]
109 source: reqwest::Error,
110 },
111 #[error("{request} was rejected: the credential is missing or invalid")]
113 CredentialRejected {
114 request: RequestLabel,
116 },
117 #[error("{request} answered 404: the resource is not there")]
119 NotFound {
120 request: RequestLabel,
122 },
123 #[error("{request} was rate limited")]
126 RateLimited {
127 request: RequestLabel,
129 wait: Option<Duration>,
131 },
132 #[error("{request} was rate limited for {} s, which is too long to wait", wait.as_secs())]
134 RateLimitWaitTooLong {
135 request: RequestLabel,
137 wait: Duration,
139 },
140 #[error("{request} answered {status}: {}", summarize(body))]
142 Rejected {
143 request: RequestLabel,
145 status: u16,
147 body: String,
149 },
150 #[error("{request} answered {status}: {}", summarize(body))]
153 Unexpected {
154 request: RequestLabel,
156 status: u16,
158 body: String,
160 },
161 #[error("{request} did not end within {limit} pages")]
163 TooManyPages {
164 request: RequestLabel,
166 limit: usize,
168 },
169 #[error("{request} returned a body this build cannot read")]
171 Malformed {
172 request: RequestLabel,
174 #[source]
176 source: Box<facet_json::DeserializeError>,
177 },
178}
179
180impl TransportError {
181 #[must_use]
183 pub const fn request(&self) -> Option<&RequestLabel> {
184 match self {
185 Self::Unreachable { request, source: _ }
186 | Self::CredentialRejected { request }
187 | Self::NotFound { request }
188 | Self::RateLimited { request, wait: _ }
189 | Self::RateLimitWaitTooLong { request, wait: _ }
190 | Self::Rejected {
191 request,
192 status: _,
193 body: _,
194 }
195 | Self::Unexpected {
196 request,
197 status: _,
198 body: _,
199 }
200 | Self::TooManyPages { request, limit: _ }
201 | Self::Malformed { request, source: _ } => Some(request),
202 Self::InvalidPath { path: _, source: _ } => None,
203 }
204 }
205
206 #[must_use]
208 pub const fn retry_after(&self) -> Option<Duration> {
209 match self {
210 Self::RateLimited { request: _, wait } => *wait,
211 Self::InvalidPath { path: _, source: _ }
212 | Self::Unreachable {
213 request: _,
214 source: _,
215 }
216 | Self::CredentialRejected { request: _ }
217 | Self::NotFound { request: _ }
218 | Self::RateLimitWaitTooLong {
219 request: _,
220 wait: _,
221 }
222 | Self::Rejected {
223 request: _,
224 status: _,
225 body: _,
226 }
227 | Self::Unexpected {
228 request: _,
229 status: _,
230 body: _,
231 }
232 | Self::TooManyPages {
233 request: _,
234 limit: _,
235 }
236 | Self::Malformed {
237 request: _,
238 source: _,
239 } => None,
240 }
241 }
242
243 #[must_use]
247 pub const fn is_uncertain(&self) -> bool {
248 match self.status() {
249 Some(status) => status >= 500 && status <= 599,
250 None => matches!(
251 self,
252 Self::Unreachable {
253 request: _,
254 source: _
255 }
256 ),
257 }
258 }
259
260 #[must_use]
262 pub const fn status(&self) -> Option<u16> {
263 match self {
264 Self::NotFound { request: _ } => Some(404),
265 Self::Rejected {
266 status,
267 body: _,
268 request: _,
269 }
270 | Self::Unexpected {
271 status,
272 body: _,
273 request: _,
274 } => Some(*status),
275 Self::InvalidPath { path: _, source: _ }
276 | Self::Unreachable {
277 request: _,
278 source: _,
279 }
280 | Self::CredentialRejected { request: _ }
281 | Self::RateLimited {
282 request: _,
283 wait: _,
284 }
285 | Self::RateLimitWaitTooLong {
286 request: _,
287 wait: _,
288 }
289 | Self::TooManyPages {
290 request: _,
291 limit: _,
292 }
293 | Self::Malformed {
294 request: _,
295 source: _,
296 } => None,
297 }
298 }
299
300 fn from_status(
303 request: RequestLabel,
304 status: u16,
305 body: String,
306 wait: Option<Duration>,
307 ) -> Self {
308 match status {
309 401 => Self::CredentialRejected { request },
310 404 => Self::NotFound { request },
311 429 => Self::RateLimited { request, wait },
312 403 if wait.is_some() => Self::RateLimited { request, wait },
313 400 | 403 | 422 => Self::Rejected {
314 request,
315 status,
316 body,
317 },
318 _ => Self::Unexpected {
319 request,
320 status,
321 body,
322 },
323 }
324 }
325}
326
327impl Http {
328 pub fn new(
335 mut base: Url,
336 token: Token,
337 timeout: Duration,
338 ) -> Result<Self, TransportBuildError> {
339 if !base.path().ends_with('/') {
340 let path = format!("{}/", base.path());
341 base.set_path(&path);
342 }
343 let client = reqwest::Client::builder()
344 .user_agent(concat!("ephemeris-forge/", env!("CARGO_PKG_VERSION")))
345 .timeout(timeout)
346 .build()?;
347 Ok(Self {
348 client,
349 base,
350 token,
351 retry: RetryPolicy::default(),
352 default_headers: HeaderMap::new(),
353 page_limit: DEFAULT_PAGE_LIMIT,
354 })
355 }
356
357 #[must_use]
359 pub const fn with_retry(mut self, retry: RetryPolicy) -> Self {
360 self.retry = retry;
361 self
362 }
363
364 #[must_use]
366 pub const fn with_page_limit(mut self, page_limit: usize) -> Self {
367 self.page_limit = page_limit;
368 self
369 }
370
371 #[must_use]
374 pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
375 self.default_headers.insert(name, value);
376 self
377 }
378
379 fn join(&self, path: &str) -> Result<Url, TransportError> {
381 self.base
382 .join(path)
383 .map_err(|source| TransportError::InvalidPath {
384 path: path.to_owned(),
385 source,
386 })
387 }
388
389 pub async fn json_optional<T: for<'input> Facet<'input>>(
395 &self,
396 method: Method,
397 path: &str,
398 body: Option<&str>,
399 ) -> Result<Option<T>, TransportError> {
400 match self.json(method, path, body).await {
401 Ok(value) => Ok(Some(value)),
402 Err(TransportError::NotFound { request: _ }) => Ok(None),
403 Err(error) => Err(error),
404 }
405 }
406
407 pub async fn json<T: for<'input> Facet<'input>>(
413 &self,
414 method: Method,
415 path: &str,
416 body: Option<&str>,
417 ) -> Result<T, TransportError> {
418 let text = self.send(method.clone(), path, body).await?;
419 let source_text = if text.trim().is_empty() {
420 "null"
421 } else {
422 &text
423 };
424 facet_json::from_str(source_text).map_err(|source| TransportError::Malformed {
425 request: RequestLabel::new(&method, path),
426 source: Box::new(source),
427 })
428 }
429
430 pub async fn paged<T: for<'input> Facet<'input>, Q: Fn(usize) -> String>(
439 &self,
440 page_size: usize,
441 path: &str,
442 query_for_page: Q,
443 ) -> Result<Vec<T>, TransportError> {
444 self.paged_in(page_size, path, query_for_page, |values: Vec<T>| values)
445 .await
446 }
447
448 pub async fn paged_in<R, T, Q, E>(
462 &self,
463 page_size: usize,
464 path: &str,
465 query_for_page: Q,
466 items: E,
467 ) -> Result<Vec<T>, TransportError>
468 where
469 R: for<'input> Facet<'input>,
470 Q: Fn(usize) -> String,
471 E: Fn(R) -> Vec<T>,
472 {
473 let separator = if path.contains('?') { '&' } else { '?' };
474 let mut all = Vec::new();
475 for page in 1..=self.page_limit {
476 let page_path = format!("{path}{separator}{}", query_for_page(page));
477 let response: R = self.json(Method::GET, &page_path, None).await?;
478 let values = items(response);
479 let complete = values.len() < page_size;
480 all.extend(values);
481 if complete {
482 return Ok(all);
483 }
484 }
485 Err(TransportError::TooManyPages {
486 request: RequestLabel::new(
487 &Method::GET,
488 &format!("{path}{separator}{}", query_for_page(1)),
489 ),
490 limit: self.page_limit,
491 })
492 }
493
494 pub async fn bytes(
503 &self,
504 method: Method,
505 path: &str,
506 accept: Option<HeaderValue>,
507 ) -> Result<Vec<u8>, TransportError> {
508 let label = RequestLabel::new(&method, path);
509 let mut request = self.request(method, path)?;
510 if let Some(accept) = accept {
511 let mut headers = HeaderMap::new();
512 headers.insert(ACCEPT, accept);
513 request = request.headers(headers);
514 }
515 let response = Self::dispatch(request, &label).await?;
516 if response.status().is_success() {
517 response
518 .bytes()
519 .await
520 .map(|bytes| bytes.to_vec())
521 .map_err(|source| TransportError::Unreachable {
522 request: label,
523 source,
524 })
525 } else {
526 Err(Self::failure(label, response).await)
527 }
528 }
529
530 pub async fn send_multipart(
536 &self,
537 method: Method,
538 path: &str,
539 form: Form,
540 ) -> Result<String, TransportError> {
541 let label = RequestLabel::new(&method, path);
542 let response = Self::dispatch(self.request(method, path)?.multipart(form), &label).await?;
543 Self::text(label, response).await
544 }
545
546 pub async fn send_bytes(
553 &self,
554 method: Method,
555 path: &str,
556 content_type: &str,
557 bytes: Vec<u8>,
558 ) -> Result<String, TransportError> {
559 let label = RequestLabel::new(&method, path);
560 let request = self
561 .request(method, path)?
562 .header(CONTENT_TYPE, content_type)
563 .body(bytes);
564 let response = Self::dispatch(request, &label).await?;
565 Self::text(label, response).await
566 }
567
568 pub async fn send(
578 &self,
579 method: Method,
580 path: &str,
581 body: Option<&str>,
582 ) -> Result<String, TransportError> {
583 let mut attempt = 1;
584 let mut delay = self.retry.initial_delay;
585 loop {
586 match self.attempt(method.clone(), path, body).await {
587 Err(error) if retryable(&method, &error) && attempt < self.retry.attempts => {
588 let wait = retry_wait(&error, delay)?;
589 log_retry(&error, attempt);
590 time::sleep(wait).await;
591 delay = delay.saturating_mul(2);
592 attempt = attempt.saturating_add(1);
593 }
594 other => return other,
595 }
596 }
597 }
598
599 async fn attempt(
604 &self,
605 method: Method,
606 path: &str,
607 body: Option<&str>,
608 ) -> Result<String, TransportError> {
609 let label = RequestLabel::new(&method, path);
610 let started = Instant::now();
611
612 let mut request = self.request(method.clone(), path)?;
613 if let Some(body) = body {
614 request = request
615 .header(CONTENT_TYPE, "application/json")
616 .body(body.to_owned());
617 }
618
619 let response = Self::dispatch(request, &label).await?;
620
621 tracing::debug!(
622 method = %method,
623 path = %path,
624 status = response.status().as_u16(),
625 duration_ms = started.elapsed().as_millis(),
626 "request complete"
627 );
628
629 Self::text(label, response).await
630 }
631
632 fn request(&self, method: Method, path: &str) -> Result<RequestBuilder, TransportError> {
634 Ok(self
635 .client
636 .request(method, self.join(path)?)
637 .headers(self.default_headers.clone())
638 .bearer_auth(self.token.expose()))
639 }
640
641 async fn dispatch(
642 request: RequestBuilder,
643 label: &RequestLabel,
644 ) -> Result<Response, TransportError> {
645 request
646 .send()
647 .await
648 .map_err(|source| TransportError::Unreachable {
649 request: label.clone(),
650 source,
651 })
652 }
653
654 async fn text(label: RequestLabel, response: Response) -> Result<String, TransportError> {
656 if response.status().is_success() {
657 Ok(response.text().await.unwrap_or_default())
658 } else {
659 Err(Self::failure(label, response).await)
660 }
661 }
662
663 async fn failure(label: RequestLabel, response: Response) -> TransportError {
664 let status = response.status().as_u16();
665 let wait = rate_limit_wait(status, response.headers(), SystemTime::now());
666 let body = response.text().await.unwrap_or_default();
667 TransportError::from_status(label, status, body, wait)
668 }
669}
670
671fn retryable(method: &Method, error: &TransportError) -> bool {
675 matches!(
676 error,
677 TransportError::RateLimited {
678 request: _,
679 wait: _
680 }
681 ) || (method.is_idempotent() && error.is_uncertain())
682}
683
684fn retry_wait(error: &TransportError, backoff: Duration) -> Result<Duration, TransportError> {
687 match (error.retry_after(), error.request()) {
688 (Some(stated), Some(request)) if stated > MAX_RATE_LIMIT_WAIT => {
689 Err(TransportError::RateLimitWaitTooLong {
690 request: request.clone(),
691 wait: stated,
692 })
693 }
694 (Some(stated), _) => Ok(stated),
695 (None, _) => Ok(backoff),
696 }
697}
698
699fn rate_limit_wait(status: u16, headers: &HeaderMap, now: SystemTime) -> Option<Duration> {
705 if status != 403 && status != 429 {
706 return None;
707 }
708 let number = |name: &HeaderName| headers.get(name)?.to_str().ok()?.trim().parse::<u64>().ok();
709 if let Some(seconds) = number(&RETRY_AFTER) {
710 return Some(Duration::from_secs(seconds));
711 }
712 if status == 403 && number(&RATE_LIMIT_REMAINING) != Some(0) {
713 return None;
714 }
715 let reset = Duration::from_secs(number(&RATE_LIMIT_RESET)?);
716 Some(reset.saturating_sub(now.duration_since(UNIX_EPOCH).unwrap_or_default()))
717}
718
719fn log_retry(error: &TransportError, attempt: u32) {
721 let cause: &dyn StdError = error;
722 tracing::warn!(error = cause, attempt, "request failed, retrying");
723}
724
725#[must_use]
727pub fn summarize(body: &str) -> String {
728 const LIMIT: usize = 500;
729 let trimmed = body.trim();
730 if trimmed.is_empty() {
731 return "<empty body>".to_owned();
732 }
733 let mut out: String = trimmed.chars().take(LIMIT).collect();
734 if trimmed.chars().count() > LIMIT {
735 out.push_str("...");
736 }
737 out
738}
739
740#[cfg(test)]
741mod tests {
742 use std::time::{Duration, SystemTime};
743
744 use reqwest::Method;
745 use reqwest::header::{HeaderMap, HeaderValue};
746
747 use super::{RequestLabel, RetryPolicy, TransportError, rate_limit_wait};
748
749 fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap {
750 let mut headers = HeaderMap::new();
751 for (name, value) in pairs {
752 headers.insert(*name, HeaderValue::from_static(value));
753 }
754 headers
755 }
756
757 #[test]
758 fn statuses_map_to_their_documented_classes() {
759 assert!(matches!(
760 TransportError::from_status(
761 RequestLabel::new(&Method::GET, "/x"),
762 401,
763 String::new(),
764 None
765 ),
766 TransportError::CredentialRejected { request: _ }
767 ));
768 assert!(matches!(
769 TransportError::from_status(
770 RequestLabel::new(&Method::GET, "/x"),
771 404,
772 String::new(),
773 None
774 ),
775 TransportError::NotFound { request: _ }
776 ));
777 assert!(matches!(
778 TransportError::from_status(
779 RequestLabel::new(&Method::GET, "/x"),
780 429,
781 String::new(),
782 None
783 ),
784 TransportError::RateLimited {
785 request: _,
786 wait: _
787 }
788 ));
789 for status in [400, 403, 422] {
790 assert!(matches!(
791 TransportError::from_status(
792 RequestLabel::new(&Method::GET, "/x"),
793 status,
794 String::new(),
795 None
796 ),
797 TransportError::Rejected {
798 request: _,
799 status: _,
800 body: _
801 }
802 ));
803 }
804 for status in [409, 500, 503] {
805 assert!(matches!(
806 TransportError::from_status(
807 RequestLabel::new(&Method::GET, "/x"),
808 status,
809 String::new(),
810 None
811 ),
812 TransportError::Unexpected {
813 request: _,
814 status: _,
815 body: _
816 }
817 ));
818 }
819 }
820
821 #[test]
822 fn the_default_policy_matches_the_documented_contract() {
823 let policy = RetryPolicy::default();
824 assert_eq!(policy.attempts, 4);
825 assert_eq!(policy.initial_delay, Duration::from_millis(250));
826 }
827
828 #[test]
829 fn a_rate_limit_wait_comes_from_retry_after_or_the_reset_time() {
830 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
831 let wait = |status, pairs: &[(&'static str, &'static str)]| {
832 rate_limit_wait(status, &headers(pairs), now)
833 };
834 let seconds = |count| Some(Duration::from_secs(count));
835 assert_eq!(wait(429, &[("retry-after", "7")]), seconds(7));
836 assert_eq!(wait(403, &[("retry-after", "7")]), seconds(7));
837 assert_eq!(wait(429, &[("x-ratelimit-reset", "1030")]), seconds(30));
838 assert_eq!(
839 wait(
840 403,
841 &[
842 ("x-ratelimit-remaining", "0"),
843 ("x-ratelimit-reset", "1030")
844 ]
845 ),
846 seconds(30)
847 );
848 assert_eq!(
849 wait(
850 403,
851 &[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", "900")]
852 ),
853 seconds(0)
854 );
855 assert_eq!(
856 wait(403, &[("retry-after", "7"), ("x-ratelimit-reset", "1030")]),
857 seconds(7)
858 );
859 assert_eq!(wait(429, &[]), None);
860 }
861
862 #[test]
863 fn a_403_without_rate_limit_headers_is_a_permission_failure() {
864 let now = SystemTime::UNIX_EPOCH;
865 assert_eq!(rate_limit_wait(403, &headers(&[]), now), None);
866 assert_eq!(
867 rate_limit_wait(
868 403,
869 &headers(&[("x-ratelimit-remaining", "12"), ("x-ratelimit-reset", "30")]),
870 now
871 ),
872 None
873 );
874 assert_eq!(
875 rate_limit_wait(500, &headers(&[("retry-after", "7")]), now),
876 None
877 );
878 }
879
880 #[test]
881 fn a_long_body_is_trimmed() {
882 let body = "x".repeat(2000);
883 let err =
884 TransportError::from_status(RequestLabel::new(&Method::GET, "/x"), 500, body, None);
885 assert!(err.to_string().len() < 700, "{err}");
886 }
887}