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.
32#[non_exhaustive] // authentication schemes grow; matching externally requires a wildcard arm
33pub enum Auth {
34 /// Send no credential header at all.
35 ///
36 /// The default for a model running on the caller's own machine. "No
37 /// token" must mean **no header**, not an empty one: a server that
38 /// validates the header's *presence* rejects `Authorization: Bearer `
39 /// with an error that reads like a bad credential rather than a missing
40 /// one.
41 None,
42 /// `Authorization: Bearer <token>` — the `OpenAI` convention.
43 Bearer(String),
44 /// A verbatim `name: value` header, for a provider that authenticates
45 /// some other way. No bearer is added alongside it.
46 Header {
47 /// Header name (e.g. `api-key`).
48 name: String,
49 /// Header value. Treated as a secret.
50 value: String,
51 },
52}
53
54/// Hand-written so a credential never reaches a log or a panic message
55/// through the derive. The same reflex [`crate::ExtractorSelection`] applies
56/// to a backend's URL, applied to something that matters more.
57///
58/// The header NAME survives: it is not a secret, and printing it is what
59/// makes a provider misconfigured with the wrong scheme diagnosable at all.
60impl std::fmt::Debug for Auth {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::None => f.write_str("None"),
64 Self::Bearer(_) => f.write_str("Bearer(<redacted>)"),
65 Self::Header { name, .. } => {
66 write!(f, "Header {{ name: {name:?}, value: <redacted> }}")
67 }
68 }
69 }
70}
71
72/// Everything a failed call can say WITHOUT knowing what it was for.
73///
74/// The role-specific half of a good error message — which model was asked,
75/// which environment variables repoint it — belongs to the caller, which is
76/// why this carries only transport facts and leaves the rendering to the
77/// provider-appropriate renderer in [`http_retry`].
78pub(crate) struct HttpFailure {
79 /// Full URL that was called, for the message the caller renders.
80 pub url: String,
81 /// How many attempts were spent before giving up.
82 pub attempts: u32,
83 /// Why the last attempt failed.
84 pub cause: String,
85}
86
87/// A JSON-over-HTTP caller bound to one base URL and one credential.
88pub struct HttpJsonClient {
89 base_url: String,
90 auth: Auth,
91 agent: ureq::Agent,
92}
93
94/// Hand-written rather than derived: `ureq::Agent` is not `Debug`, and the
95/// credential must go through [`Auth`]'s own redacting impl rather than
96/// whatever a derive would have produced. The agent is omitted entirely —
97/// its timeouts belong to the caller that built it.
98impl std::fmt::Debug for HttpJsonClient {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.debug_struct("HttpJsonClient")
101 .field("base_url", &self.base_url)
102 .field("auth", &self.auth)
103 .finish_non_exhaustive()
104 }
105}
106
107impl HttpJsonClient {
108 /// Bind a client to `base_url`, authenticating with `auth`.
109 ///
110 /// `base_url` is kept verbatim and only concatenated with the caller's
111 /// path, so a non-standard port (`http://localhost:8020`) needs no special
112 /// handling — it is already part of the string.
113 ///
114 /// `agent` is the caller's, not this module's: see the module docs.
115 #[must_use]
116 pub fn new(base_url: impl Into<String>, auth: Auth, agent: ureq::Agent) -> Self {
117 Self {
118 base_url: base_url.into().trim_end_matches('/').to_owned(),
119 auth,
120 agent,
121 }
122 }
123
124 /// The URL `path` resolves to. Exposed for the caller's error messages,
125 /// which name the endpoint they failed against.
126 pub(crate) fn url_for(&self, path: &str) -> String {
127 format!("{}{path}", self.base_url)
128 }
129
130 /// POST `body` as JSON to `path`, retrying transient transport failures,
131 /// and return the raw response body.
132 ///
133 /// # Errors
134 /// [`HttpFailure`] carrying the URL, the attempt count and the cause —
135 /// never a rendered sentence, since only the caller knows the levers that
136 /// would change the outcome.
137 pub(crate) fn post_json(&self, path: &str, body: &str) -> Result<String, HttpFailure> {
138 let url = self.url_for(path);
139 let attempt = || {
140 let response = self
141 .authenticated(self.agent.post(&url))
142 .set("Content-Type", "application/json")
143 .send_string(body)
144 .map_err(|err| Call::Transport(Box::new(err)))?;
145 response.into_string().map_err(Call::Body)
146 };
147
148 http_retry::with_retry(&http_retry::HTTP_RETRIES, call_is_retryable, attempt).map_err(
149 |(err, attempts)| HttpFailure {
150 url,
151 attempts,
152 cause: match err {
153 Call::Transport(inner) => inner.to_string(),
154 Call::Body(inner) => format!("reading the response failed: {inner}"),
155 },
156 },
157 )
158 }
159
160 /// Apply the credential — or, for [`Auth::None`], apply nothing.
161 ///
162 /// The `None` arm returns the request UNTOUCHED. That is the whole point
163 /// of the variant and the thing the wire-level tests assert: not an empty
164 /// header, not a header with an empty value, no header.
165 fn authenticated(&self, request: ureq::Request) -> ureq::Request {
166 match &self.auth {
167 Auth::None => request,
168 Auth::Bearer(token) => request.set("Authorization", &format!("Bearer {token}")),
169 Auth::Header { name, value } => request.set(name, value),
170 }
171 }
172}
173
174/// How one attempt failed, kept apart just long enough to classify it —
175/// mirrors the shape both role-specific backends already use.
176enum Call {
177 /// The request never completed (reset, refusal, timeout, error status).
178 /// Boxed: `ureq::Error::Status` carries a whole `Response`.
179 Transport(Box<ureq::Error>),
180 /// The response arrived but its body could not be read.
181 Body(std::io::Error),
182}
183
184/// Replay a transport hiccup; never replay a body the server finished sending.
185fn call_is_retryable(err: &Call) -> bool {
186 match err {
187 Call::Transport(inner) => http_retry::is_retryable(inner),
188 Call::Body(inner) => http_retry::io_is_retryable(inner),
189 }
190}
191
192/// The three timeout ceilings a remote-inference agent is built from.
193///
194/// A budget, not an agent: the *values* are role knowledge (an embedding that
195/// has not answered in a minute is not going to; a generation legitimately
196/// takes hundreds of seconds), so they stay declared next to the role. What
197/// lives here is the *shape* — which knobs exist and what they actually do —
198/// because it was declared four times over three files, each copy one edit
199/// away from drifting from the others.
200#[derive(Debug, Clone, Copy)]
201pub struct AgentBudget {
202 /// Ceiling on establishing the TCP connection. The one knob that
203 /// genuinely changes behavior over `ureq`'s defaults: its own connect
204 /// default is 30 s (`agent.rs`) — sane for the open internet, absurd for
205 /// a daemon on `localhost`, which either accepts immediately or is not
206 /// running. With replays, that idle wait would be paid three times over.
207 pub connect: std::time::Duration,
208 /// Ceiling on writing the request. Applied to the socket at connect time,
209 /// so — unlike the read deadline — it is in force independently of the
210 /// overall budget.
211 pub write: std::time::Duration,
212 /// Whole-request deadline, connect included.
213 pub overall: std::time::Duration,
214}
215
216impl AgentBudget {
217 /// Budget for a model daemon expected on `localhost`: 2 s to connect,
218 /// 10 s to write, `overall` to answer. The connect and write figures are
219 /// shared by the embedding and extraction roles on purpose — they bound
220 /// the *transport* to a local daemon, which is the same transport for
221 /// both; only `overall` is role knowledge.
222 #[must_use]
223 pub const fn local_daemon(overall: std::time::Duration) -> Self {
224 Self {
225 connect: std::time::Duration::from_secs(2),
226 write: std::time::Duration::from_secs(10),
227 overall,
228 }
229 }
230
231 /// One figure for every ceiling — for probes whose caller states a single
232 /// whole budget and means it.
233 #[must_use]
234 pub const fn uniform(budget: std::time::Duration) -> Self {
235 Self {
236 connect: budget,
237 write: budget,
238 overall: budget,
239 }
240 }
241}
242
243/// Build the agent a budget describes.
244///
245/// # Precedence, stated plainly (the authoritative copy)
246///
247/// `ureq` documents that `.timeout()` "takes precedence over `.timeout_read()`
248/// and `.timeout_write()`, but not `.timeout_connect()`", and its
249/// `DeadlineStream` rewrites the socket read deadline to the remaining global
250/// budget before every read. So the `.timeout_read()` set here is
251/// **subordinate**: declared for the day the global bound is lifted, and not
252/// to be read as a per-read ceiling today. `.timeout_connect()` and
253/// `.timeout_write()` are the two that bite alongside the global deadline.
254/// Saying otherwise in a doc — or writing a test that claimed to prove a
255/// per-read bound — would be a reassurance with nothing behind it.
256#[must_use]
257pub fn bounded_agent(budget: AgentBudget) -> ureq::Agent {
258 ureq::AgentBuilder::new()
259 .timeout_connect(budget.connect)
260 .timeout_write(budget.write)
261 .timeout_read(budget.overall)
262 .timeout(budget.overall)
263 .build()
264}
265
266#[cfg(test)]
267#[path = "http_client_tests.rs"]
268mod tests;