Skip to main content

game_manager_lib/services/
rate_limiter.rs

1//! Limitadores de taxa compartilhados para APIs externas.
2//!
3//! Cada instância controla concorrência (via semáforo) e aplica exponential
4//! backoff em respostas de throttling (HTTP 429/403), compartilhado entre
5//! TODOS os comandos que chamam a respectiva API — não apenas dentro de um
6//! único fluxo de import/enrichment.
7
8use crate::constants::{
9    RAWG_BACKOFF_BASE_MS, RAWG_BACKOFF_MAX_RETRIES, RAWG_MAX_CONCURRENT_REQUESTS,
10    STEAM_BACKOFF_BASE_MS, STEAM_BACKOFF_MAX_RETRIES, STEAM_MAX_CONCURRENT_REQUESTS,
11};
12use lazy_static::lazy_static;
13use std::sync::Arc;
14use std::time::Duration;
15use tokio::sync::Semaphore;
16use tokio::time::sleep;
17use tracing::warn;
18
19pub struct ApiRateLimiter {
20    semaphore: Arc<Semaphore>,
21    max_retries: u32,
22    backoff_base_ms: u64,
23    name: &'static str, // só para os logs identificarem qual limiter disparou
24}
25
26impl ApiRateLimiter {
27    pub fn new(
28        max_concurrent: usize,
29        max_retries: u32,
30        backoff_base_ms: u64,
31        name: &'static str,
32    ) -> Self {
33        Self {
34            semaphore: Arc::new(Semaphore::new(max_concurrent)),
35            max_retries,
36            backoff_base_ms,
37            name,
38        }
39    }
40
41    /// Executa `call` respeitando o limite de concorrência desta instância, com exponential
42    /// backoff quando o erro indica throttling (429/403/502/503/504).
43    /// `call` é uma closure que retorna o future a cada tentativa — precisa ser `Fn` (não `FnOnce`)
44    /// porque pode ser chamada mais de uma vez em caso de retry.
45    pub async fn run<F, Fut, T>(&self, call: F) -> Result<T, String>
46    where
47        F: Fn() -> Fut,
48        Fut: std::future::Future<Output=Result<T, String>>,
49    {
50        let _permit = self.semaphore.acquire().await.map_err(|e| e.to_string())?;
51
52        let mut attempt = 0;
53        loop {
54            match call().await {
55                Ok(result) => return Ok(result),
56                Err(e) if is_throttling_error(&e) && attempt < self.max_retries => {
57                    attempt += 1;
58                    let backoff = self.backoff_base_ms * 2u64.pow(attempt - 1);
59                    warn!(
60                        "{}: throttling detectado, tentativa {}/{}, aguardando {}ms — {}",
61                        self.name, attempt, self.max_retries, backoff, e
62                    );
63                    sleep(Duration::from_millis(backoff)).await;
64                }
65                Err(e) => return Err(e),
66            }
67        }
68    }
69}
70
71lazy_static! {
72    pub static ref RAWG_LIMITER: ApiRateLimiter = ApiRateLimiter::new(
73        RAWG_MAX_CONCURRENT_REQUESTS as usize,
74        RAWG_BACKOFF_MAX_RETRIES,
75        RAWG_BACKOFF_BASE_MS,
76        "RAWG",
77    );
78    pub static ref STEAM_LIMITER: ApiRateLimiter = ApiRateLimiter::new(
79        STEAM_MAX_CONCURRENT_REQUESTS as usize,
80        STEAM_BACKOFF_MAX_RETRIES,
81        STEAM_BACKOFF_BASE_MS,
82        "Steam",
83    );
84}
85
86// === HELPERS ===
87
88/// Detecta se o erro indica throttling (não erro genérico de rede/parse).
89/// Depende das mensagens de erro incluírem o status HTTP — já é o caso hoje em
90/// `rawg.rs` e `steam_api.rs` (formato `"... Error: {status}"`).
91fn is_throttling_error(err: &str) -> bool {
92    err.contains("429")
93        || err.contains("403")
94        || err.contains("502")
95        || err.contains("503")
96        || err.contains("504")
97}