Skip to main content

velesdb_memory/
http_client.rs

1//! Authenticated JSON over HTTP — the transport layer under every remote
2//! inference backend.
3//!
4//! # What this module deliberately does NOT know
5//!
6//! Not which *role* is calling: nothing here mentions embedding or extraction,
7//! and the caller supplies its own [`ureq::Agent`] precisely because the two
8//! roles need different ceilings (an embedding answers in a moment, a
9//! generation can take minutes). A client that built its own agent would have
10//! to know which of the two it was serving.
11//!
12//! Not which *vendor* is answering either. Once the path, the body and the
13//! auth scheme all come from the caller, nothing OpenAI-specific is left —
14//! which is the honest reason this is not called an "OpenAI client". The
15//! OpenAI protocol lives one layer up, in [`crate::openai`]; Azure, Gemini or
16//! Anthropic would each get their own protocol module over this same
17//! transport.
18//!
19//! Not `velesdb-server`'s [`crate::http`] either, despite the neighbouring
20//! name: that module *serves* MCP over HTTP, this one *calls* a model.
21
22use crate::http_retry;
23
24/// How a request proves who it is.
25///
26/// An enum rather than an `Option<String>` bearer token, because the shape
27/// varies by provider and was already known to vary before the first
28/// non-OpenAI one landed: Azure `OpenAI` authenticates with `api-key`, a local
29/// server wants nothing at all, and a future provider will want something
30/// else again. Widening an `Option<String>` later would break every caller;
31/// adding a variant here does not.
32pub enum Auth {
33    /// Send no credential header at all.
34    ///
35    /// The default for a model running on the caller's own machine. "No
36    /// token" must mean **no header**, not an empty one: a server that
37    /// validates the header's *presence* rejects `Authorization: Bearer `
38    /// with an error that reads like a bad credential rather than a missing
39    /// one.
40    None,
41    /// `Authorization: Bearer <token>` — the `OpenAI` convention.
42    Bearer(String),
43    /// A verbatim `name: value` header, for a provider that authenticates
44    /// some other way. No bearer is added alongside it.
45    Header {
46        /// Header name (e.g. `api-key`).
47        name: String,
48        /// Header value. Treated as a secret.
49        value: String,
50    },
51}
52
53/// Hand-written so a credential never reaches a log or a panic message
54/// through the derive. The same reflex [`crate::ExtractorSelection`] applies
55/// to a backend's URL, applied to something that matters more.
56///
57/// The header NAME survives: it is not a secret, and printing it is what
58/// makes a provider misconfigured with the wrong scheme diagnosable at all.
59impl std::fmt::Debug for Auth {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::None => f.write_str("None"),
63            Self::Bearer(_) => f.write_str("Bearer(<redacted>)"),
64            Self::Header { name, .. } => {
65                write!(f, "Header {{ name: {name:?}, value: <redacted> }}")
66            }
67        }
68    }
69}
70
71/// Everything a failed call can say WITHOUT knowing what it was for.
72///
73/// The role-specific half of a good error message — which model was asked,
74/// which environment variables repoint it — belongs to the caller, which is
75/// why this carries only transport facts and leaves the rendering to the
76/// provider-appropriate renderer in [`http_retry`].
77pub(crate) struct HttpFailure {
78    /// Full URL that was called, for the message the caller renders.
79    pub url: String,
80    /// How many attempts were spent before giving up.
81    pub attempts: u32,
82    /// Why the last attempt failed.
83    pub cause: String,
84}
85
86/// A JSON-over-HTTP caller bound to one base URL and one credential.
87pub struct HttpJsonClient {
88    base_url: String,
89    auth: Auth,
90    agent: ureq::Agent,
91}
92
93/// Hand-written rather than derived: `ureq::Agent` is not `Debug`, and the
94/// credential must go through [`Auth`]'s own redacting impl rather than
95/// whatever a derive would have produced. The agent is omitted entirely —
96/// its timeouts belong to the caller that built it.
97impl std::fmt::Debug for HttpJsonClient {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("HttpJsonClient")
100            .field("base_url", &self.base_url)
101            .field("auth", &self.auth)
102            .finish_non_exhaustive()
103    }
104}
105
106impl HttpJsonClient {
107    /// Bind a client to `base_url`, authenticating with `auth`.
108    ///
109    /// `base_url` is kept verbatim and only concatenated with the caller's
110    /// path, so a non-standard port (`http://localhost:8020`) needs no special
111    /// handling — it is already part of the string.
112    ///
113    /// `agent` is the caller's, not this module's: see the module docs.
114    #[must_use]
115    pub fn new(base_url: impl Into<String>, auth: Auth, agent: ureq::Agent) -> Self {
116        Self {
117            base_url: base_url.into().trim_end_matches('/').to_owned(),
118            auth,
119            agent,
120        }
121    }
122
123    /// The URL `path` resolves to. Exposed for the caller's error messages,
124    /// which name the endpoint they failed against.
125    pub(crate) fn url_for(&self, path: &str) -> String {
126        format!("{}{path}", self.base_url)
127    }
128
129    /// POST `body` as JSON to `path`, retrying transient transport failures,
130    /// and return the raw response body.
131    ///
132    /// # Errors
133    /// [`HttpFailure`] carrying the URL, the attempt count and the cause —
134    /// never a rendered sentence, since only the caller knows the levers that
135    /// would change the outcome.
136    pub(crate) fn post_json(&self, path: &str, body: &str) -> Result<String, HttpFailure> {
137        let url = self.url_for(path);
138        let attempt = || {
139            let response = self
140                .authenticated(self.agent.post(&url))
141                .set("Content-Type", "application/json")
142                .send_string(body)
143                .map_err(|err| Call::Transport(Box::new(err)))?;
144            response.into_string().map_err(Call::Body)
145        };
146
147        http_retry::with_retry(&http_retry::HTTP_RETRIES, call_is_retryable, attempt).map_err(
148            |(err, attempts)| HttpFailure {
149                url,
150                attempts,
151                cause: match err {
152                    Call::Transport(inner) => inner.to_string(),
153                    Call::Body(inner) => format!("reading the response failed: {inner}"),
154                },
155            },
156        )
157    }
158
159    /// Apply the credential — or, for [`Auth::None`], apply nothing.
160    ///
161    /// The `None` arm returns the request UNTOUCHED. That is the whole point
162    /// of the variant and the thing the wire-level tests assert: not an empty
163    /// header, not a header with an empty value, no header.
164    fn authenticated(&self, request: ureq::Request) -> ureq::Request {
165        match &self.auth {
166            Auth::None => request,
167            Auth::Bearer(token) => request.set("Authorization", &format!("Bearer {token}")),
168            Auth::Header { name, value } => request.set(name, value),
169        }
170    }
171}
172
173/// How one attempt failed, kept apart just long enough to classify it —
174/// mirrors the shape both role-specific backends already use.
175enum Call {
176    /// The request never completed (reset, refusal, timeout, error status).
177    /// Boxed: `ureq::Error::Status` carries a whole `Response`.
178    Transport(Box<ureq::Error>),
179    /// The response arrived but its body could not be read.
180    Body(std::io::Error),
181}
182
183/// Replay a transport hiccup; never replay a body the server finished sending.
184fn call_is_retryable(err: &Call) -> bool {
185    match err {
186        Call::Transport(inner) => http_retry::is_retryable(inner),
187        Call::Body(inner) => http_retry::io_is_retryable(inner),
188    }
189}
190
191#[cfg(test)]
192#[path = "http_client_tests.rs"]
193mod tests;