pub use cdk_common::rate_limit::{
RateLimitConfig, RateLimitedTransport, RateLimiterManager, TokenBucket,
};
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
pub use cdk_http_client::TorAsync;
pub use cdk_http_client::{Async, Transport};
#[cfg(all(test, not(target_arch = "wasm32")))]
mod persistence_tests {
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, Instant};
use cdk_common::database;
use url::Url;
use super::{RateLimitConfig, RateLimiterManager, TokenBucket};
use crate::cdk_database::WalletDatabase;
fn config(capacity: u32, refill_per_minute: u32) -> RateLimitConfig {
RateLimitConfig::new(
NonZeroU32::new(capacity).unwrap_or(NonZeroU32::MIN),
NonZeroU32::new(refill_per_minute).unwrap_or(NonZeroU32::MIN),
)
}
fn parse(url: &str) -> Url {
Url::parse(url).expect("valid url")
}
async fn store() -> Arc<dyn WalletDatabase<database::Error> + Send + Sync> {
Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("in-memory wallet database"),
)
}
#[tokio::test]
async fn manager_shares_one_bucket_per_origin() {
let manager = RateLimiterManager::new(config(2, 300), Some(store().await));
let a = manager.bucket_for(&parse("https://mint.example.com/v1/keys"));
let b = manager.bucket_for(&parse("https://mint.example.com:443/v1/swap"));
assert!(a.try_acquire().await);
assert!(b.try_acquire().await);
assert!(!a.try_acquire().await, "shared origin budget is drained");
assert!(!b.try_acquire().await, "shared origin budget is drained");
let other = manager.bucket_for(&parse("https://other.example.com"));
assert!(other.try_acquire().await, "distinct origin is independent");
}
#[tokio::test]
async fn manager_evicts_recovered_unreferenced_bucket() {
let manager = RateLimiterManager::new(config(5, 300), Some(store().await));
drop(manager.bucket_for(&parse("https://a.example.com")));
assert_eq!(manager.origin_count(), 1);
let _b = manager.bucket_for(&parse("https://b.example.com"));
assert_eq!(manager.origin_count(), 1);
}
#[tokio::test]
async fn manager_retains_referenced_bucket() {
let manager = RateLimiterManager::new(config(5, 300), Some(store().await));
let _held = manager.bucket_for(&parse("https://a.example.com"));
let _b = manager.bucket_for(&parse("https://b.example.com"));
assert_eq!(manager.origin_count(), 2);
}
#[tokio::test]
async fn manager_retains_unrecovered_bucket() {
let manager = RateLimiterManager::new(config(5, 300), Some(store().await));
let bucket_a = manager.bucket_for(&parse("https://a.example.com"));
for _ in 0..5 {
bucket_a.try_acquire().await;
}
drop(bucket_a);
let _b = manager.bucket_for(&parse("https://b.example.com"));
assert_eq!(manager.origin_count(), 2);
}
#[tokio::test]
async fn shared_bucket_survives_concurrent_writers() {
use std::sync::atomic::{AtomicUsize, Ordering};
let store = store().await;
let url = parse("https://concurrent.example.com/v1/info");
let cfg = config(5, 300);
let manager = RateLimiterManager::new(cfg, Some(store.clone()));
let a = manager.bucket_for(&url);
let b = manager.bucket_for(&url);
let admitted = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for bucket in [a.clone(), b.clone(), a.clone(), b.clone()] {
let admitted = admitted.clone();
handles.push(tokio::spawn(async move {
for _ in 0..10 {
if bucket.try_acquire().await {
admitted.fetch_add(1, Ordering::SeqCst);
}
}
}));
}
for handle in handles {
handle.await.unwrap();
}
assert_eq!(
admitted.load(Ordering::SeqCst),
5,
"concurrent handles share one burst, not one each"
);
a.flush().await;
drop((a, b, manager));
let fresh = RateLimiterManager::new(cfg, Some(store)).bucket_for(&url);
let start = Instant::now();
fresh.acquire(async {}).await;
fresh.acquire(async {}).await;
assert!(
start.elapsed() >= Duration::from_millis(150),
"inherited budget should pace, took {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn manager_flush_covers_every_origin() {
let store = store().await;
let cfg = config(2, 300);
let urls = [
parse("https://a.example.com/v1/info"),
parse("https://b.example.com/v1/info"),
];
let first = RateLimiterManager::new(cfg, Some(store.clone()));
for url in &urls {
let bucket = first.bucket_for(url);
bucket.acquire(async {}).await;
bucket.acquire(async {}).await;
}
first.flush().await;
drop(first);
let second = RateLimiterManager::new(cfg, Some(store));
for url in &urls {
let bucket = second.bucket_for(url);
let start = Instant::now();
bucket.acquire(async {}).await;
bucket.acquire(async {}).await;
assert!(
start.elapsed() >= Duration::from_millis(150),
"{url} should inherit its drained budget, took {:?}",
start.elapsed()
);
}
}
#[tokio::test]
async fn budget_persists_across_instances() {
let store = store().await;
let url = parse("https://persist.example.com/.well-known/lnurlp/alice");
let cfg = config(2, 300);
let first = RateLimiterManager::new(cfg, Some(store.clone())).bucket_for(&url);
first.acquire(async {}).await;
first.acquire(async {}).await;
first.flush().await;
drop(first);
let second = RateLimiterManager::new(cfg, Some(store)).bucket_for(&url);
let start = Instant::now();
second.acquire(async {}).await;
second.acquire(async {}).await;
assert!(
start.elapsed() >= Duration::from_millis(150),
"inherited bucket should pace, took {:?}",
start.elapsed()
);
let fresh = TokenBucket::new(cfg);
let start = Instant::now();
fresh.acquire(async {}).await;
fresh.acquire(async {}).await;
assert!(start.elapsed() < Duration::from_millis(100));
}
}