Skip to main content

ephemeris_release/adapters/
registry.rs

1//! HTTP requests shared by the package registries.
2
3use std::error::Error as StdError;
4use std::time::Duration;
5
6use ephemeris_token::Token;
7use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderValue, InvalidHeaderValue};
8use reqwest::{Client, StatusCode, Url};
9use tokio::time;
10
11const TIMEOUT: Duration = Duration::from_secs(30);
12const ATTEMPTS: u32 = 4;
13const INITIAL_DELAY: Duration = Duration::from_millis(250);
14
15/// A client for requests against one registry.
16///
17/// A GET request that fails to connect, answers 429, or answers a server error
18/// is retried up to four attempts with a doubling delay. The authorization
19/// value is marked sensitive.
20#[derive(Debug)]
21pub(crate) struct HttpRegistry {
22    client: Client,
23    authorization: Option<HeaderValue>,
24}
25
26/// A registry lookup could not run or could not be answered.
27#[derive(Debug, thiserror::Error)]
28pub enum RegistryError {
29    /// The HTTP client could not be built.
30    #[error("cannot build the registry HTTP client")]
31    Client(#[source] reqwest::Error),
32    /// The token cannot be carried in an HTTP header.
33    #[error("the registry token cannot be sent as a header value")]
34    InvalidToken(#[source] InvalidHeaderValue),
35    /// The registry URL cannot address this request path.
36    #[error("registry URL cannot address {path:?}")]
37    InvalidPath {
38        /// The request path that would not join.
39        path: String,
40        /// The URL parse failure.
41        #[source]
42        source: url::ParseError,
43    },
44    /// The request failed before an answer came back.
45    #[error("registry request failed")]
46    Request(#[source] reqwest::Error),
47    /// The response body is not the document the lookup expects.
48    #[error("registry answered with an unreadable body")]
49    Body(#[source] facet_json::DeserializeError),
50    /// The registry answered with an unexpected status.
51    #[error("registry answered {status}: {body}")]
52    Response {
53        /// The HTTP status.
54        status: u16,
55        /// The response body.
56        body: String,
57    },
58}
59
60impl HttpRegistry {
61    /// A client that sends `token` as `Authorization: <scheme> <value>`, or
62    /// no authorization without a token.
63    pub(crate) fn new(scheme: &str, token: Option<&Token>) -> Result<Self, RegistryError> {
64        Self::build(token.map(|token| format!("{scheme} {}", token.expose().trim())))
65    }
66
67    /// A client that sends `token` as the whole `Authorization` value.
68    pub(crate) fn bare(token: &Token) -> Result<Self, RegistryError> {
69        Self::build(Some(token.expose().trim().to_owned()))
70    }
71
72    fn build(authorization: Option<String>) -> Result<Self, RegistryError> {
73        let authorization = authorization
74            .map(|text| {
75                let mut value =
76                    HeaderValue::from_str(&text).map_err(RegistryError::InvalidToken)?;
77                value.set_sensitive(true);
78                Ok(value)
79            })
80            .transpose()?;
81        let client = Client::builder()
82            .user_agent(concat!("ephemeris-release/", env!("CARGO_PKG_VERSION")))
83            .timeout(TIMEOUT)
84            .build()
85            .map_err(RegistryError::Client)?;
86        Ok(Self {
87            client,
88            authorization,
89        })
90    }
91
92    /// The response body at `url`, or `None` when the registry answers 404.
93    pub(crate) async fn get(&self, url: &Url) -> Result<Option<String>, RegistryError> {
94        self.lookup(url).await
95    }
96
97    /// Send `body` to `url` once and return the response body.
98    ///
99    /// An upload is not retried, because the caller checks the registry
100    /// afterwards.
101    pub(crate) async fn put(&self, url: &Url, body: Vec<u8>) -> Result<String, RegistryError> {
102        let mut request = self
103            .client
104            .put(url.clone())
105            .header(CONTENT_TYPE, "application/octet-stream")
106            .body(body);
107        if let Some(authorization) = &self.authorization {
108            request = request.header(AUTHORIZATION, authorization.clone());
109        }
110        let response = request.send().await.map_err(RegistryError::Request)?;
111        let status = response.status();
112        let answer = response.text().await.map_err(RegistryError::Request)?;
113        if status.is_success() {
114            Ok(answer)
115        } else {
116            Err(RegistryError::Response {
117                status: status.as_u16(),
118                body: answer,
119            })
120        }
121    }
122
123    async fn lookup(&self, url: &Url) -> Result<Option<String>, RegistryError> {
124        let mut attempt = 1;
125        let mut delay = INITIAL_DELAY;
126        loop {
127            match self.attempt(url).await {
128                Err(error) if error.retryable() && attempt < ATTEMPTS => {
129                    error.log_retry(attempt);
130                    time::sleep(delay).await;
131                    delay = delay.saturating_mul(2);
132                    attempt = attempt.saturating_add(1);
133                }
134                other => return other,
135            }
136        }
137    }
138
139    async fn attempt(&self, url: &Url) -> Result<Option<String>, RegistryError> {
140        let mut request = self.client.get(url.clone());
141        if let Some(authorization) = &self.authorization {
142            request = request.header(AUTHORIZATION, authorization.clone());
143        }
144        let response = request.send().await.map_err(RegistryError::Request)?;
145        let status = response.status();
146        if status == StatusCode::NOT_FOUND {
147            return Ok(None);
148        }
149        let body = response.text().await.map_err(RegistryError::Request)?;
150        if status.is_success() {
151            Ok(Some(body))
152        } else {
153            Err(RegistryError::Response {
154                status: status.as_u16(),
155                body,
156            })
157        }
158    }
159}
160
161impl RegistryError {
162    fn log_retry(&self, attempt: u32) {
163        let cause: &dyn StdError = self;
164        tracing::warn!(error = cause, attempt, "registry lookup failed, retrying");
165    }
166
167    const fn retryable(&self) -> bool {
168        match self {
169            Self::Request(_) => true,
170            Self::Response { status, body: _ } => *status == 429 || *status >= 500,
171            Self::Client(_)
172            | Self::InvalidToken(_)
173            | Self::Body(_)
174            | Self::InvalidPath { path: _, source: _ } => false,
175        }
176    }
177}