Skip to main content

steam_client/internal/
limiter.rs

1use std::{
2    num::NonZeroU32,
3    sync::{
4        atomic::{AtomicU64, Ordering},
5        Arc,
6    },
7    time::{Duration, SystemTime, UNIX_EPOCH},
8};
9
10use governor::{Quota, RateLimiter};
11use once_cell::sync::Lazy;
12use tokio::sync::Semaphore;
13
14/// Global lockout timestamp (ms since epoch).
15static LOCKOUT_UNTIL: AtomicU64 = AtomicU64::new(0);
16
17// Strict limiter for Web Auth (passwords) to avoid IP bans
18static WEB_AUTH_LIMITER: Lazy<RateLimiter<governor::state::NotKeyed, governor::state::InMemoryState, governor::clock::QuantaClock, governor::middleware::NoOpMiddleware<governor::clock::QuantaInstant>>> = Lazy::new(|| RateLimiter::direct(Quota::per_minute(NonZeroU32::new(3).unwrap()).allow_burst(NonZeroU32::new(5).unwrap())));
19
20// Lenient limiter for CM connections (tokens) to allow fast bulk reconnects
21static CM_CONNECTION_LIMITER: Lazy<RateLimiter<governor::state::NotKeyed, governor::state::InMemoryState, governor::clock::QuantaClock, governor::middleware::NoOpMiddleware<governor::clock::QuantaInstant>>> = Lazy::new(|| RateLimiter::direct(Quota::per_minute(NonZeroU32::new(30).unwrap()).allow_burst(NonZeroU32::new(50).unwrap())));
22
23/// Concurrency cap: at most 3 simultaneous password (WebAuth) logins
24/// process-wide. This prevents the cron job from firing 50+ simultaneous
25/// credential requests.
26static WEB_AUTH_SEMAPHORE: Lazy<Arc<Semaphore>> = Lazy::new(|| Arc::new(Semaphore::new(3)));
27
28pub enum LoginType {
29    WebAuth,
30    CMConnection,
31}
32
33/// RAII guard that holds a WebAuth semaphore permit.
34/// Drop to release the slot for the next queued login.
35pub struct WebAuthPermit {
36    _permit: tokio::sync::OwnedSemaphorePermit,
37}
38
39/// Acquires a concurrency slot for a WebAuth login.
40/// Callers should hold the returned `WebAuthPermit` for the duration of the
41/// login attempt (including cookie fetch) and drop it when done.
42pub async fn acquire_web_auth_slot() -> WebAuthPermit {
43    let permit = WEB_AUTH_SEMAPHORE.clone().acquire_owned().await.expect("semaphore closed");
44    WebAuthPermit { _permit: permit }
45}
46
47/// Waits until a rate-limit permit is available and adds randomized jitter.
48pub async fn wait_for_permit(login_type: LoginType) {
49    // 1. Check for active lockout
50    loop {
51        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64;
52        let lockout = LOCKOUT_UNTIL.load(Ordering::Acquire);
53
54        if lockout > now {
55            let wait_ms = lockout - now;
56            tracing::warn!("Steam login rate-limit LOCKOUT active. Waiting {}ms...", wait_ms);
57            tokio::time::sleep(Duration::from_millis(wait_ms)).await;
58
59            // 1.5 Add randomized jitter when waking up from lockout
60            // to mitigate "thundering herd" CPU spikes
61            let wake_jitter = rand::random::<u64>() % 1000 + 100;
62            tokio::time::sleep(Duration::from_millis(wake_jitter)).await;
63            continue;
64        }
65        break;
66    }
67
68    // 2. Wait for governor permit
69    let start = std::time::Instant::now();
70    match login_type {
71        LoginType::WebAuth => WEB_AUTH_LIMITER.until_ready().await,
72        LoginType::CMConnection => CM_CONNECTION_LIMITER.until_ready().await,
73    }
74    let wait_time = start.elapsed();
75
76    if wait_time > Duration::from_secs(5) {
77        tracing::warn!("Steam login throttled for {:?}", wait_time);
78    } else {
79        tracing::trace!("Steam login permit granted after {:?}", wait_time);
80    }
81
82    // 3. Add randomized jitter (50ms - 250ms)
83    // This makes the request pattern less predictable/bot-like
84    let jitter_ms = rand::random::<u64>() % 200 + 50;
85    tokio::time::sleep(Duration::from_millis(jitter_ms)).await;
86}
87
88/// Penalizes the global limiter by locking it for a specific duration.
89pub fn penalize_abuse(duration: Duration, reason: &str) {
90    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64;
91    let until = now + duration.as_millis() as u64;
92
93    // Only extend the lockout, never shorten it
94    let current = LOCKOUT_UNTIL.load(Ordering::Acquire);
95    if until > current {
96        LOCKOUT_UNTIL.store(until, Ordering::Release);
97    }
98    tracing::error!("Received {reason}. Locking global Steam login limiter for {:?}", duration);
99}