alopex_cli/client/
http.rs1use 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 .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}