grafana 0.1.3

Ergonomic Rust SDK for Grafana's HTTP API, with async and blocking clients.
Documentation
use std::time::Duration;

use http::{HeaderMap, Method, StatusCode};

use crate::{Auth, util::redact};

#[cfg(feature = "async")]
mod async_transport;
#[cfg(feature = "blocking")]
mod blocking_transport;

#[cfg(feature = "async")]
pub(crate) use async_transport::AsyncTransport;
#[cfg(feature = "blocking")]
pub(crate) use blocking_transport::BlockingTransport;

#[derive(Clone)]
pub(crate) struct TransportConfig {
    pub timeout: Duration,
    pub connect_timeout: Duration,
}

#[derive(Clone)]
pub(crate) struct RetryConfig {
    pub max_retries: usize,
    pub base_delay: Duration,
    pub max_delay: Duration,
}

#[derive(Clone)]
pub(crate) struct BodySnippetConfig {
    pub enabled: bool,
    pub byte_limit: usize,
}

#[derive(Clone, Copy)]
pub(crate) struct RequestContext<'a> {
    pub base_url: &'a url::Url,
    pub auth: &'a Auth,
    pub default_headers: &'a HeaderMap,
    pub retry: &'a RetryConfig,
    pub body_snippet: &'a BodySnippetConfig,
}

fn is_retryable_status(method: &Method, status: StatusCode) -> bool {
    if !is_idempotent_method(method) {
        return false;
    }

    matches!(
        status,
        StatusCode::TOO_MANY_REQUESTS
            | StatusCode::BAD_GATEWAY
            | StatusCode::SERVICE_UNAVAILABLE
            | StatusCode::GATEWAY_TIMEOUT
    )
}

fn is_retryable_transport_error(method: &Method, err: &reqwest::Error) -> bool {
    if !is_idempotent_method(method) {
        return false;
    }

    err.is_timeout() || err.is_connect()
}

fn is_idempotent_method(method: &Method) -> bool {
    matches!(
        *method,
        Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
    )
}

fn extract_request_id(headers: &HeaderMap) -> Option<String> {
    const CANDIDATES: [&str; 3] = ["x-request-id", "x-grafana-request-id", "x-amzn-trace-id"];

    for name in CANDIDATES {
        if let Some(value) = headers.get(name)
            && let Ok(value) = value.to_str()
        {
            let value = value.trim();
            if !value.is_empty() {
                return Some(value.to_owned());
            }
        }
    }
    None
}

fn extract_message(bytes: &[u8]) -> Option<String> {
    #[derive(serde::Deserialize)]
    struct ApiErrorBody {
        message: Option<String>,
        error: Option<String>,
    }

    let body = serde_json::from_slice::<ApiErrorBody>(bytes).ok()?;
    body.message.or(body.error)
}

fn capture_snippet(bytes: &[u8], config: &BodySnippetConfig) -> Option<String> {
    if !config.enabled {
        return None;
    }
    redact::redact_body_snippet(bytes, config.byte_limit)
}

fn extract_retry_after(headers: &HeaderMap) -> Option<Duration> {
    let value = headers.get(http::header::RETRY_AFTER)?;
    let value = value.to_str().ok()?.trim();
    if value.is_empty() {
        return None;
    }

    if let Ok(seconds) = value.parse::<u64>() {
        return Some(Duration::from_secs(seconds));
    }

    let when = httpdate::parse_http_date(value).ok()?;
    let now = std::time::SystemTime::now();
    let diff = when.duration_since(now).unwrap_or(Duration::ZERO);
    Some(diff)
}

fn compute_retry_delay(
    retry_after: Option<Duration>,
    retry: &RetryConfig,
    attempt: usize,
) -> Option<Duration> {
    if let Some(delay) = retry_after {
        return Some(delay);
    }

    if retry.base_delay.is_zero() {
        return Some(Duration::ZERO);
    }

    let base_ms = retry.base_delay.as_millis().min(u128::from(u64::MAX)) as u64;
    let max_ms = retry.max_delay.as_millis().min(u128::from(u64::MAX)) as u64;

    let shift = attempt.min(32) as u32;
    let exp_ms = base_ms.saturating_mul(1u64 << shift);
    let cap_ms = exp_ms.min(max_ms);

    if cap_ms == 0 {
        return Some(Duration::ZERO);
    }

    let jitter_ms = fastrand::u64(0..=cap_ms);
    Some(Duration::from_millis(jitter_ms))
}