use std::{
num::NonZeroU32,
sync::{
LazyLock,
OnceLock,
atomic::{AtomicU64, Ordering},
},
time::{Duration, Instant},
};
use governor::{Quota, RateLimiter};
use crate::endpoint::Host;
pub(crate) type SteamRateLimiter = RateLimiter<governor::state::NotKeyed, governor::state::InMemoryState, governor::clock::QuantaClock, governor::middleware::NoOpMiddleware<governor::clock::QuantaInstant>>;
static LOCKOUT_EPOCH: OnceLock<Instant> = OnceLock::new();
static LOCKOUT_UNTIL_NS: AtomicU64 = AtomicU64::new(0);
fn lockout_epoch() -> Instant {
*LOCKOUT_EPOCH.get_or_init(Instant::now)
}
fn nanos_since_epoch() -> u64 {
Instant::now().duration_since(lockout_epoch()).as_nanos() as u64
}
static LIMITER: LazyLock<SteamRateLimiter> = LazyLock::new(|| RateLimiter::direct(Quota::per_minute(NonZeroU32::new(30).expect("non-zero literal")).allow_burst(NonZeroU32::new(50).expect("non-zero literal"))));
fn quota_for(host: Host) -> Quota {
match host {
Host::Community => Quota::per_minute(NonZeroU32::new(60).expect("nz")).allow_burst(NonZeroU32::new(60).expect("nz")),
Host::Store => Quota::per_minute(NonZeroU32::new(60).expect("nz")).allow_burst(NonZeroU32::new(60).expect("nz")),
Host::Api => Quota::per_minute(NonZeroU32::new(120).expect("nz")).allow_burst(NonZeroU32::new(120).expect("nz")),
Host::Help => Quota::per_minute(NonZeroU32::new(5).expect("nz")).allow_burst(NonZeroU32::new(5).expect("nz")),
Host::ShortLink => Quota::per_minute(NonZeroU32::new(30).expect("nz")).allow_burst(NonZeroU32::new(30).expect("nz")),
}
}
static HOST_LIMITERS: LazyLock<HostLimiters> = LazyLock::new(|| HostLimiters {
community: RateLimiter::direct(quota_for(Host::Community)),
store: RateLimiter::direct(quota_for(Host::Store)),
help: RateLimiter::direct(quota_for(Host::Help)),
api: RateLimiter::direct(quota_for(Host::Api)),
short_link: RateLimiter::direct(quota_for(Host::ShortLink)),
});
struct HostLimiters {
community: SteamRateLimiter,
store: SteamRateLimiter,
help: SteamRateLimiter,
api: SteamRateLimiter,
short_link: SteamRateLimiter,
}
fn limiter_for(host: Host) -> &'static SteamRateLimiter {
let hl = &*HOST_LIMITERS;
match host {
Host::Community => &hl.community,
Host::Store => &hl.store,
Host::Help => &hl.help,
Host::Api => &hl.api,
Host::ShortLink => &hl.short_link,
}
}
pub async fn wait_for_host_permit(host: Host) {
let start = std::time::Instant::now();
limiter_for(host).until_ready().await;
let waited = start.elapsed();
if waited > Duration::from_secs(2) {
tracing::warn!(host = %host, "per-host limiter throttled for {:?}", waited);
} else {
tracing::trace!(host = %host, "per-host permit granted after {:?}", waited);
}
}
pub async fn wait_for_permit() {
loop {
let now_ns = nanos_since_epoch();
let until_ns = LOCKOUT_UNTIL_NS.load(Ordering::Relaxed);
if until_ns > now_ns {
let wait_ms = (until_ns - now_ns) / 1_000_000;
tracing::warn!("Steam HTTP rate-limit LOCKOUT active. Waiting {}ms...", wait_ms);
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
use rand::Rng;
let wake_jitter = rand::rng().random_range(100..1100);
tokio::time::sleep(Duration::from_millis(wake_jitter)).await;
continue;
}
break;
}
let start = std::time::Instant::now();
LIMITER.until_ready().await;
let wait_time = start.elapsed();
if wait_time > Duration::from_secs(5) {
tracing::warn!("Steam request throttled for {:?}", wait_time);
} else {
tracing::trace!("Steam permit granted after {:?}", wait_time);
}
use rand::Rng;
let jitter_ms = rand::rng().random_range(50..250);
tokio::time::sleep(Duration::from_millis(jitter_ms)).await;
}
pub fn penalize_abuse(duration: Duration) {
let until_ns = nanos_since_epoch() + duration.as_nanos() as u64;
LOCKOUT_UNTIL_NS.fetch_max(until_ns, Ordering::Relaxed);
tracing::error!("Received HTTP 429. Locking global Steam limiter for {:?}", duration);
}