webhookloader 0.1.6

Trigger HTTP webhooks by name from a TOML config. Small async library with retries and automatic Content-Type detection.
Documentation
use config::{Config, ConfigError};
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use reqwest::StatusCode;
use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rand::RngCore;

pub struct WebHookLoader {
    pub hook_map: HashMap<String, String>,
}

#[derive(Debug)]
pub enum WebHookError {
    ConfigError(ConfigError),
    ExecutionError(String),
}

fn compute_discord_backoff(status: StatusCode, headers: &HeaderMap) -> Option<Duration> {
    // Helper to parse f64 seconds from header
    fn parse_secs(h: &HeaderMap, name: &str) -> Option<f64> {
        h.get(name).and_then(|v| v.to_str().ok()).and_then(|s| s.trim().parse::<f64>().ok())
    }

    // Add jitter 50–200ms
    let mut rng = rand::rng();
    let jitter_ms = 50 + (rng.next_u32() as u64 % 151); // 50..=200 ms

    // If throttled (429) or global ratelimit flag, prefer Retry-After
    let global = headers
        .get("x-ratelimit-global")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.eq_ignore_ascii_case("true"))
        .unwrap_or(false);

    if status == StatusCode::TOO_MANY_REQUESTS || global {
        if let Some(secs) = parse_secs(headers, "retry-after") {
            let base_ms = if secs <= 0.0 { 0 } else { (secs * 1000.0).ceil() as u64 };
            return Some(Duration::from_millis(base_ms + jitter_ms));
        }
        if let Some(secs) = parse_secs(headers, "x-ratelimit-reset-after") {
            let base_ms = if secs <= 0.0 { 0 } else { (secs * 1000.0).ceil() as u64 };
            return Some(Duration::from_millis(base_ms + jitter_ms));
        }
        if let Some(reset_epoch) = parse_secs(headers, "x-ratelimit-reset") {
            let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64();
            let secs_left = (reset_epoch - now).max(0.0);
            let base_ms = (secs_left * 1000.0).ceil() as u64;
            return Some(Duration::from_millis(base_ms + jitter_ms));
        }
        // Fallback: wait ~1s + jitter
        return Some(Duration::from_millis(1000 + jitter_ms));
    }

    // Not a 429: Pace if remaining is 0 and we know when reset occurs
    if let Some(remaining) = headers
        .get("x-ratelimit-remaining")
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.trim().parse::<i64>().ok())
    {
        if remaining <= 0 {
            if let Some(secs) = parse_secs(headers, "x-ratelimit-reset-after") {
                let base_ms = if secs <= 0.0 { 0 } else { (secs * 1000.0).ceil() as u64 };
                return Some(Duration::from_millis(base_ms + jitter_ms));
            }
            if let Some(reset_epoch) = parse_secs(headers, "x-ratelimit-reset") {
                let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64();
                let secs_left = (reset_epoch - now).max(0.0);
                let base_ms = (secs_left * 1000.0).ceil() as u64;
                return Some(Duration::from_millis(base_ms + jitter_ms));
            }
        }
    }

    None
}

impl WebHookLoader {
    pub fn new(config_path: &str) -> Result<WebHookLoader, WebHookError> {
        let map = Config::builder()
            .add_source(config::File::with_name(config_path))
            .build()
            .map_err(WebHookError::ConfigError)?;

        let dsmap = map
            .try_deserialize::<HashMap<String, String>>()
            .map_err(WebHookError::ConfigError)?;

        Ok(WebHookLoader { hook_map: dsmap })
    }

    pub fn list_hooks(&self) -> Result<Vec<String>, WebHookError> {
        Ok(self.hook_map.keys().cloned().collect::<Vec<String>>())
    }

