Skip to main content

tessera/
config.rs

1//! Client configuration and shared helpers (transport-agnostic).
2
3use std::time::Duration;
4
5use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue};
6
7use crate::error::TesseraError;
8
9/// Production API base URL.
10pub const DEFAULT_BASE_URL: &str = "https://tesseralytics.dev";
11
12/// Environment variable consulted when no explicit API key is passed.
13pub const API_KEY_ENV_VAR: &str = "TESSERA_API_KEY";
14
15/// Default `User-Agent` for requests made by this client.
16pub const USER_AGENT: &str = concat!("tessera-rust/", env!("CARGO_PKG_VERSION"));
17
18/// Statuses worth retrying: rate limiting + transient server/gateway errors.
19pub(crate) const RETRYABLE: &[u16] = &[429, 500, 502, 503, 504];
20
21/// Resolve the API key from the argument, then `$TESSERA_API_KEY`.
22///
23/// # Errors
24///
25/// Returns [`TesseraError::Configuration`] when no key can be resolved.
26pub fn resolve_api_key(api_key: Option<&str>) -> Result<String, TesseraError> {
27    api_key
28        .map(str::to_string)
29        .or_else(|| std::env::var(API_KEY_ENV_VAR).ok())
30        .filter(|key| !key.is_empty())
31        .ok_or_else(|| {
32            TesseraError::Configuration(
33                "No API key provided. Pass api_key=... or set the TESSERA_API_KEY environment variable. Get a free key at https://tesseralytics.dev.".to_string(),
34            )
35        })
36}
37
38/// Compute the delay before a retry.
39///
40/// Honours a server `Retry-After` when present (and non-negative), otherwise
41/// exponential backoff (0.5s, 1s, 2s, …) with full jitter to avoid thundering
42/// herds (`fastrand` draws from the half-open interval `[0, 1)`).
43#[must_use]
44pub fn backoff_delay(attempt: u32, retry_after: Option<f64>) -> Duration {
45    if let Some(seconds) = retry_after.filter(|s| *s >= 0.0) {
46        return Duration::from_secs_f64(seconds);
47    }
48    let base = 0.5 * 2f64.powi(i32::try_from(attempt).unwrap_or(63));
49    Duration::from_secs_f64(fastrand::f64() * base)
50}
51
52/// Resolved configuration shared by the sync and async clients.
53#[derive(Debug, Clone)]
54pub struct ClientConfig {
55    /// Tessera API key (sent as `Authorization: Bearer ...`).
56    pub api_key: String,
57    /// API base URL, trailing `/` stripped.
58    pub base_url: String,
59    /// Per-request timeout.
60    pub timeout: Duration,
61    /// Retries for transient failures (429/5xx, network errors).
62    pub max_retries: u32,
63    /// `User-Agent` header value.
64    pub user_agent: String,
65}
66
67impl ClientConfig {
68    /// Build a configuration from an explicit key or the environment.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`TesseraError::Configuration`] when no API key can be resolved.
73    pub fn new(api_key: Option<&str>) -> Result<Self, TesseraError> {
74        Ok(Self {
75            api_key: resolve_api_key(api_key)?,
76            base_url: DEFAULT_BASE_URL.to_string(),
77            timeout: Duration::from_secs(30),
78            max_retries: 3,
79            user_agent: USER_AGENT.to_string(),
80        })
81    }
82
83    /// Headers every request carries: bearer auth, user agent, JSON accept.
84    #[must_use]
85    pub fn auth_headers(&self) -> HeaderMap {
86        let mut headers = HeaderMap::new();
87        if let Ok(value) = HeaderValue::from_str(&format!("Bearer {}", self.api_key)) {
88            headers.insert(AUTHORIZATION, value);
89        }
90        if let Ok(value) = HeaderValue::from_str(&self.user_agent) {
91            headers.insert(reqwest::header::USER_AGENT, value);
92        }
93        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
94        headers
95    }
96}