use crate::cdn::PurgeApi;
use crate::Error;
use async_trait::async_trait;
use std::collections::VecDeque;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::Instant;
const BUNNY_RATE_LIMIT_WINDOW: Duration = Duration::from_secs(10);
const BUNNY_RATE_LIMIT_MAX: usize = 100;
#[derive(Clone)]
pub struct BunnyCdnConfig {
pub cdn_base_url: String,
pub access_key: String,
}
impl std::fmt::Debug for BunnyCdnConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BunnyCdnConfig")
.field("cdn_base_url", &self.cdn_base_url)
.field("access_key", &"<redacted>")
.finish()
}
}
impl BunnyCdnConfig {
pub fn from_env() -> Self {
Self {
cdn_base_url: std::env::var("BUNNY_CDN_URL").unwrap_or_default(),
access_key: std::env::var("BUNNY_ACCESS_KEY").unwrap_or_default(),
}
}
}
pub struct BunnyCdn {
config: BunnyCdnConfig,
client: reqwest::Client,
request_times: Mutex<VecDeque<Instant>>,
}
impl BunnyCdn {
pub fn new(config: BunnyCdnConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
request_times: Mutex::new(VecDeque::new()),
}
}
async fn throttle(&self) {
loop {
let mut times = self.request_times.lock().await;
let now = Instant::now();
while times
.front()
.map(|t| now.duration_since(*t) >= BUNNY_RATE_LIMIT_WINDOW)
.unwrap_or(false)
{
times.pop_front();
}
if times.len() < BUNNY_RATE_LIMIT_MAX {
times.push_back(Instant::now());
return;
}
let oldest = *times.front().unwrap();
let sleep_for = BUNNY_RATE_LIMIT_WINDOW - now.duration_since(oldest);
drop(times);
tokio::time::sleep(sleep_for).await;
}
}
}
#[async_trait]
impl PurgeApi for BunnyCdn {
async fn purge(&self, paths: &[String]) -> Result<(), Error> {
if paths.is_empty() {
return Ok(());
}
if self.config.access_key.is_empty() {
return Err(Error::cdn("BUNNY_ACCESS_KEY not set"));
}
if self.config.cdn_base_url.is_empty() {
return Err(Error::cdn("BUNNY_CDN_URL not set"));
}
for path in paths {
self.throttle().await;
let full_url = format!(
"{}/{}",
self.config.cdn_base_url.trim_end_matches('/'),
path.trim_start_matches('/')
);
let resp = self
.client
.post("https://api.bunny.net/purge")
.query(&[("url", full_url.as_str()), ("async", "false")])
.header("AccessKey", &self.config.access_key)
.send()
.await
.map_err(|e| Error::cdn(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(Error::cdn(format!("Bunny purge status {status}: {body}")));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_cdn() -> BunnyCdn {
BunnyCdn::new(BunnyCdnConfig {
cdn_base_url: "https://myzone.b-cdn.net".to_string(),
access_key: "test-key".to_string(),
})
}
#[tokio::test]
async fn bunny_adapter_empty_noop() {
valid_cdn().purge(&[]).await.unwrap();
}
#[tokio::test]
async fn bunny_adapter_missing_key_errors() {
let cdn = BunnyCdn::new(BunnyCdnConfig {
cdn_base_url: "https://myzone.b-cdn.net".to_string(),
access_key: "".to_string(),
});
let err = cdn.purge(&["index.html".to_string()]).await.unwrap_err();
assert!(err.to_string().contains("BUNNY_ACCESS_KEY"), "Error: {err}");
}
#[tokio::test]
async fn bunny_adapter_missing_cdn_url_errors() {
let cdn = BunnyCdn::new(BunnyCdnConfig {
cdn_base_url: "".to_string(),
access_key: "test-key".to_string(),
});
let err = cdn.purge(&["index.html".to_string()]).await.unwrap_err();
assert!(err.to_string().contains("BUNNY_CDN_URL"), "Error: {err}");
}
}