vectorizer_sdk/
http_transport.rs1use std::time::Duration;
4
5use async_trait::async_trait;
6use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
7use reqwest::{Client, ClientBuilder};
8use serde_json::Value;
9
10use crate::error::{Result, VectorizerError};
11use crate::transport::{Protocol, Transport};
12
13const RETRY_AFTER_MAX_ATTEMPTS: u32 = 3;
16const RETRY_AFTER_MAX_SECS: u64 = 30;
20const RETRY_AFTER_DEFAULT_SECS: u64 = 1;
23
24pub struct HttpTransport {
26 client: Client,
27 base_url: String,
28}
29
30impl HttpTransport {
31 pub fn new(base_url: &str, api_key: Option<&str>, timeout_secs: u64) -> Result<Self> {
44 validate_base_url_scheme(base_url)?;
45
46 let mut headers = HeaderMap::new();
47 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
48
49 if let Some(key) = api_key {
50 let (header_name, header_value) = if looks_like_jwt(key) {
51 ("Authorization", format!("Bearer {key}"))
52 } else {
53 ("X-API-Key", key.to_string())
54 };
55 headers.insert(
56 header_name,
57 HeaderValue::from_str(&header_value).map_err(|e| {
58 VectorizerError::configuration(format!("Invalid auth credential: {e}"))
59 })?,
60 );
61 }
62
63 let client = ClientBuilder::new()
64 .timeout(std::time::Duration::from_secs(timeout_secs))
65 .default_headers(headers)
66 .build()
67 .map_err(|e| {
68 VectorizerError::configuration(format!("Failed to create HTTP client: {e}"))
69 })?;
70
71 Ok(Self {
72 client,
73 base_url: base_url.to_string(),
74 })
75 }
76}
77
78fn validate_base_url_scheme(base_url: &str) -> Result<()> {
100 let Some((scheme, _)) = base_url.split_once("://") else {
103 return Ok(());
104 };
105
106 if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") {
107 return Ok(());
108 }
109
110 if scheme.eq_ignore_ascii_case("vectorizer") {
111 return Err(VectorizerError::configuration(format!(
112 "VectorizerClient cannot dial RPC URL '{base_url}'; \
113 `vectorizer://` is the RPC transport — use \
114 `vectorizer_sdk::rpc::RpcClient::connect_url` instead, \
115 or pass an `http(s)://` URL"
116 )));
117 }
118
119 Err(VectorizerError::configuration(format!(
120 "unsupported base URL scheme `{scheme}://` in '{base_url}'; \
121 VectorizerClient speaks HTTP — pass an `http(s)://` URL"
122 )))
123}
124
125fn looks_like_jwt(token: &str) -> bool {
130 let mut parts = token.split('.');
131 let Some(header) = parts.next() else {
132 return false;
133 };
134 let Some(payload) = parts.next() else {
135 return false;
136 };
137 let Some(signature) = parts.next() else {
138 return false;
139 };
140 if parts.next().is_some() {
141 return false;
142 }
143 !header.is_empty() && !payload.is_empty() && !signature.is_empty()
144}
145
146impl HttpTransport {
147 async fn request(&self, method: &str, path: &str, body: Option<&Value>) -> Result<String> {
152 let url = format!("{}{}", self.base_url, path);
153 let mut attempts_remaining = RETRY_AFTER_MAX_ATTEMPTS;
154
155 loop {
156 let mut request = match method {
157 "GET" => self.client.get(&url),
158 "POST" => self.client.post(&url),
159 "PUT" => self.client.put(&url),
160 "DELETE" => self.client.delete(&url),
161 "PATCH" => self.client.patch(&url),
162 _ => {
163 return Err(VectorizerError::configuration(format!(
164 "Unsupported HTTP method: {method}"
165 )));
166 }
167 };
168
169 if let Some(data) = body {
170 request = request.json(data);
171 }
172
173 let response = request
174 .send()
175 .await
176 .map_err(|e| VectorizerError::network(format!("HTTP request failed: {e}")))?;
177
178 if response.status().as_u16() == 429 {
179 let retry_after = parse_retry_after_secs(
180 response
181 .headers()
182 .get(reqwest::header::RETRY_AFTER)
183 .and_then(|v| v.to_str().ok()),
184 );
185 let body_text = response
186 .text()
187 .await
188 .unwrap_or_else(|_| "Unknown error".to_string());
189
190 if attempts_remaining == 0 {
191 return Err(VectorizerError::rate_limit(format!(
192 "HTTP 429 after {RETRY_AFTER_MAX_ATTEMPTS} retries: {body_text}",
193 )));
194 }
195
196 tracing::info!(
197 "Vectorizer 429 — sleeping {retry_after:?} before retry \
198 (remaining attempts={attempts_remaining})",
199 );
200 attempts_remaining -= 1;
201 tokio::time::sleep(retry_after).await;
202 continue;
203 }
204
205 if !response.status().is_success() {
206 let status = response.status();
207 let error_text = response
208 .text()
209 .await
210 .unwrap_or_else(|_| "Unknown error".to_string());
211 return Err(VectorizerError::server(format!(
212 "HTTP {status}: {error_text}"
213 )));
214 }
215
216 return response
217 .text()
218 .await
219 .map_err(|e| VectorizerError::network(format!("Failed to read response: {e}")));
220 }
221 }
222}
223
224#[doc(hidden)]
230pub fn parse_retry_after_secs(value: Option<&str>) -> Duration {
231 let raw = match value {
232 Some(v) => v.trim(),
233 None => return Duration::from_secs(RETRY_AFTER_DEFAULT_SECS),
234 };
235 let secs = raw.parse::<u64>().unwrap_or(RETRY_AFTER_DEFAULT_SECS);
236 let secs = if secs == 0 {
237 RETRY_AFTER_DEFAULT_SECS
238 } else {
239 secs.min(RETRY_AFTER_MAX_SECS)
240 };
241 Duration::from_secs(secs)
242}
243
244#[async_trait]
245impl Transport for HttpTransport {
246 async fn get(&self, path: &str) -> Result<String> {
247 self.request("GET", path, None).await
248 }
249
250 async fn post(&self, path: &str, data: Option<&Value>) -> Result<String> {
251 self.request("POST", path, data).await
252 }
253
254 async fn put(&self, path: &str, data: Option<&Value>) -> Result<String> {
255 self.request("PUT", path, data).await
256 }
257
258 async fn delete(&self, path: &str) -> Result<String> {
259 self.request("DELETE", path, None).await
260 }
261
262 async fn patch(&self, path: &str, data: Option<&Value>) -> Result<String> {
263 self.request("PATCH", path, data).await
264 }
265
266 fn protocol(&self) -> Protocol {
267 Protocol::Http
268 }
269}
270
271impl HttpTransport {
272 pub async fn post_multipart(
274 &self,
275 path: &str,
276 file_bytes: Vec<u8>,
277 filename: &str,
278 form_fields: std::collections::HashMap<String, String>,
279 ) -> Result<String> {
280 let url = format!("{}{}", self.base_url, path);
281
282 let mut form = reqwest::multipart::Form::new();
284
285 let file_part = reqwest::multipart::Part::bytes(file_bytes).file_name(filename.to_string());
287 form = form.part("file", file_part);
288
289 for (key, value) in form_fields {
291 form = form.text(key, value);
292 }
293
294 let response = self
295 .client
296 .post(&url)
297 .multipart(form)
298 .send()
299 .await
300 .map_err(|e| VectorizerError::network(format!("File upload failed: {e}")))?;
301
302 if !response.status().is_success() {
303 let status = response.status();
304 let error_text = response
305 .text()
306 .await
307 .unwrap_or_else(|_| "Unknown error".to_string());
308 return Err(VectorizerError::server(format!(
309 "HTTP {status}: {error_text}"
310 )));
311 }
312
313 response
314 .text()
315 .await
316 .map_err(|e| VectorizerError::network(format!("Failed to read response: {e}")))
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 fn try_new(base_url: &str) -> std::result::Result<(), String> {
327 HttpTransport::new(base_url, None, 30)
328 .map(|_| ())
329 .map_err(|e| e.to_string())
330 }
331
332 #[test]
333 fn rpc_scheme_is_rejected_and_names_the_right_client() {
334 let err = try_new("vectorizer://127.0.0.1:15503")
335 .expect_err("the REST transport must not accept an RPC URL");
336
337 assert!(
341 err.contains("RpcClient"),
342 "the error must point at the RPC client: {err}"
343 );
344 assert!(
345 err.contains("vectorizer://"),
346 "the error must name the offending scheme: {err}"
347 );
348 assert!(
349 err.contains("127.0.0.1:15503"),
350 "the error must quote the URL passed in: {err}"
351 );
352 }
353
354 #[test]
355 fn rpc_scheme_is_rejected_case_insensitively() {
356 let err = try_new("VECTORIZER://host:15503").expect_err("uppercase scheme must be caught");
359 assert!(err.contains("RpcClient"), "{err}");
360 }
361
362 #[test]
363 fn other_schemes_are_rejected_generically() {
364 let err = try_new("umicp://host:15004").expect_err("only http(s) is dialable here");
365 assert!(
366 err.contains("umicp"),
367 "the message must name what was passed: {err}"
368 );
369 assert!(
370 !err.contains("RpcClient"),
371 "an unrelated scheme must not be misrouted to the RPC client: {err}"
372 );
373 assert!(
374 err.contains("http"),
375 "the message must say what is wanted: {err}"
376 );
377 }
378
379 #[test]
380 fn http_and_https_are_accepted() {
381 try_new("http://localhost:15002").expect("http is the normal case");
382 try_new("https://vectorizer.example.com").expect("https is the normal case");
383 try_new("HTTPS://vectorizer.example.com").expect("scheme case must not matter");
384 }
385
386 #[test]
387 fn scheme_less_base_urls_keep_working() {
388 try_new("localhost:15002").expect("scheme-less host:port must still build");
392 try_new("localhost").expect("bare host must still build");
393 }
394}