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