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