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},
};
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_retries: u32,
pub initial_delay: Duration,
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 {
pub fn none() -> Self {
Self {
max_retries: 0,
..Self::default()
}
}
}
#[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 {
pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
Self::builder().api_key(api_key).build()
}
pub fn from_env() -> Result<Self, Error> {
Self::builder().build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
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));
tokio::time::sleep(delay.min(self.timeout)).await;
retries += 1;
backoff = backoff.saturating_mul(2).min(self.retry.max_delay);
}
}
}
#[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 {
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn retry_policy(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
pub fn http_client(mut self, http: reqwest::Client) -> Self {
self.http = Some(http);
self
}
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);
}
}