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 .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}