billdogeng 1.0.0-beta.1

Official BilldogEng server SDK for Rust — Analytics, Feature Flags (remote + local eval), Surveys, Messaging, and LLM observability.
Documentation
//! Thin HTTP client around `ureq` with:
//!  - per-request timeout
//!  - optional gzip request bodies
//!  - exponential backoff retry (1s, 2s, 4s …) for 5xx / 429 / network errors
//!  - unwrapping of the `{ success, data, meta }` envelope when present

use std::io::Read;
use std::thread;
use std::time::Duration;

use flate2::write::GzEncoder;
use flate2::Compression;
use serde_json::Value;
use std::io::Write;

use crate::error::{BilldogEngError, Result};

/// Only compress bodies large enough to benefit.
const GZIP_THRESHOLD_BYTES: usize = 1024;

/// HTTP method for a request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
    Get,
    Post,
}

/// Transport configuration.
#[derive(Debug, Clone)]
pub struct TransportConfig {
    pub host: String,
    pub request_timeout_ms: u64,
    pub gzip: bool,
    pub max_retries: u32,
    pub enable_logging: bool,
}

/// A single request to perform.
pub struct RequestOptions<'a> {
    /// Path appended to `host`, e.g. `/ingest-events`.
    pub path: &'a str,
    /// JSON body; `None` issues a GET.
    pub body: Option<Value>,
    /// Extra headers (auth headers are supplied per-call).
    pub headers: Vec<(String, String)>,
    /// Whether to gzip-encode this request (only honored if config.gzip).
    pub gzip: bool,
}

impl<'a> RequestOptions<'a> {
    /// Construct a POST request with a JSON body and no extra headers.
    pub fn post(path: &'a str, body: Value) -> Self {
        Self {
            path,
            body: Some(body),
            headers: Vec::new(),
            gzip: true,
        }
    }

    /// Add a header.
    pub fn header(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
        self.headers.push((k.into(), v.into()));
        self
    }

    /// Override gzip for this request.
    pub fn gzip(mut self, gzip: bool) -> Self {
        self.gzip = gzip;
        self
    }
}

/// The HTTP transport. Thread-safe and cheap to clone.
#[derive(Clone)]
pub struct Transport {
    config: TransportConfig,
    agent: ureq::Agent,
}

impl Transport {
    /// Create a transport from config.
    pub fn new(config: TransportConfig) -> Self {
        let agent = ureq::AgentBuilder::new()
            .timeout(Duration::from_millis(config.request_timeout_ms))
            .build();
        Self { config, agent }
    }

    fn log(&self, msg: &str) {
        if self.config.enable_logging {
            eprintln!("[BilldogEng] {msg}");
        }
    }

    /// Perform a request with retry/backoff. Returns the parsed JSON body,
    /// unwrapped from the standard `{ success, data, meta }` envelope when present.
    pub fn request(&self, options: &RequestOptions) -> Result<Value> {
        let url = format!("{}{}", self.config.host, options.path);
        let max_attempts = self.config.max_retries + 1;
        let mut last_error: Option<BilldogEngError> = None;

        for attempt in 0..max_attempts {
            if attempt > 0 {
                let backoff = 1000u64 * 2u64.pow(attempt - 1); // 1s, 2s, 4s …
                self.log(&format!(
                    "retry {}/{} after {}ms → {}",
                    attempt, self.config.max_retries, backoff, options.path
                ));
                thread::sleep(Duration::from_millis(backoff));
            }

            match self.attempt(&url, options) {
                Ok(v) => return Ok(v),
                Err(e) => {
                    let retryable = e.is_retryable();
                    last_error = Some(e.clone());
                    if !retryable || attempt == max_attempts - 1 {
                        return Err(e);
                    }
                    self.log(&format!(
                        "request failed (status {}), will retry: {}",
                        e.status, e.message
                    ));
                }
            }
        }

        Err(last_error.unwrap_or_else(|| BilldogEngError::new("request failed", 0)))
    }

