steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
use std::{
    num::NonZeroU32,
    sync::{
        LazyLock,
        OnceLock,
        atomic::{AtomicU64, Ordering},
    },
    time::{Duration, Instant},
};

use governor::{Quota, RateLimiter};

use crate::endpoint::Host;

/// Type alias for the global Steam rate limiter.
pub(crate) type SteamRateLimiter = RateLimiter<governor::state::NotKeyed, governor::state::InMemoryState, governor::clock::QuantaClock, governor::middleware::NoOpMiddleware<governor::clock::QuantaInstant>>;

/// Epoch for lockout calculations: first time `LOCKOUT_UNTIL` is read/written.
/// All lockout deadlines are stored as nanoseconds relative to this epoch,
/// so that comparison is monotonic and immune to wall-clock jumps.
static LOCKOUT_EPOCH: OnceLock<Instant> = OnceLock::new();

/// Nanoseconds after `LOCKOUT_EPOCH` at which the lockout expires.
/// Zero means "no lockout". Stored as `u64` so `fetch_max` works.
static LOCKOUT_UNTIL_NS: AtomicU64 = AtomicU64::new(0);

/// Returns the epoch, initialising it on first call.
fn lockout_epoch() -> Instant {
    *LOCKOUT_EPOCH.get_or_init(Instant::now)
}

/// Returns how many nanoseconds have elapsed since `LOCKOUT_EPOCH`.
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"))));

/// Per-host quotas. Numbers are conservative defaults observed in practice;
/// override at runtime if necessary. `help.steampowered.com` is intentionally
/// strict because the recovery wizard there will lock an account on rapid
/// retries.
fn quota_for(host: Host) -> Quota {
    match host {
        // Most volume — community pages, market, friends — handled by the
        // existing global ~30/min quota. Per-host bucket on top stays loose.
        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")),
        // WebAPI is generally lenient; tighten if we observe 429s.
        Host::Api => Quota::per_minute(NonZeroU32::new(120).expect("nz")).allow_burst(NonZeroU32::new(120).expect("nz")),
        // Recovery wizard: very strict. 5/min is intentional throttling on
        // top of the human-paced wizard flow.
        Host::Help => Quota::per_minute(NonZeroU32::new(5).expect("nz")).allow_burst(NonZeroU32::new(5).expect("nz")),
        // Short-link redirector. Each hit is a single 302 then the real
        // request proceeds against Community/Store, where its own quota
        // applies. 30/min is plenty for human-paced invite flows.
        Host::ShortLink => Quota::per_minute(NonZeroU32::new(30).expect("nz")).allow_burst(NonZeroU32::new(30).expect("nz")),
    }
}

/// Per-host limiters. Each variant is dispatched by explicit `match` so that
/// a future enum reorder cannot silently index the wrong bucket.
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,
    }
}

/// Wait for a per-host permit. Called in addition to the global limiter so
/// one host getting hammered cannot starve another.
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);
    }
}

/// Waits until a rate-limit permit is available and adds randomized jitter.
pub async fn wait_for_permit() {
    // 1. Check for active lockout (monotonic: Instant-based nanos)
    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;

            // Jitter on wake-up to avoid thundering-herd when many tasks
            // are waiting on the same lockout (mirrors steam-client limiter).
            use rand::Rng;
            let wake_jitter = rand::rng().random_range(100..1100);
            tokio::time::sleep(Duration::from_millis(wake_jitter)).await;
            continue;
        }
        break;
    }

    // 2. Wait for governor permit
    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);
    }

    // 3. Add randomized jitter (50ms - 250ms)
    use rand::Rng;
    let jitter_ms = rand::rng().random_range(50..250);
    tokio::time::sleep(Duration::from_millis(jitter_ms)).await;
}

/// Penalizes the global limiter by locking it for a specific duration.
///
/// Uses `fetch_max` so a longer existing lockout is never clobbered by a
/// shorter new one. Monotonic: based on `Instant`, not `SystemTime`.
///
/// This should be called when an HTTP 429 (Too Many Requests) is received.
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);
}