jevapi 0.1.0

An asynchronous, typed Rust client for the TypeSafe AI evaluation API
Documentation
use crate::{ApiError, DEFAULT_BASE_URL, Error, EvaluationRequest, EvaluationResponse};
use reqwest::{
    Url,
    header::{ACCEPT, AUTHORIZATION, HeaderValue, RETRY_AFTER, USER_AGENT},
};
use std::{
    fmt,
    time::{Duration, SystemTime},
};

/// Bounded exponential backoff with jitter for HTTP 429 and 529 responses.
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
    /// Additional attempts after the original request. Zero disables retries.
    pub max_retries: u32,
    /// First backoff ceiling; the actual jittered delay is between half and all of it.
    pub initial_delay: Duration,
    /// Maximum exponential backoff ceiling. `Retry-After` may exceed this value,
    /// but the overall evaluation timeout always applies.
    pub max_delay: Duration,
}

impl Default for RetryPolicy {
    fn default() -> Self {
        Self {
            max_retries: 3,
            initial_delay: Duration::from_millis(500),
            max_delay: Duration::from_secs(8),
        }
    }
}

impl RetryPolicy {
    /// Disable retries.
    pub fn none() -> Self {
        Self {
            max_retries: 0,
            ..Self::default()
        }
    }
}

/// An asynchronous client backed by a shared HTTP connection pool.
///
/// Cheap to clone, `Send + Sync`, and intended to be reused across evaluations.
/// Requires a Tokio runtime with time and I/O enabled.
#[derive(Clone)]
pub struct Client {
    http: reqwest::Client,
    endpoint: Url,
    authorization: HeaderValue,
    timeout: Duration,
    retry: RetryPolicy,
}

impl fmt::Debug for Client {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Client")
            .field("endpoint", &self.endpoint)
            .field("timeout", &self.timeout)
            .field("retry", &self.retry)
            .finish_non_exhaustive()
    }
}

impl Client {
    /// Construct a client using an explicit API key and default settings.
    pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
        Self::builder().api_key(api_key).build()
    }

    /// Read the API key from `TYPESAFE_API_KEY`.
    pub fn from_env() -> Result<Self, Error> {
        Self::builder().build()
    }

    /// Configure the API key, base URL, deadline, retries, or HTTP client.
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    /// Evaluate a state against typed questions.
    ///
    /// Validates before sending, retries only HTTP 429/529, and applies one
    /// deadline to all attempts, body reads, and retry sleeps. Transport errors
    /// are not retried because the server may already have processed the POST.
    /// Dropping the future cancels local work, but cannot undo server processing.
    pub async fn evaluate(&self, request: &EvaluationRequest) -> Result<EvaluationResponse, Error> {
        request.validate()?;
        tokio::time::timeout(self.timeout, self.execute(request))
            .await
            .map_err(|_| Error::Timeout)?
    }

    async fn execute(&self, request: &EvaluationRequest) -> Result<EvaluationResponse, Error> {
        let mut retries = 0;
        let mut backoff = self.retry.initial_delay;
        loop {
            let response = self
                .http
                .post(self.endpoint.clone())
                .header(AUTHORIZATION, self.authorization.clone())
                .header(ACCEPT, "application/json")
                .header(USER_AGENT, concat!("jevapi/", env!("CARGO_PKG_VERSION")))
                .json(request)
                .send()
                .await?;
            let status = response.status();
            if status.is_success() {
                let body = response.bytes().await?;
                return serde_json::from_slice(&body).map_err(Error::Decode);
            }
            let headers = response.headers().clone();
            let body = response.bytes().await?;
            let error = ApiError {
                status,
                headers,
                body: String::from_utf8_lossy(&body).into_owned(),
            };
            if !error.is_retryable() || retries >= self.retry.max_retries {
                return Err(Error::Api(Box::new(error)));
            }
            let jittered = backoff.mul_f64(0.5 + fastrand::f64() * 0.5);
            let delay = retry_after(error.headers.get(RETRY_AFTER), SystemTime::now())
                .map_or(jittered, |hint| hint.max(jittered));
            // A server hint may be larger than Instant can represent. Sleeping
            // for at most our deadline still lets the outer timeout cancel us.
            tokio::time::sleep(delay.min(self.timeout)).await;
            retries += 1;
            backoff = backoff.saturating_mul(2).min(self.retry.max_delay);
        }
    }
}

/// Configuration builder. API keys are omitted from debug output.
#[derive(Clone)]
#[must_use]
pub struct ClientBuilder {
    api_key: Option<String>,
    base_url: String,
    timeout: Duration,
    retry: RetryPolicy,
    http: Option<reqwest::Client>,
}

impl fmt::Debug for ClientBuilder {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ClientBuilder")
            .field("timeout", &self.timeout)
            .field("retry", &self.retry)
            .finish_non_exhaustive()
    }
}