    fn attempt(&self, url: &str, options: &RequestOptions) -> Result<Value> {
        let method = if options.body.is_some() {
            Method::Post
        } else {
            Method::Get
        };

        let mut req = match method {
            Method::Post => self.agent.post(url),
            Method::Get => self.agent.get(url),
        };
        req = req
            .set("Content-Type", "application/json")
            .set("User-Agent", "billdogeng-rust");
        for (k, v) in &options.headers {
            req = req.set(k, v);
        }

        // Build the payload, gzip when large enough and enabled.
        let response = if let Some(body) = &options.body {
            let json = serde_json::to_vec(body)
                .map_err(|e| BilldogEngError::new(format!("serialize error: {e}"), 0))?;
            let use_gzip =
                self.config.gzip && options.gzip && json.len() >= GZIP_THRESHOLD_BYTES;
            if use_gzip {
                let mut enc = GzEncoder::new(Vec::new(), Compression::default());
                enc.write_all(&json)
                    .and_then(|_| enc.finish())
                    .map_err(|e| BilldogEngError::new(format!("gzip error: {e}"), 0))
                    .and_then(|gz| {
                        req.set("Content-Encoding", "gzip")
                            .send_bytes(&gz)
                            .map_err(map_ureq_error)
                    })
            } else {
                req.send_bytes(&json).map_err(map_ureq_error)
            }
        } else {
            req.call().map_err(map_ureq_error)
        };

        let resp = response?;
        let status = resp.status();
        let mut text = String::new();
        resp.into_reader()
            .read_to_string(&mut text)
            .map_err(|e| BilldogEngError::new(format!("read body error: {e}"), 0))?;

        let parsed: Option<Value> = if text.is_empty() {
            None
        } else {
            serde_json::from_str(&text).ok()
        };

        if !(200..300).contains(&status) {
            let (code, message) = extract_envelope_error(&parsed);
            return Err(BilldogEngError::new(
                message.unwrap_or_else(|| format!("HTTP {status}")),
                status,
            )
            .with_code(code)
            .with_body(parsed));
        }

        Ok(unwrap_envelope(parsed))
    }
}

/// Map a `ureq` error to a [`BilldogEngError`]. HTTP error responses preserve
/// their status (so 5xx remains retryable); transport errors map to status 0.
fn map_ureq_error(err: ureq::Error) -> BilldogEngError {
    match err {
        ureq::Error::Status(status, resp) => {
            let mut text = String::new();
            let _ = resp.into_reader().read_to_string(&mut text);
            let parsed: Option<Value> = if text.is_empty() {
                None
            } else {
                serde_json::from_str(&text).ok()
            };
            let (code, message) = extract_envelope_error(&parsed);
            BilldogEngError::new(message.unwrap_or_else(|| format!("HTTP {status}")), status)
                .with_code(code)
                .with_body(parsed)
        }
        ureq::Error::Transport(t) => {
            // Network failure / timeout → status 0, retryable.
            BilldogEngError::new(format!("network error: {t}"), 0)
                .with_code(Some("NETWORK_ERROR".to_string()))
        }
    }
}

/// Pull `error.code` / `error.message` out of an error envelope.
fn extract_envelope_error(parsed: &Option<Value>) -> (Option<String>, Option<String>) {
    if let Some(Value::Object(obj)) = parsed {
        if let Some(Value::Object(err)) = obj.get("error") {
            let code = err.get("code").and_then(|v| v.as_str()).map(String::from);
            let message = err
                .get("message")
                .and_then(|v| v.as_str())
                .map(String::from);
            return (code, message);
        }
    }
    (None, None)
}

/// Unwrap the standard `{ success, data, meta }` envelope when present; legacy
/// endpoints return the raw body, which is passed through unchanged.
fn unwrap_envelope(parsed: Option<Value>) -> Value {
    match parsed {
        Some(Value::Object(obj))
            if obj.contains_key("data") && obj.contains_key("success") =>
        {
            obj.get("data").cloned().unwrap_or(Value::Null)
        }
        Some(v) => v,
        None => Value::Null,
    }
}