refactor: migrate infra to mytheclipse crates (retry, cache, event, ratelimit, config)
Deploy Scraper / build-and-deploy (push) Canceled after 0s

Replace hand-rolled infrastructure with the custom mytheclipse library:

- retry (src/infrastructure/scraping/retry.rs): backoff crate -> mytheclipse
  RetryConfig + retry() with retry_all predicate; same call-site helpers.
- cache (src/infrastructure/cache): deadpool raw AsyncCommands ->
  mytheclipse_cache::RedisCache bridge (src/infrastructure/cache/mytheclipse.rs),
  typed JSON wrapper keeps the Cache<'a> API used by use cases.
- events (src/events/bus.rs): custom broadcast pub/sub ->
  mytheclipse_event InMemoryEventBus + TypedEventBus alias.
- ratelimit (src/presentation/middleware/ratelimit.rs): hand-rolled window ->
  mytheclipse::RateLimiter token bucket.
- config (src/config/mod.rs): config crate -> mytheclipse_config ConfigLoader,
  preserving env-only fallback (config files optional).

No route/API/Redis-key changes. mytheclipse-cache path-dep points at local
source (redis 0.32 aligned with deadpool). Removed backoff/config deps.
This commit is contained in:
asepharyana
2026-08-30 18:53:00 +07:00
parent 6ebff90d5b
commit c7ffd29e79
14 changed files with 703 additions and 564 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ impl Application {
// App State components
let db_arc = Arc::new(db);
let event_bus = Arc::new(crate::events::bus::EventBus::new());
let event_bus = Arc::new(crate::events::bus::new_event_bus());
let redis_pool = crate::infrastructure::cache::redis_pool::redis_pool()
.map_err(|e| anyhow::anyhow!("Failed to init Redis pool: {}", e))?;
+36 -18
View File
@@ -5,7 +5,7 @@
//! - Fails fast at startup if required variables are missing
//! - Supports hierarchical configuration (default -> environment-specific)
use config::{Config, ConfigError, Environment, File};
use mytheclipse_config::ConfigError;
use serde::Deserialize;
use std::env;
use std::sync::LazyLock;
@@ -220,6 +220,8 @@ impl AppConfig {
/// 2. `config/{environment}.toml`
/// 3. `config/default.toml`
pub fn load() -> Result<Self, ConfigError> {
use mytheclipse_config::ConfigLoader;
// Load .env file first
if let Err(e) = dotenvy::dotenv() {
tracing::debug!("Could not load .env file: {}", e);
@@ -227,24 +229,40 @@ impl AppConfig {
let run_mode = env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
let config = Config::builder()
// Start with default config file
.add_source(File::with_name("config/default").required(false))
// Layer on environment-specific values
.add_source(File::with_name(&format!("config/{}", run_mode)).required(false))
// Add environment variables (with APP_ prefix)
.add_source(
Environment::with_prefix("APP")
.separator("__")
.try_parsing(true),
)
// Map legacy env vars to new config structure
.set_override_option("database_url", env::var("DATABASE_URL").ok())?
.set_override_option("jwt_secret", env::var("JWT_SECRET").ok())?
.set_override_option("redis_url", env::var("REDIS_URL").ok())?
.build()?;
// Start with default config file (optional — many deploys are env-only)
let mut loader = ConfigLoader::<AppConfig>::new()
.load_dotenv(std::path::Path::new(".env"))
.unwrap_or_else(|_| ConfigLoader::<AppConfig>::new());
config.try_deserialize()
let default_path = std::path::Path::new("config/default.toml");
if default_path.exists() {
loader = loader.merge_file(default_path)?;
}
// Layer on environment-specific values (optional)
let env_path_str = format!("config/{}.toml", run_mode);
let env_path = std::path::Path::new(&env_path_str);
if env_path.exists() {
loader = loader.merge_file(env_path)?;
}
// Add environment variables (with APP_ prefix)
loader = loader.merge_env("APP");
// Map legacy env vars to new config structure.
// ConfigLoader merges at leaf level; legacy vars override the merged
// value directly (highest priority after APP_*).
let mut value = loader.peek().clone();
if let Ok(v) = env::var("DATABASE_URL") {
value["database_url"] = serde_json::Value::String(v);
}
if let Ok(v) = env::var("JWT_SECRET") {
value["jwt_secret"] = serde_json::Value::String(v);
}
if let Ok(v) = env::var("REDIS_URL") {
value["redis_url"] = serde_json::Value::String(v);
}
ConfigLoader::<AppConfig>::new().merge_value(value).build()
}
/// Check if running in production mode
+18 -120
View File
@@ -1,146 +1,44 @@
//! Event bus implementation.
//! Event bus — delegated to `mytheclipse_event`.
//!
//! The scraper uses `TypedEventBus<InMemoryEventBus>` from the mytheclipse
//! event crate (JSON-typed pub/sub over an in-process broadcast bus).
//! Domain events are plain serde structs; `mytheclipse_event::Event` is a
//! blanket trait, so every `Serialize + DeserializeOwned + Send + Sync + 'static`
//! type is automatically an event — no manual impl needed.
use async_trait::async_trait;
use mytheclipse_event::{InMemoryEventBus, TypedEventBus};
use std::{any::TypeId, collections::HashMap, sync::Arc};
use tokio::sync::{broadcast, RwLock};
use tracing::{debug, info};
/// Event payload marker — re-exported so domain types can reference it.
pub use mytheclipse_event::Event;
/// Trait for events that can be published.
pub trait Event: Clone + Send + Sync + 'static {
/// Event name for logging/debugging.
const NAME: &'static str;
}
/// The scraper's application event bus.
pub type EventBus = TypedEventBus<InMemoryEventBus>;
/// Trait for event handlers.
#[async_trait]
pub trait EventHandler<E: Event>: Send + Sync {
async fn handle(&self, event: E);
}
/// The event bus for publishing and subscribing to events.
pub struct EventBus {
channels: RwLock<HashMap<TypeId, Box<dyn std::any::Any + Send + Sync>>>,
}
impl EventBus {
/// Create a new event bus.
pub fn new() -> Self {
Self {
channels: RwLock::new(HashMap::new()),
}
}
/// Publish an event to all subscribers.
pub async fn publish<E: Event>(&self, event: E) {
let type_id = TypeId::of::<E>();
let channels = self.channels.read().await;
if let Some(sender) = channels.get(&type_id) {
if let Some(tx) = sender.downcast_ref::<broadcast::Sender<E>>() {
let _ = tx.send(event);
debug!("Published event: {}", E::NAME);
}
}
}
/// Subscribe to events of a specific type.
/// Returns a receiver that can be used to receive events.
pub async fn subscribe<E: Event>(&self) -> broadcast::Receiver<E> {
let type_id = TypeId::of::<E>();
// Check if channel exists
{
let channels = self.channels.read().await;
if let Some(sender) = channels.get(&type_id) {
if let Some(tx) = sender.downcast_ref::<broadcast::Sender<E>>() {
return tx.subscribe();
}
}
}
// Create new channel
let (tx, rx) = broadcast::channel::<E>(100);
{
let mut channels = self.channels.write().await;
channels.insert(type_id, Box::new(tx));
}
// Re-get the receiver from the stored sender
let channels = self.channels.read().await;
if let Some(sender) = channels.get(&type_id) {
if let Some(tx) = sender.downcast_ref::<broadcast::Sender<E>>() {
return tx.subscribe();
}
}
rx
}
/// Register a handler for a specific event type.
/// The handler will be called whenever an event of that type is published.
pub async fn on<E: Event, H: EventHandler<E> + 'static>(&self, handler: H) {
let mut rx = self.subscribe::<E>().await;
let handler = Arc::new(handler);
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
handler.handle(event).await;
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("Event handler lagged by {} events", n);
}
}
}
});
info!("Registered handler for event: {}", E::NAME);
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new()
}
/// Build a new in-memory event bus.
pub fn new_event_bus() -> EventBus {
TypedEventBus::new(InMemoryEventBus::default())
}
// Common events
/// User registered event.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct UserRegistered {
pub user_id: String,
pub email: String,
pub name: String,
}
impl Event for UserRegistered {
const NAME: &'static str = "user.registered";
}
/// User logged in event.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct UserLoggedIn {
pub user_id: String,
pub ip_address: Option<String>,
}
impl Event for UserLoggedIn {
const NAME: &'static str = "user.logged_in";
}
/// Order created event.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct OrderCreated {
pub order_id: String,
pub user_id: String,
pub total: f64,
}
impl Event for OrderCreated {
const NAME: &'static str = "order.created";
}
+1
View File
@@ -1,3 +1,4 @@
pub mod mytheclipse;
pub mod redis;
pub mod redis_pool;
+54
View File
@@ -0,0 +1,54 @@
//! Bridge between the deadpool Redis pool and `mytheclipse_cache::RedisCache`.
//!
//! The scraper keeps its deadpool `Pool` (connection lifecycle, recycling) and
//! hands each checked-out connection to mytheclipse's `RedisCache` (which
//! implements the library `Cache` trait). Because both sides now share the
//! same `redis` crate version, a `deadpool_redis::Connection` can be converted
//! directly into a `redis::aio::MultiplexedConnection` via `take()`.
use std::sync::LazyLock;
use mytheclipse_cache::{Cache, CacheError, RedisCache};
use tokio::sync::OnceCell;
use crate::infrastructure::cache::redis_pool::redis_pool;
/// Lazily-initialised shared `RedisCache` built from the deadpool pool.
///
/// The multiplexed connection is cheaply cloneable (Arc-backed), so the whole
/// process shares one logical connection while deadpool manages recycling.
static REDIS_CACHE: LazyLock<OnceCell<RedisCache>> = LazyLock::new(OnceCell::new);
/// Return a handle to the shared mytheclipse `RedisCache`, initialising it on
/// first use from the deadpool pool.
pub async fn redis_cache() -> Result<&'static RedisCache, CacheError> {
let cell = &*REDIS_CACHE;
cell.get_or_try_init(|| async {
let pool = redis_pool().map_err(CacheError::Io)?;
let conn = pool
.get()
.await
.map_err(|e| CacheError::Io(e.to_string()))?;
let mux = deadpool_redis::Connection::take(conn);
Ok(RedisCache::new(mux))
})
.await
}
/// Convenience wrappers so callers can use the mytheclipse `Cache` methods
/// directly without importing the trait twice.
pub async fn get(key: &str) -> Result<Option<Vec<u8>>, CacheError> {
redis_cache().await?.get(key).await
}
pub async fn set(
key: &str,
value: Vec<u8>,
ttl: Option<std::time::Duration>,
) -> Result<(), CacheError> {
redis_cache().await?.set(key, value, ttl).await
}
pub async fn invalidate(key: &str) -> Result<(), CacheError> {
redis_cache().await?.invalidate(key).await
}
+43 -57
View File
@@ -1,70 +1,59 @@
//! Redis caching helpers.
//! Redis caching helpers — typed wrapper over `mytheclipse_cache`.
//!
//! The underlying byte-cache is `mytheclipse_cache::RedisCache` (which
//! implements the library `Cache` trait). This module keeps the ergonomic
//! typed JSON surface the use cases rely on (`get_or_set`, `set_with_ttl`)
//! while delegating the actual Redis commands to the library.
use deadpool_redis::redis::AsyncCommands;
use deadpool_redis::Pool;
use mytheclipse_cache::{Cache as CacheTrait, CacheError};
use serde::{de::DeserializeOwned, Serialize};
use tracing::{debug, error};
use tracing::debug;
/// Default cache TTL in seconds (5 minutes).
pub const DEFAULT_CACHE_TTL: u64 = 300;
/// Cache helper for Redis operations.
/// Typed JSON cache helper over the shared mytheclipse `RedisCache`.
pub struct Cache<'a> {
pool: &'a Pool,
_marker: std::marker::PhantomData<&'a ()>,
}
impl<'a> Cache<'a> {
pub fn new(pool: &'a Pool) -> Self {
Self { pool }
pub fn new(_pool: &'a deadpool_redis::Pool) -> Self {
Self {
_marker: std::marker::PhantomData,
}
}
async fn cache(&self) -> Result<&'static mytheclipse_cache::RedisCache, CacheError> {
super::mytheclipse::redis_cache().await
}
pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
let mut conn = match self.pool.get().await {
Ok(c) => c,
match self.cache().await {
Ok(cache) => match CacheTrait::get(cache, key).await {
Ok(Some(bytes)) => serde_json::from_slice(&bytes).ok(),
Ok(None) => None,
Err(e) => {
debug!("Cache: get error for {}: {}", key, e);
None
}
},
Err(e) => {
error!("Cache: failed to get connection: {}", e);
return None;
debug!("Cache: unavailable for {}: {}", key, e);
None
}
};
let cached: Option<String> = conn.get(key).await.ok()?;
if cached.is_some() {
debug!("Cache hit: {}", key);
} else {
debug!("Cache miss: {}", key);
}
cached.and_then(|json| serde_json::from_str(&json).ok())
}
pub async fn mget<T: DeserializeOwned>(&self, keys: &[String]) -> Vec<Option<T>> {
if keys.is_empty() {
return Vec::new();
}
let mut conn = match self.pool.get().await {
Ok(c) => c,
Err(e) => {
error!("Cache: failed to get connection: {}", e);
return std::iter::repeat_with(|| None).take(keys.len()).collect();
}
};
use deadpool_redis::redis::cmd;
let cached_values: Vec<Option<String>> =
match cmd("MGET").arg(keys).query_async(&mut conn).await {
Ok(v) => v,
Err(e) => {
error!("Cache: failed to mget values: {}", e);
return std::iter::repeat_with(|| None).take(keys.len()).collect();
}
};
cached_values
.into_iter()
.map(|opt_s| opt_s.and_then(|json| serde_json::from_str(&json).ok()))
.collect()
let mut out = Vec::with_capacity(keys.len());
for k in keys {
out.push(self.get::<T>(k).await);
}
out
}
pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), String> {
@@ -77,9 +66,10 @@ impl<'a> Cache<'a> {
value: &T,
ttl_secs: u64,
) -> Result<(), String> {
let mut conn = self.pool.get().await.map_err(|e| e.to_string())?;
let json = serde_json::to_string(value).map_err(|e| e.to_string())?;
conn.set_ex::<_, _, ()>(key, json, ttl_secs)
let json = serde_json::to_vec(value).map_err(|e| e.to_string())?;
let ttl = std::time::Duration::from_secs(ttl_secs);
let cache = self.cache().await.map_err(|e| e.to_string())?;
CacheTrait::set(cache, key, json, Some(ttl))
.await
.map_err(|e| e.to_string())?;
debug!("Cache: set key {} with TTL {}s", key, ttl_secs);
@@ -87,18 +77,14 @@ impl<'a> Cache<'a> {
}
pub async fn delete(&self, key: &str) -> Result<(), String> {
let mut conn = self.pool.get().await.map_err(|e| e.to_string())?;
conn.del::<_, ()>(key).await.map_err(|e| e.to_string())?;
debug!("Cache: deleted key {}", key);
Ok(())
let cache = self.cache().await.map_err(|e| e.to_string())?;
CacheTrait::invalidate(cache, key)
.await
.map_err(|e| e.to_string())
}
pub async fn exists(&self, key: &str) -> bool {
let mut conn = match self.pool.get().await {
Ok(c) => c,
Err(_) => return false,
};
conn.exists::<_, bool>(key).await.unwrap_or(false)
self.get::<serde_json::Value>(key).await.is_some()
}
/// Get or set: returns cached value or computes and caches new value.
+3 -7
View File
@@ -1,7 +1,6 @@
//! Otakudesu anime scraping repository.
use async_trait::async_trait;
use backoff::future::retry;
use tracing::{info, warn};
use crate::domain::entity::anime::{
@@ -13,7 +12,7 @@ use crate::domain::repository::ScrapingRepository;
use crate::infrastructure::repository::parsers::otakudesu_parser;
use crate::infrastructure::scraping::html_fetcher::fetch_html_with_retry;
use crate::infrastructure::scraping::proxy_fetch::fetch_with_proxy;
use crate::infrastructure::scraping::retry::{default_backoff, transient};
use crate::infrastructure::scraping::retry::{default_backoff, retry, retry_all};
const OTAKUDESU_BASE_URL: &str = "https://otakudesu.cloud";
@@ -208,14 +207,11 @@ impl OtakudesuRepository {
}
Err(e) => {
warn!("Failed to fetch URL: {}, error: {:?}", url_owned, e);
Err(transient(ScrapingError::Http(format!(
"Proxy fetch failed: {}",
e
))))
Err(ScrapingError::Http(format!("Proxy fetch failed: {}", e)))
}
}
};
retry(backoff, fetch_op)
retry(backoff, retry_all, fetch_op)
.await
.map_err(|e| ScrapingError::Http(e.to_string()))
}
+3 -4
View File
@@ -4,8 +4,7 @@
use crate::domain::error::ScrapingError;
use crate::infrastructure::scraping::proxy_fetch::fetch_with_proxy;
use crate::infrastructure::scraping::retry::{default_backoff, transient};
use backoff::future::retry;
use crate::infrastructure::scraping::retry::{default_backoff, retry, retry_all};
use regex::Regex;
use scraper::{ElementRef, Html, Selector};
use std::sync::LazyLock;
@@ -23,12 +22,12 @@ pub async fn fetch_html_with_retry(url: &str) -> Result<String, ScrapingError> {
}
Err(e) => {
warn!("Failed to fetch: {}, error: {:?}", url, e);
Err(transient(e))
Err(e)
}
}
};
retry(backoff, fetch_operation)
retry(backoff, retry_all, fetch_operation)
.await
.map_err(|e| ScrapingError::Http(e.to_string()))
}
+14 -12
View File
@@ -2,12 +2,11 @@
// Updated for sync Redis API, reqwest API changes, and concurrency optimization.
use dashmap::DashMap;
use redis::AsyncCommands;
use std::sync::LazyLock;
use tokio::sync::broadcast;
use tracing::{debug, error, warn};
use crate::infrastructure::cache::redis_pool::get_redis_conn;
use crate::infrastructure::cache::mytheclipse;
use crate::infrastructure::utils::cache_ttl::CACHE_TTL_VERY_SHORT;
use crate::infrastructure::utils::http::common_headers;
use crate::infrastructure::utils::http::is_internet_baik_block_page;
@@ -52,13 +51,13 @@ fn get_fetch_cache_key(slug: &str) -> String {
}
async fn get_cached_fetch(slug: &str) -> Result<Option<FetchResult>, AppError> {
let mut conn = get_redis_conn().await?;
let key = get_fetch_cache_key(slug);
let bytes = mytheclipse::get(&key)
.await
.map_err(|e| AppError::Internal(format!("Cache get failed for {}: {}", slug, e)))?;
let cached: Option<String> = conn.get(&key).await?;
if let Some(cached_str) = cached {
match serde_json::from_str::<FetchResult>(&cached_str) {
if let Some(bytes) = bytes {
match serde_json::from_slice::<FetchResult>(&bytes) {
Ok(parsed) => {
debug!("[fetchWithProxy] Returning cached response for {}", slug);
Ok(Some(parsed))
@@ -71,14 +70,17 @@ async fn get_cached_fetch(slug: &str) -> Result<Option<FetchResult>, AppError> {
}
async fn set_cached_fetch(slug: &str, value: &FetchResult) -> Result<(), AppError> {
let mut conn = get_redis_conn().await?;
let key = get_fetch_cache_key(slug);
let json_string = serde_json::to_string(value)?;
let json = serde_json::to_vec(value)?;
// Use standardized TTL
conn.set_ex::<_, _, ()>(&key, &json_string, CACHE_TTL_VERY_SHORT)
.await?;
Ok(())
mytheclipse::set(
&key,
json,
Some(std::time::Duration::from_secs(CACHE_TTL_VERY_SHORT)),
)
.await
.map_err(|e| AppError::Internal(format!("Cache set failed for {}: {}", slug, e)))
}
// --- REDIS CACHE WRAPPER END ---
+98 -38
View File
@@ -1,16 +1,21 @@
//! HTTP retry utilities with exponential backoff.
//! HTTP retry utilities — delegated to mytheclipse `retry` primitives.
//!
//! The scraper uses mytheclipse's retry machinery (`RetryConfig` +
//! `mytheclipse::retry`). These helpers keep the old backoff-style call
//! sites ergonomic while delegating the actual backoff/sleep/jitter logic
//! to the library.
use backoff::ExponentialBackoff;
use mytheclipse::{JitterKind, RetryConfig};
use std::time::Duration;
/// Default retry configuration for HTTP requests.
pub fn default_backoff() -> ExponentialBackoff {
ExponentialBackoff {
initial_interval: Duration::from_millis(500),
max_interval: Duration::from_secs(10),
multiplier: 2.0,
max_elapsed_time: Some(Duration::from_secs(30)),
..Default::default()
pub fn default_backoff() -> RetryConfig {
RetryConfig {
max_attempts: 4,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(10),
factor: 2.0,
jitter: JitterKind::Full,
}
}
@@ -20,47 +25,102 @@ pub fn custom_backoff(
max_secs: u64,
multiplier: f64,
max_elapsed_secs: u64,
) -> ExponentialBackoff {
ExponentialBackoff {
initial_interval: Duration::from_millis(initial_ms),
max_interval: Duration::from_secs(max_secs),
multiplier,
max_elapsed_time: Some(Duration::from_secs(max_elapsed_secs)),
..Default::default()
) -> RetryConfig {
RetryConfig {
max_attempts: compute_attempts(initial_ms, max_secs, multiplier, max_elapsed_secs).max(1),
base_delay: Duration::from_millis(initial_ms),
max_delay: Duration::from_secs(max_secs),
factor: multiplier,
jitter: JitterKind::Full,
}
}
/// Estimate the number of attempts that fit in `max_elapsed_secs` given the
/// exponential backoff curve: solve `sum(base * factor^i) ≈ max_elapsed`.
fn compute_attempts(initial_ms: u64, max_secs: u64, multiplier: f64, max_elapsed_secs: u64) -> u32 {
if initial_ms == 0 || multiplier <= 1.0 {
return 1;
}
let mut elapsed_ms = 0u64;
let mut attempt = 0u64;
let max_ms = max_secs.saturating_mul(1000);
let budget_ms = max_elapsed_secs.saturating_mul(1000);
let mut delay_ms = initial_ms;
while elapsed_ms < budget_ms {
elapsed_ms = elapsed_ms.saturating_add(delay_ms);
attempt += 1;
delay_ms = ((delay_ms as f64) * multiplier).min(max_ms as f64) as u64;
}
attempt as u32
}
/// Quick backoff for fast retries (3 attempts, 100ms initial).
pub fn quick_backoff() -> ExponentialBackoff {
ExponentialBackoff {
initial_interval: Duration::from_millis(100),
max_interval: Duration::from_secs(1),
multiplier: 2.0,
max_elapsed_time: Some(Duration::from_secs(5)),
..Default::default()
pub fn quick_backoff() -> RetryConfig {
RetryConfig {
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(1),
factor: 2.0,
jitter: JitterKind::Full,
}
}
/// Slow backoff for long operations (10 attempts, 1s initial).
pub fn slow_backoff() -> ExponentialBackoff {
ExponentialBackoff {
initial_interval: Duration::from_secs(1),
max_interval: Duration::from_secs(30),
multiplier: 2.0,
max_elapsed_time: Some(Duration::from_secs(120)),
..Default::default()
pub fn slow_backoff() -> RetryConfig {
RetryConfig {
max_attempts: 10,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(30),
factor: 2.0,
jitter: JitterKind::Full,
}
}
/// Make an error transient (will be retried).
pub fn transient<E>(err: E) -> backoff::Error<E> {
backoff::Error::transient(err)
/// Re-export mytheclipse retry for convenience.
pub use mytheclipse::retry;
/// Re-export transient/permanent helpers for API compatibility.
///
/// The legacy `backoff` crate distinguished transient vs permanent errors at
/// the error-type level; mytheclipse uses a retry predicate instead. All
/// scraped-HTTP failures are transient by nature (network/5xx), so both
/// helpers return the error unchanged and every call site retries everything
/// (`|_| true`). `permanent` is kept as a no-op alias for source
/// compatibility.
pub fn transient<E>(e: E) -> E {
e
}
/// Make an error permanent (will NOT be retried).
pub fn permanent<E>(err: E) -> backoff::Error<E> {
backoff::Error::permanent(err)
/// No-op alias for source compatibility (see [`transient`]).
pub fn permanent<E>(e: E) -> E {
e
}
// Re-export retry function for convenience
pub use backoff::future::retry;
/// Predicate used by all scraper retry loops: retry every error.
pub fn retry_all<E>(_: &E) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_configs_build() {
assert_eq!(default_backoff().max_attempts, 4);
assert_eq!(quick_backoff().max_attempts, 3);
assert_eq!(slow_backoff().max_attempts, 10);
}
#[test]
fn compute_attempts_curve() {
// 100ms base, 2x, 1s max, 5s budget → roughly 6 attempts.
let n = compute_attempts(100, 1, 2.0, 5);
assert!(n >= 4 && n <= 8, "got {n}");
}
#[test]
fn retry_all_retries() {
assert!(retry_all::<std::io::Error>(&std::io::Error::other("x")));
}
}
+19 -33
View File
@@ -1,4 +1,8 @@
//! Rate limiting middleware.
//! Rate limiting middleware — backed by `mytheclipse::RateLimiter`.
//!
//! The limiter is a token bucket from the mytheclipse core crate. The
//! middleware keeps the same axum shape (State<Arc<RateLimiter>>) and
//! `check()` semantics, but delegates token accounting to the library.
use axum::{
extract::Request,
@@ -7,42 +11,24 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use mytheclipse::RateLimiter;
use std::sync::Arc;
use crate::presentation::dto::common::ApiResponse;
/// Simple in-memory rate limiter.
pub struct RateLimiter {
max_requests: u64,
window_secs: u64,
counter: AtomicU64,
window_start: Mutex<Instant>,
/// Convenience alias so callers don't need the mytheclipse import.
pub type AppRateLimiter = RateLimiter;
/// Build a rate limiter (rate = requests/sec, burst = max burst capacity).
pub fn new_rate_limiter(rate_per_sec: f64, burst: u64) -> Arc<RateLimiter> {
Arc::new(RateLimiter::new(rate_per_sec, burst))
}
impl RateLimiter {
pub fn new(max_requests: u64, window_secs: u64) -> Arc<Self> {
Arc::new(Self {
max_requests,
window_secs,
counter: AtomicU64::new(0),
window_start: Mutex::new(Instant::now()),
})
}
pub fn check(&self) -> bool {
let Ok(mut window_guard) = self.window_start.lock() else {
return false;
};
let window = &mut *window_guard;
if window.elapsed().as_secs() >= self.window_secs {
*window = Instant::now();
self.counter.store(0, Ordering::SeqCst);
}
let count = self.counter.fetch_add(1, Ordering::SeqCst);
count < self.max_requests
}
/// Compatibility constructor matching the old (max_requests, window_secs) API.
/// Converts a fixed window into an equivalent token-bucket rate.
pub fn new_window_rate_limiter(max_requests: u64, window_secs: u64) -> Arc<RateLimiter> {
let rate_per_sec = max_requests as f64 / window_secs.max(1) as f64;
new_rate_limiter(rate_per_sec, max_requests)
}
pub async fn rate_limit_middleware(
@@ -50,7 +36,7 @@ pub async fn rate_limit_middleware(
request: Request,
next: Next,
) -> Response {
if state.check() {
if state.try_acquire().is_ok() {
next.run(request).await
} else {
(