Skip to main content

vectorizer_sdk/
http_transport.rs

1//! HTTP transport implementation using reqwest
2
3use 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
13/// Maximum number of times an HTTP 429 will be retried before the
14/// error is surfaced to the caller (issue #263).
15const RETRY_AFTER_MAX_ATTEMPTS: u32 = 3;
16/// Cap on the `Retry-After` header value the client is willing to
17/// honor. A misconfigured server can't pin the client into a long
18/// sleep beyond this.
19const RETRY_AFTER_MAX_SECS: u64 = 30;
20/// Floor on the parsed `Retry-After` value when the header is missing
21/// or zero, so we don't busy-loop the server.
22const RETRY_AFTER_DEFAULT_SECS: u64 = 1;
23
24/// HTTP transport client
25pub struct HttpTransport {
26    client: Client,
27    base_url: String,
28}
29
30impl HttpTransport {
31    /// Create a new HTTP transport.
32    ///
33    /// The `api_key` argument carries either a raw Vectorizer API key
34    /// (created via `POST /auth/keys`) or a JWT minted by `POST /auth/login`.
35    /// The transport sniffs the shape — three dot-separated base64url
36    /// segments → JWT, sent as `Authorization: Bearer <token>`; otherwise
37    /// sent as `X-API-Key: <key>`. The server's auth middleware treats
38    /// Bearer-wrapped strings as JWTs and never falls back to the API-key
39    /// validator, so sending a raw API key under `Authorization: Bearer`
40    /// silently 401s. This sniff keeps the public method signature
41    /// unchanged while routing each credential down the path the server
42    /// actually accepts.
43    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
78/// Reject a base URL this transport cannot dial, at construction.
79///
80/// Without this, a `vectorizer://` URL builds a client fine and fails at the
81/// first request, deep inside reqwest:
82///
83/// ```text
84/// Network error: HTTP request failed:
85/// builder error for url (vectorizer://127.0.0.1:15503/auth/login)
86/// ```
87///
88/// which names neither the scheme nor the client that would have worked. The
89/// RPC side already handles the mirror-image mistake well — `connect_url` on
90/// an `http://` URL points the caller at `VectorizerClient` — so this closes
91/// the asymmetry (issue #392).
92///
93/// Deliberately hand-rolled rather than delegating to
94/// `rpc::endpoint::parse_endpoint`: that parser maps a scheme-less
95/// `host:port` to *RPC*, and this transport accepts scheme-less base URLs and
96/// hands them to reqwest. Routing them through it would reject `localhost:15002`,
97/// a form that works today. The only question here is whether reqwest can
98/// dial the scheme.
99fn validate_base_url_scheme(base_url: &str) -> Result<()> {
100    // No `://` means no scheme — `localhost:15002` and bare hosts are
101    // accepted, unchanged.
102    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
125/// Cheap JWT shape sniff. A JWT is three base64url-encoded segments
126/// separated by `.`; every segment must be non-empty. Raw API keys
127/// generated by `POST /auth/keys` are a single 32-char alphanumeric
128/// string, so they fail this check and get routed to `X-API-Key`.
129fn 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    /// Make a generic request. Honors `Retry-After` on HTTP 429
148    /// responses (issue #263): the client sleeps for the header's
149    /// value (capped) and retries up to [`RETRY_AFTER_MAX_ATTEMPTS`]
150    /// times before surfacing a `RateLimit` error.
151    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/// Parse a `Retry-After` header value (seconds form only). Returns a
225/// sensible default when missing/unparseable; caps the value so a
226/// misconfigured server can't pin the client into a long sleep.
227///
228/// Public for test consumption only; not part of the stable SDK API.
229#[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    /// Upload a file using multipart/form-data (not part of Transport trait)
273    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        // Create multipart form
283        let mut form = reqwest::multipart::Form::new();
284
285        // Add file
286        let file_part = reqwest::multipart::Part::bytes(file_bytes).file_name(filename.to_string());
287        form = form.part("file", file_part);
288
289        // Add other form fields
290        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    /// Build a transport the way `VectorizerClient` does, and report the
325    /// error message on failure.
326    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        // The whole point of the fix: the message has to name the scheme AND
338        // the client that would have worked. The old failure was reqwest's
339        // "builder error for url (...)", which named neither.
340        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        // Schemes are case-insensitive per RFC 3986; a caller shouting the
357        // scheme deserves the same guidance.
358        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        // Callers pass these today and reqwest handles them. This is also why
389        // the guard does not delegate to `rpc::endpoint::parse_endpoint`,
390        // which would classify them as RPC endpoints and reject them.
391        try_new("localhost:15002").expect("scheme-less host:port must still build");
392        try_new("localhost").expect("bare host must still build");
393    }
394}