    /// Fire a webhook by name, returning Result instead of panicking.
    pub async fn fire_hook_result(&self, name: &str) -> Result<(), WebHookError> {
        let Some(value) = self.hook_map.get(name) else {
            return Err(WebHookError::ExecutionError(format!(
                "Webhook '{}' not found in config",
                name
            )));
        };

        // Split only once: "url:::body"
        let mut parts = value.splitn(2, ":::");
        let url = parts.next().unwrap_or("").trim();
        let body = parts.next().unwrap_or("").trim();

        if url.is_empty() {
            return Err(WebHookError::ExecutionError(
                "Configured URL is empty".to_string(),
            ));
        }

        // Decide content-type
        let content_type = if body.starts_with('{') || body.starts_with('[') {
            "application/json"
        } else {
            "text/plain; charset=utf-8"
        };

        let client = reqwest::Client::new();
        let tries = 10;
        let mut counter = 0;
        let mut last_err: Option<String> = None;

        while counter < tries {
            let mut headers = HeaderMap::new();
            headers.insert(
                CONTENT_TYPE,
                HeaderValue::from_str(content_type).unwrap_or(HeaderValue::from_static("application/json")),
            );

            let req = client.post(url).headers(headers.clone());
            let req = if body.is_empty() {
                req
            } else {
                req.body(body.to_string())
            };

            match req.send().await {
                Ok(resp) => {
                    let status = resp.status();
                    if status.is_success() {
                        // Even on success, pace if remaining=0 with a reset-after to avoid immediate next hits
                        if let Some(dur) = compute_discord_backoff(status, resp.headers()) {
                            tokio::time::sleep(dur).await;
                        }
                        return Ok(());
                    } else {
                        // Compute backoff based on Discord rate-limit headers
                        let backoff = compute_discord_backoff(status, resp.headers());
                        if let Some(dur) = backoff {
                            tokio::time::sleep(dur).await;
                        }
                        last_err = Some(format!(
                            "HTTP {} when POSTing to {}",
                            status, url
                        ));
                        counter += 1;
                    }
                }
                Err(e) => {
                    // If we have an HTTP status, try to back off accordingly
                    if let Some(status) = e.status() {
                        // No headers available on error; minimal courtesy sleep for 429
                        if status == StatusCode::TOO_MANY_REQUESTS {
                            let mut rng = rand::rng();
                            let jitter_ms = 50 + (rng.next_u32() as u64 % 151);
                            tokio::time::sleep(Duration::from_millis(1000 + jitter_ms)).await;
                        }
                    }
                    last_err = Some(format!("{}", e));
                    counter += 1;
                }
            }
        }

        Err(WebHookError::ExecutionError(
            last_err.unwrap_or_else(|| "Failed to send webhook after retries".to_string()),
        ))
    }

    /// Backward-compatible fire_hook that ignores the result.
    pub async fn fire_hook(&self, name: &str) {
        let _ = self.fire_hook_result(name).await;
    }
}

#[cfg(test)]
mod tests {
    use rand::RngCore;

    use super::{WebHookLoader, compute_discord_backoff};
    use crate::WebHookError;
    use reqwest::header::{HeaderMap, HeaderValue};
    use reqwest::StatusCode;
    use std::fs::OpenOptions;
    use std::io::Write;
    use std::path::Path;

    fn loader() -> Result<WebHookLoader, WebHookError> {
        let config = r#"light_on = "http://localhost:8080/api/testme""#;

        let mut rand = rand::rng();
        let test_config = format!("/tmp/{}.toml", rand.next_u32());

        if !Path::is_file(Path::new(&test_config)) {
            if let Ok(mut file) = OpenOptions::new()
                .append(true)
                .create(true)
                .open(&test_config)
            {
                file.write_all(config.as_bytes()).unwrap();
            }
        }

        WebHookLoader::new(&test_config)
    }

    #[test]
    fn test_webhook_loader() {
        let new_whl = loader();
        if let Ok(a) = &new_whl {
            a.hook_map.iter().for_each(|(key, val)| {
                println!("Key: {}, Value: {}", key, val);
            })
        }
        assert!(new_whl.is_ok());
    }

    #[test]
    fn test_backoff_429_retry_after_zero() {
        let mut headers = HeaderMap::new();
        headers.insert("retry-after", HeaderValue::from_static("0"));
        let dur = compute_discord_backoff(StatusCode::TOO_MANY_REQUESTS, &headers).expect("should backoff");
        // Expect only jitter 50..=200ms
        assert!(dur.as_millis() as u64 >= 50 && dur.as_millis() as u64 <= 200);
    }

    #[test]
    fn test_backoff_remaining_zero_reset_after() {
        let mut headers = HeaderMap::new();
        headers.insert("x-ratelimit-remaining", HeaderValue::from_static("0"));
        headers.insert("x-ratelimit-reset-after", HeaderValue::from_static("0.2"));
        let dur = compute_discord_backoff(StatusCode::BAD_REQUEST, &headers).expect("should backoff");
        // base 200ms + jitter 50..=200
        let ms = dur.as_millis() as u64;
        assert!(ms >= 200 && ms <= 450, "ms={} not in expected range", ms);
    }

    #[test]
    fn test_no_backoff_when_ok() {
        let headers = HeaderMap::new();
        let dur = compute_discord_backoff(StatusCode::OK, &headers);
        assert!(dur.is_none());
    }
}