Skip to main content

alopex_cli/client/
http.rs

1use std::time::Duration;
2
3use reqwest::{Client, Method, RequestBuilder, Response, Url};
4use serde::de::DeserializeOwned;
5use serde::Serialize;
6use tokio::time::sleep;
7
8use crate::client::auth::{AuthConfig, AuthError};
9use crate::profile::config::ServerConfig;
10
11#[derive(thiserror::Error, Debug)]
12pub enum ClientError {
13    #[error("invalid server url: {0}")]
14    InvalidUrl(String),
15    #[error("failed to build HTTP client: {0}")]
16    Build(String),
17    #[error("authentication error: {0}")]
18    Auth(#[from] AuthError),
19    #[error("request failed after {retries} retries: {source}")]
20    Request {
21        retries: usize,
22        #[source]
23        source: reqwest::Error,
24    },
25    #[error("unexpected response status {status}: {body}")]
26    HttpStatus {
27        status: reqwest::StatusCode,
28        body: String,
29    },
30}
31
32pub type ClientResult<T> = Result<T, ClientError>;
33
34#[derive(Debug, Clone)]
35struct RetryPolicy {
36    delays: Vec<Duration>,
37}
38
39impl RetryPolicy {
40    fn default() -> Self {
41        Self {
42            delays: vec![
43                Duration::from_secs(1),
44                Duration::from_secs(2),
45                Duration::from_secs(4),
46            ],
47        }
48    }
49
50    fn attempts(&self) -> usize {
51        self.delays.len() + 1
52    }
53}
54
55pub struct HttpClient {
56    base_url: Url,
57    auth: AuthConfig,
58    client: Client,
59    retry_policy: RetryPolicy,
60}
61
62impl HttpClient {
63    pub fn new(config: &ServerConfig) -> ClientResult<Self> {
64        let base_url =
65            Url::parse(&config.url).map_err(|err| ClientError::InvalidUrl(err.to_string()))?;
66        validate_base_url(&base_url, config.insecure)?;
67        let auth = AuthConfig::from_server_config(config)?;
68        let builder = Client::builder()
69            // Explicit even though `default-tls` (native-tls/openssl) is not
70            // compiled in (see reqwest dependency in Cargo.toml): documents
71            // that this client is rustls-backed and protects against
72            // accidentally picking up `default-tls` again via feature
73            // unification if another crate in the workspace enables it.
74            .use_rustls_tls()
75            .pool_idle_timeout(Duration::from_secs(90))
76            .pool_max_idle_per_host(8);
77        let builder = auth.apply_to_builder(builder)?;
78        let client = builder
79            .build()
80            .map_err(|err| ClientError::Build(err.to_string()))?;
81
82        Ok(Self {
83            base_url,
84            auth,
85            client,
86            retry_policy: RetryPolicy::default(),
87        })
88    }
89
90    #[allow(dead_code)]
91    pub fn new_with_client(config: &ServerConfig, client: Client) -> ClientResult<Self> {
92        let base_url =
93            Url::parse(&config.url).map_err(|err| ClientError::InvalidUrl(err.to_string()))?;
94        validate_base_url(&base_url, config.insecure)?;
95        let auth = AuthConfig::from_server_config(config)?;
96
97        Ok(Self {
98            base_url,
99            auth,
100            client,
101            retry_policy: RetryPolicy::default(),
102        })
103    }
104
105    fn request(&self, method: Method, path: &str) -> ClientResult<RequestBuilder> {
106        let url = self
107            .base_url
108            .join(path)
109            .map_err(|err| ClientError::InvalidUrl(err.to_string()))?;
110        let request = self.client.request(method, url);
111        Ok(self.auth.apply_to_request(request)?)
112    }
113
114    async fn send_with_retry<F>(&self, mut build: F) -> ClientResult<Response>
115    where
116        F: FnMut() -> ClientResult<RequestBuilder>,
117    {
118        let mut last_err: Option<reqwest::Error> = None;
119        for (attempt, delay) in self.retry_policy.delays.iter().enumerate() {
120            match build()?.send().await {
121                Ok(response) => return Ok(response),
122                Err(err) => {
123                    last_err = Some(err);
124                    sleep(*delay).await;
125                    tracing::warn!(
126                        attempt = attempt + 1,
127                        "HTTP request failed, retrying after {:?}",
128                        delay
129                    );
130                }
131            }
132        }
133        match build()?.send().await {
134            Ok(response) => Ok(response),
135            Err(err) => Err(ClientError::Request {
136                retries: self.retry_policy.attempts(),
137                source: last_err.unwrap_or(err),
138            }),
139        }
140    }
141
142    async fn send_and_check<F>(&self, build: F) -> ClientResult<Response>
143    where
144        F: FnMut() -> ClientResult<RequestBuilder>,
145    {
146        let response = self.send_with_retry(build).await?;
147        if response.status().is_success() {
148            Ok(response)
149        } else {
150            let status = response.status();
151            let body = response.text().await.unwrap_or_default();
152            Err(ClientError::HttpStatus { status, body })
153        }
154    }
155
156    #[allow(dead_code)]
157    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> ClientResult<T> {
158        let response = self
159            .send_and_check(|| self.request(Method::GET, path))
160            .await?;
161        response
162            .json::<T>()
163            .await
164            .map_err(|err| ClientError::Request {
165                retries: 0,
166                source: err,
167            })
168    }
169
170    #[allow(dead_code)]
171    pub async fn get_text(&self, path: &str) -> ClientResult<String> {
172        let response = self
173            .send_and_check(|| self.request(Method::GET, path))
174            .await?;
175        response.text().await.map_err(|err| ClientError::Request {
176            retries: 0,
177            source: err,
178        })
179    }
180
181    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
182        &self,
183        path: &str,
184        body: &B,
185    ) -> ClientResult<T> {
186        let response = self
187            .send_and_check(|| self.request(Method::POST, path).map(|req| req.json(body)))
188            .await?;
189        response
190            .json::<T>()
191            .await
192            .map_err(|err| ClientError::Request {
193                retries: 0,
194                source: err,
195            })
196    }
197
198    pub async fn post_json_stream<B: Serialize>(
199        &self,
200        path: &str,
201        body: &B,
202    ) -> ClientResult<Response> {
203        self.send_and_check(|| self.request(Method::POST, path).map(|req| req.json(body)))
204            .await
205    }
206}
207
208fn validate_base_url(base_url: &Url, insecure: bool) -> ClientResult<()> {
209    match base_url.scheme() {
210        "https" => Ok(()),
211        "http" => {
212            if insecure {
213                eprintln!("Warning: using insecure HTTP connection to {}", base_url);
214                Ok(())
215            } else {
216                Err(ClientError::InvalidUrl(
217                    "server url must use https scheme (use --insecure to allow http)".to_string(),
218                ))
219            }
220        }
221        _ => Err(ClientError::InvalidUrl(
222            "server url must use http or https scheme".to_string(),
223        )),
224    }
225}