refactor: migrate infra to mytheclipse crates (retry, cache, event, ratelimit, config)
Deploy Scraper / build-and-deploy (push) Canceled after 0s
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:
@@ -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
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
|
||||
Vendored
+1
@@ -1,3 +1,4 @@
|
||||
pub mod mytheclipse;
|
||||
pub mod redis;
|
||||
pub mod redis_pool;
|
||||
|
||||
|
||||
+54
@@ -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
|
||||
}
|
||||
Vendored
+43
-57
@@ -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.
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user