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;