impl Default for ClientBuilder {
    fn default() -> Self {
        Self {
            api_key: None,
            base_url: DEFAULT_BASE_URL.into(),
            timeout: Duration::from_secs(60),
            retry: RetryPolicy::default(),
            http: None,
        }
    }
}

impl ClientBuilder {
    /// Use an explicit key instead of reading `TYPESAFE_API_KEY` at build time.
    pub fn api_key(mut self, key: impl Into<String>) -> Self {
        self.api_key = Some(key.into());
        self
    }

    /// Set the base URL including the version prefix, e.g. `https://host/v1`.
    /// A trailing slash is optional. HTTP is supported for local test servers.
    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = base_url.into();
        self
    }

    /// Set the overall evaluation deadline, including retries. Defaults to 60 seconds.
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Override the retry policy; use `RetryPolicy::none()` to disable retries.
    pub fn retry_policy(mut self, retry: RetryPolicy) -> Self {
        self.retry = retry;
        self
    }

    /// Supply a configured reqwest client, e.g. with a proxy or custom certificates.
    /// Its redirect and transport retry settings replace this crate's defaults.
    /// Its timeouts still apply in addition to the evaluation deadline.
    pub fn http_client(mut self, http: reqwest::Client) -> Self {
        self.http = Some(http);
        self
    }

    /// Validate configuration and construct the client without making a request.
    pub fn build(self) -> Result<Client, Error> {
        let key = match self.api_key {
            Some(key) => key,
            None => std::env::var("TYPESAFE_API_KEY").map_err(|_| {
                Error::Configuration("TYPESAFE_API_KEY is missing or is not valid Unicode".into())
            })?,
        };
        if key.trim().is_empty() {
            return Err(Error::Configuration("API key must not be empty".into()));
        }
        let mut authorization = HeaderValue::from_str(&format!("Bearer {key}")).map_err(|_| {
            Error::Configuration("API key contains invalid header characters".into())
        })?;
        authorization.set_sensitive(true);
        let mut base = Url::parse(&self.base_url)
            .map_err(|_| Error::Configuration("base URL must be an absolute HTTP(S) URL".into()))?;
        if !matches!(base.scheme(), "http" | "https")
            || base.host_str().is_none()
            || !base.username().is_empty()
            || base.password().is_some()
            || base.query().is_some()
            || base.fragment().is_some()
        {
            return Err(Error::Configuration(
                "base URL must use HTTP(S) with a host and no credentials, query, or fragment"
                    .into(),
            ));
        }
        if !base.path().ends_with('/') {
            base.set_path(&format!("{}/", base.path()));
        }
        let endpoint = base
            .join("systemone")
            .map_err(|_| Error::Configuration("invalid API base URL".into()))?;
        if self.timeout.is_zero()
            || tokio::time::Instant::now()
                .checked_add(self.timeout)
                .is_none()
        {
            return Err(Error::Configuration(
                "timeout must be positive and representable".into(),
            ));
        }
        if self.retry.max_retries > 0
            && (self.retry.initial_delay.is_zero()
                || self.retry.initial_delay > self.retry.max_delay
                || tokio::time::Instant::now()
                    .checked_add(self.retry.max_delay)
                    .is_none())
        {
            return Err(Error::Configuration(
                "retry delays must be representable and satisfy 0 < initial_delay <= max_delay"
                    .into(),
            ));
        }
        let http = match self.http {
            Some(http) => http,
            None => reqwest::Client::builder()
                .redirect(reqwest::redirect::Policy::none())
                .retry(reqwest::retry::never())
                .build()?,
        };
        Ok(Client {
            http,
            endpoint,
            authorization,
            timeout: self.timeout,
            retry: self.retry,
        })
    }
}

fn retry_after(value: Option<&HeaderValue>, now: SystemTime) -> Option<Duration> {
    let value = value?.to_str().ok()?.trim();
    if let Ok(seconds) = value.parse::<u64>() {
        return Some(Duration::from_secs(seconds));
    }
    httpdate::parse_http_date(value)
        .ok()
        .map(|date| date.duration_since(now).unwrap_or_default())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn retry_after_supports_seconds_dates_and_bad_values() {
        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
        assert_eq!(
            retry_after(Some(&HeaderValue::from_static("12")), now),
            Some(Duration::from_secs(12))
        );
        let date =
            HeaderValue::from_str(&httpdate::fmt_http_date(now + Duration::from_secs(30))).unwrap();
        assert_eq!(retry_after(Some(&date), now), Some(Duration::from_secs(30)));
        let past =
            HeaderValue::from_str(&httpdate::fmt_http_date(now - Duration::from_secs(30))).unwrap();
        assert_eq!(retry_after(Some(&past), now), Some(Duration::ZERO));
        assert_eq!(
            retry_after(Some(&HeaderValue::from_static("bad")), now),
            None
        );
        assert_eq!(retry_after(None, now), None);
    }
}