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            .pool_idle_timeout(Duration::from_secs(90))
70            .pool_max_idle_per_host(8);
71        let builder = auth.apply_to_builder(builder)?;
72        let client = builder
73            .build()
74            .map_err(|err| ClientError::Build(err.to_string()))?;
75
76        Ok(Self {
77            base_url,
78            auth,
79            client,
80            retry_policy: RetryPolicy::default(),
81        })
82    }
83
84    #[allow(dead_code)]
85    pub fn new_with_client(config: &ServerConfig, client: Client) -> ClientResult<Self> {
86        let base_url =
87            Url::parse(&config.url).map_err(|err| ClientError::InvalidUrl(err.to_string()))?;
88        validate_base_url(&base_url, config.insecure)?;
89        let auth = AuthConfig::from_server_config(config)?;
90
91        Ok(Self {
92            base_url,
93            auth,
94            client,
95            retry_policy: RetryPolicy::default(),
96        })
97    }
98
99    fn request(&self, method: Method, path: &str) -> ClientResult<RequestBuilder> {
100        let url = self
101            .base_url
102            .join(path)
103            .map_err(|err| ClientError::InvalidUrl(err.to_string()))?;
104        let request = self.client.request(method, url);
105        Ok(self.auth.apply_to_request(request)?)
106    }
107
108    async fn send_with_retry<F>(&self, mut build: F) -> ClientResult<Response>
109    where
110        F: FnMut() -> ClientResult<RequestBuilder>,
111    {
112        let mut last_err: Option<reqwest::Error> = None;
113        for (attempt, delay) in self.retry_policy.delays.iter().enumerate() {
114            match build()?.send().await {
115                Ok(response) => return Ok(response),
116                Err(err) => {
117                    last_err = Some(err);
118                    sleep(*delay).await;
119                    tracing::warn!(
120                        attempt = attempt + 1,
121                        "HTTP request failed, retrying after {:?}",
122                        delay
123                    );
124                }
125            }
126        }
127        match build()?.send().await {
128            Ok(response) => Ok(response),
129            Err(err) => Err(ClientError::Request {
130                retries: self.retry_policy.attempts(),
131                source: last_err.unwrap_or(err),
132            }),
133        }
134    }
135
136    async fn send_and_check<F>(&self, build: F) -> ClientResult<Response>
137    where
138        F: FnMut() -> ClientResult<RequestBuilder>,
139    {
140        let response = self.send_with_retry(build).await?;
141        if response.status().is_success() {
142            Ok(response)
143        } else {
144            let status = response.status();
145            let body = response.text().await.unwrap_or_default();
146            Err(ClientError::HttpStatus { status, body })
147        }
148    }
149
150    #[allow(dead_code)]
151    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> ClientResult<T> {
152        let response = self
153            .send_and_check(|| self.request(Method::GET, path))
154            .await?;
155        response
156            .json::<T>()
157            .await
158            .map_err(|err| ClientError::Request {
159                retries: 0,
160                source: err,
161            })
162    }
163
164    #[allow(dead_code)]
165    pub async fn get_text(&self, path: &str) -> ClientResult<String> {
166        let response = self
167            .send_and_check(|| self.request(Method::GET, path))
168            .await?;
169        response.text().await.map_err(|err| ClientError::Request {
170            retries: 0,
171            source: err,
172        })
173    }
174
175    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
176        &self,
177        path: &str,
178        body: &B,
179    ) -> ClientResult<T> {
180        let response = self
181            .send_and_check(|| self.request(Method::POST, path).map(|req| req.json(body)))
182            .await?;
183        response
184            .json::<T>()
185            .await
186            .map_err(|err| ClientError::Request {
187                retries: 0,
188                source: err,
189            })
190    }
191
192    pub async fn post_json_stream<B: Serialize>(
193        &self,
194        path: &str,
195        body: &B,
196    ) -> ClientResult<Response> {
197        self.send_and_check(|| self.request(Method::POST, path).map(|req| req.json(body)))
198            .await
199    }
200}
201
202fn validate_base_url(base_url: &Url, insecure: bool) -> ClientResult<()> {
203    match base_url.scheme() {
204        "https" => Ok(()),
205        "http" => {
206            if insecure {
207                eprintln!("Warning: using insecure HTTP connection to {}", base_url);
208                Ok(())
209            } else {
210                Err(ClientError::InvalidUrl(
211                    "server url must use https scheme (use --insecure to allow http)".to_string(),
212                ))
213            }
214        }
215        _ => Err(ClientError::InvalidUrl(
216            "server url must use http or https scheme".to_string(),
217        )),
218    }
219}