use super::schemas::{JevRequest, JevResponse};
use anyhow::{bail, Result};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::time::Duration;
const API_URL: &str = "https://api.typesafe.ai/v1/systemone";
const MAX_RETRIES: u32 = 3;
pub struct JevClient {
http: reqwest::Client,
key: String,
cache_dir: Option<PathBuf>,
}
impl JevClient {
pub fn new(key: String, cache_dir: Option<PathBuf>) -> Self {
Self {
http: reqwest::Client::new(),
key,
cache_dir,
}
}
pub fn cache_path(&self, body_json: &str) -> Option<PathBuf> {
let dir = self.cache_dir.as_ref()?;
let hash = Sha256::digest(body_json.as_bytes());
let mut hex = String::with_capacity(64);
for byte in hash {
hex.push_str(&format!("{byte:02x}"));
}
Some(dir.join(format!("{hex}.json")))
}
pub async fn system_one<S: Serialize>(&self, body: &JevRequest<S>) -> Result<JevResponse> {
let body_json = serde_json::to_string(body)?;
let cache_file = self.cache_path(&body_json);
if let Some(path) = &cache_file {
if let Ok(text) = std::fs::read_to_string(path) {
if let Ok(cached) = serde_json::from_str(&text) {
return Ok(cached);
}
}
}
if std::env::var_os("JEVR_DEBUG").is_some() {
eprintln!("jevr: debug: jev request");
}
let mut last = String::new();
for retry in 0..=MAX_RETRIES {
match self
.http
.post(API_URL)
.bearer_auth(&self.key)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body_json.clone())
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let text = resp.text().await?;
let parsed: JevResponse = serde_json::from_str(&text)?;
if let Some(path) = &cache_file {
write_cache(path, &text);
}
return Ok(parsed);
}
Ok(resp) => {
let status = resp.status();
let detail: String = resp
.text()
.await
.unwrap_or_default()
.chars()
.take(500)
.collect();
last = format!("HTTP {status}: {detail}");
let retryable = status.as_u16() == 408
|| status.as_u16() == 429
|| status.is_server_error();
if !retryable {
bail!(last);
}
}
Err(e) => last = e.to_string(),
}
if retry < MAX_RETRIES {
tokio::time::sleep(retry_delay(retry)).await;
}
}
bail!("request failed after {} attempts: {last}", MAX_RETRIES + 1)
}
}
fn write_cache(path: &std::path::Path, text: &str) {
let Some(parent) = path.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
return;
}
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
if std::fs::write(&tmp, text).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
fn retry_delay(retry: u32) -> Duration {
Duration::from_millis(250 << retry)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retries_use_exponential_backoff() {
assert_eq!(MAX_RETRIES, 3);
assert_eq!(retry_delay(0), Duration::from_millis(250));
assert_eq!(retry_delay(1), Duration::from_millis(500));
assert_eq!(retry_delay(2), Duration::from_millis(1000));
}
#[test]
fn cache_path_is_deterministic_and_off_without_dir() {
let cached = JevClient::new("k".into(), Some(PathBuf::from("/tmp/c")));
let a = cached.cache_path("{\"x\":1}").unwrap();
let b = cached.cache_path("{\"x\":1}").unwrap();
assert_eq!(a, b);
assert_ne!(a, cached.cache_path("{\"x\":2}").unwrap());
assert!(JevClient::new("k".into(), None).cache_path("{}").is_none());
}
}