Skip to main content

typesafe_jev/
client.rs

1use std::fmt;
2use std::time::Duration;
3
4use serde::Serialize;
5
6use crate::limiter::{jitter, AdaptiveLimiter};
7use crate::transport::{Http, Transport};
8use crate::{Error, Questions, Response, Usage, API_KEY_VAR, DEFAULT_BASE_URL, DEFAULT_MODEL};
9
10/// Statuses worth another attempt: timeouts, conflicts, throttling and server-side trouble.
11const RETRYABLE: &[u16] = &[408, 409, 425, 429, 500, 502, 503, 504, 529];
12
13/// How a [`Client`] reaches the API and how hard it tries.
14///
15/// Build one with `Config { field: value, ..Config::default() }`. Every field has a default that
16/// suits TypeSafe's hosted endpoint.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Config {
19    /// The endpoint every request is POSTed to. [`DEFAULT_BASE_URL`], or a gateway in front of
20    /// it. The URL is used as given: a token is sent to whatever host it names.
21    pub base_url: String,
22    /// The model name sent in every request. [`DEFAULT_MODEL`] follows TypeSafe's latest Jev.
23    pub model: String,
24    /// Who serves the endpoint, for error messages only: `TypeSafe`, or the name of a gateway.
25    pub provider: String,
26    /// The `User-Agent` header. Defaults to this crate's name and version.
27    pub user_agent: String,
28    /// Time allowed to open a connection.
29    pub connect_timeout: Duration,
30    /// Time allowed for each of sending the request, waiting for the response and reading it.
31    pub timeout: Duration,
32    /// Attempts after the first for transient failures; see [`Error`] for what is transient.
33    pub max_retries: u32,
34    /// The longest a retry's exponential backoff grows to. The backoff starts at half a second
35    /// and doubles per attempt, up to this cap, times a random factor in [0.5, 1.5).
36    pub max_backoff: Duration,
37    /// Connections kept open, and the ceiling of the concurrency [`AdaptiveLimiter`]. Set it to
38    /// the number of threads that will call [`Client::ask`] at once.
39    pub pool_size: usize,
40    /// How long every thread waits after a throttled reply (HTTP 429 or 529) before the next
41    /// attempt, times a random factor in [1, 2).
42    pub throttle_pause: Duration,
43    /// Multiplies the retry backoff and any `Retry-After` wait. Zero makes retries immediate,
44    /// which tests want; [`Config::throttle_pause`] is separate. A negative or non-finite scale
45    /// also means no wait.
46    pub backoff_scale: f64,
47}
48
49impl Default for Config {
50    fn default() -> Self {
51        Config {
52            base_url: DEFAULT_BASE_URL.into(),
53            model: DEFAULT_MODEL.into(),
54            provider: "TypeSafe".into(),
55            user_agent: concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")).into(),
56            connect_timeout: Duration::from_secs(15),
57            timeout: Duration::from_secs(60),
58            max_retries: 8,
59            max_backoff: Duration::from_secs(30),
60            pool_size: 64,
61            throttle_pause: Duration::from_secs(1),
62            backoff_scale: 1.0,
63        }
64    }
65}
66
67type DebugReporter = dyn Fn(&str) + Send + Sync;
68
69/// A connection to the System One API. One shared pool; call [`ask`](Client::ask) from as many
70/// threads as [`Config::pool_size`] allows.
71pub struct Client {
72    model: String,
73    provider: String,
74    max_retries: u32,
75    max_backoff: Duration,
76    backoff_scale: f64,
77    usage: Usage,
78    limiter: AdaptiveLimiter,
79    transport: Box<dyn Transport>,
80    debug_reporter: Option<Box<DebugReporter>>,
81}
82
83impl Client {
84    /// A client that sends `api_key` as a bearer token to [`Config::base_url`] over HTTPS.
85    /// Whitespace around the key, such as the newline at the end of a key file, is dropped.
86    ///
87    /// Nothing is sent until the first [`ask`](Client::ask): a key the API rejects is reported
88    /// then, as [`Error::Auth`].
89    ///
90    /// # Errors
91    ///
92    /// What no request could be sent with is reported here rather than retried later:
93    /// [`Error::Auth`] for a blank key or one that cannot go in a header, and
94    /// [`Error::InvalidConfig`] for a [`Config::base_url`] that is not an absolute `http` or
95    /// `https` URL or a [`Config::user_agent`] that is not a valid header value.
96    pub fn new(api_key: &str, cfg: Config) -> Result<Self, Error> {
97        let http = Http::new(api_key, &cfg)?;
98        Ok(Self::with_transport(http, cfg))
99    }
100
101    /// A client that reads its API key from the [`API_KEY_VAR`] environment variable.
102    ///
103    /// # Errors
104    ///
105    /// [`Error::Auth`] if the variable is unset or blank; otherwise as [`Client::new`].
106    pub fn from_env(cfg: Config) -> Result<Self, Error> {
107        let key = api_key_from(std::env::var(API_KEY_VAR).ok().as_deref())?;
108        Self::new(&key, cfg)
109    }
110
111    /// A client over any [`Transport`], for tests and local fakes. [`Config::base_url`],
112    /// [`Config::user_agent`] and the timeouts are the transport's business and are ignored.
113    pub fn with_transport(transport: impl Transport + 'static, cfg: Config) -> Self {
114        Client {
115            model: cfg.model,
116            provider: cfg.provider,
117            max_retries: cfg.max_retries,
118            max_backoff: cfg.max_backoff,
119            backoff_scale: cfg.backoff_scale,
120            usage: Usage::default(),
121            limiter: AdaptiveLimiter::new(cfg.pool_size, cfg.throttle_pause),
122            transport: Box::new(transport),
123            debug_reporter: None,
124        }
125    }
126
127    /// Receives one line per failed attempt (the status or connection error, and any
128    /// `Retry-After`) before the client retries. Nothing is reported without a reporter, and
129    /// the reporter changes neither the retry policy nor the request.
130    pub fn set_debug_reporter(&mut self, reporter: impl Fn(&str) + Send + Sync + 'static) {
131        self.debug_reporter = Some(Box::new(reporter));
132    }
133
134    /// The model name sent in every request.
135    pub fn model(&self) -> &str {
136        &self.model
137    }
138
139    /// Requests, retries and tokens so far.
140    pub fn usage(&self) -> &Usage {
141        &self.usage
142    }
143
144    /// The concurrency gate, for inspection.
145    pub fn limiter(&self) -> &AdaptiveLimiter {
146        &self.limiter
147    }
148
149    fn debug(&self, msg: &str) {
150        if let Some(reporter) = &self.debug_reporter {
151            reporter(msg);
152        }
153    }
154
155    /// A product that is negative, NaN or too large for a `Duration` is no wait, not a panic.
156    fn sleep(&self, seconds: f64) {
157        match Duration::try_from_secs_f64(seconds * self.backoff_scale) {
158            Ok(wait) if !wait.is_zero() => std::thread::sleep(wait),
159            _ => {}
160        }
161    }
162
163    /// Evaluates `questions` against `state` and returns the [`Response`]: one
164    /// [`Answer`](crate::Answer) per question, under the id the question had.
165    ///
166    /// `state` is what the questions are about, and anything that serializes to a JSON string,
167    /// object or array: a `&str`, a `serde_json::Value`, or a type of your own that derives
168    /// `Serialize`. Jev reads text only. A question can name a field of the state in backticks.
169    ///
170    /// Transient failures are retried with exponential backoff up to [`Config::max_retries`]
171    /// times, honouring `Retry-After` (capped at 30 seconds) and the shared [`AdaptiveLimiter`].
172    /// A `Retry-After` given as a date rather than as seconds is ignored in favour of the backoff.
173    /// The call blocks the calling thread; run it from a thread pool for concurrency.
174    ///
175    /// # Errors
176    ///
177    /// [`Error::Auth`] for HTTP 401 and 403, [`Error::TokenLimit`] for a request larger than the
178    /// model's context, and [`Error::InvalidRequest`] for a state that does not serialize or a
179    /// body the API refuses as malformed (HTTP 422), all without a retry. [`Error::Api`] for any
180    /// other status that is not worth retrying, for a reply that is not the documented JSON, and
181    /// once the retries are spent; the message then ends with the last failure.
182    pub fn ask<S: Serialize + ?Sized>(&self, state: &S, questions: &Questions) -> Result<Response, Error> {
183        let request = Request { model: &self.model, state, questions };
184        let body = serde_json::to_vec(&request).map_err(|e| Error::InvalidRequest(format!("the state does not serialize to JSON: {e}")))?;
185        self.send(&body)
186    }
187
188    /// The part of [`ask`](Client::ask) that does not depend on the type of the state.
189    fn send(&self, body: &[u8]) -> Result<Response, Error> {
190        let mut last = String::from("unknown error");
191        for attempt in 0..=self.max_retries {
192            if attempt > 0 {
193                self.usage.record_retry();
194                let doublings = (attempt - 1).min(32) as i32;
195                self.sleep((0.5 * 2f64.powi(doublings)).min(self.max_backoff.as_secs_f64()) * (0.5 + jitter()));
196            }
197            let sent = {
198                let mut slot = Slot::acquire(&self.limiter);
199                let sent = self.transport.post(body);
200                slot.throttled = matches!(&sent, Ok(r) if r.status == 429 || r.status == 529);
201                sent
202            };
203            let reply = match sent {
204                Ok(reply) => reply,
205                Err(e) => {
206                    self.debug(&format!("attempt {}: {e}", attempt + 1));
207                    last = e;
208                    continue;
209                }
210            };
211            if reply.status == 200 {
212                let response: Response = serde_json::from_str(&reply.body).map_err(|e| Error::Api(format!("unreadable response: {e}")))?;
213                self.usage.record_reported(response.usage);
214                return Ok(response);
215            }
216            let text: String = reply.body.chars().take(400).collect();
217            if reply.status == 401 || reply.status == 403 {
218                return Err(Error::Auth(format!("{} rejected the API key (HTTP {}): {text}", self.provider, reply.status)));
219            }
220            if text.contains("max_tokens_exceeded") || reply.status == 413 {
221                return Err(Error::TokenLimit(text));
222            }
223            if reply.status == 422 {
224                return Err(Error::InvalidRequest(format!("HTTP 422: {text}")));
225            }
226            if RETRYABLE.contains(&reply.status) {
227                last = format!("HTTP {}: {text}", reply.status);
228                self.debug(&format!("attempt {}: {last} retry-after={:?}", attempt + 1, reply.retry_after));
229                if let Some(seconds) = reply.retry_after.and_then(|v| v.trim().parse::<f64>().ok()) {
230                    self.sleep(seconds.clamp(0.0, 30.0));
231                }
232                continue;
233            }
234            return Err(Error::Api(format!("HTTP {}: {text}", reply.status)));
235        }
236        Err(Error::Api(format!("gave up after {} retries: {last}", self.max_retries)))
237    }
238}
239
240/// The request body. The field order is the order on the wire.
241#[derive(Serialize)]
242struct Request<'a, S: ?Sized> {
243    model: &'a str,
244    state: &'a S,
245    questions: &'a Questions,
246}
247
248/// The model, the retry policy and the counters. The transport is left out: it holds the key.
249impl fmt::Debug for Client {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        f.debug_struct("Client")
252            .field("model", &self.model)
253            .field("provider", &self.provider)
254            .field("max_retries", &self.max_retries)
255            .field("max_backoff", &self.max_backoff)
256            .field("backoff_scale", &self.backoff_scale)
257            .field("usage", &self.usage)
258            .field("limiter", &self.limiter)
259            .finish_non_exhaustive()
260    }
261}
262
263/// One slot of the limiter, given back when dropped: a transport that panics must not leak it,
264/// or the threads that are left would wait for a slot that never frees.
265struct Slot<'a> {
266    limiter: &'a AdaptiveLimiter,
267    throttled: bool,
268}
269
270impl<'a> Slot<'a> {
271    fn acquire(limiter: &'a AdaptiveLimiter) -> Self {
272        limiter.acquire();
273        Slot { limiter, throttled: false }
274    }
275}
276
277impl Drop for Slot<'_> {
278    fn drop(&mut self) {
279        self.limiter.release(self.throttled);
280    }
281}
282
283/// The key as the environment supplies it: trimmed, and required to be non-blank.
284fn api_key_from(value: Option<&str>) -> Result<String, Error> {
285    match value.map(str::trim) {
286        Some(key) if !key.is_empty() => Ok(key.to_owned()),
287        _ => Err(Error::Auth(format!("{API_KEY_VAR} is not set"))),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn the_environment_key_is_trimmed_and_must_not_be_blank() {
297        assert_eq!(api_key_from(Some("  sk-1 \n")).unwrap(), "sk-1");
298        for missing in [None, Some(""), Some("   ")] {
299            let error = api_key_from(missing).unwrap_err();
300            assert!(matches!(&error, Error::Auth(m) if m.contains(API_KEY_VAR)), "{missing:?} -> {error:?}");
301        }
302    }
303
304    #[test]
305    fn defaults_point_at_typesafe() {
306        let cfg = Config::default();
307        assert_eq!((cfg.base_url.as_str(), cfg.model.as_str(), cfg.provider.as_str()), (DEFAULT_BASE_URL, DEFAULT_MODEL, "TypeSafe"));
308        assert!(cfg.user_agent.starts_with("typesafe-jev/"), "{}", cfg.user_agent);
309        assert_eq!((cfg.max_retries, cfg.max_backoff, cfg.pool_size, cfg.backoff_scale), (8, Duration::from_secs(30), 64, 1.0));
310    }
311}