spacetraders-client 0.1.0

Async Rust client for the SpaceTraders API v2 with priority-aware rate limiting and retry
Documentation
use tokio::time::{Duration, Instant};

use super::priority::RequestPriority;

/// A single queued waiter. `enqueued_at` drives aging so long-waiting requests
/// gradually climb in effective priority.
#[derive(Clone, Copy)]
pub(super) struct Waiter {
    pub(super) priority: RequestPriority,
    pub(super) seq: u64,
    pub(super) enqueued_at: Instant,
}

pub(super) struct State {
    pub(super) tokens: f64,
    pub(super) capacity: f64,
    pub(super) rate_per_sec: f64,
    pub(super) aging_interval: Duration,
    pub(super) last_refill: Instant,
    pub(super) waiters: Vec<Waiter>,
    pub(super) next_seq: u64,
}

#[derive(Clone, Copy, Debug)]
pub struct SchedulerPermit {
    pub queued: Duration,
    pub queue_depth: usize,
    pub rate_budget_remaining: f64,
}

impl State {
    pub(super) fn refill(&mut self, now: Instant) {
        let elapsed = now
            .saturating_duration_since(self.last_refill)
            .as_secs_f64();
        if elapsed > 0.0 {
            self.tokens = (self.tokens + elapsed * self.rate_per_sec).min(self.capacity);
            self.last_refill = now;
        }
    }

    /// How long until at least one token is available. `Duration::ZERO` when a
    /// token is ready right now.
    pub(super) fn time_until_token(&self) -> Duration {
        if self.tokens >= 1.0 {
            Duration::ZERO
        } else {
            Duration::from_secs_f64((1.0 - self.tokens) / self.rate_per_sec)
        }
    }

    /// Priority of a waiter after accounting for how long it has waited. The
    /// base priority is raised one level per `aging_interval` elapsed, capped at
    /// `Critical`.
    pub(super) fn effective_priority(&self, w: &Waiter, now: Instant) -> u32 {
        let base = w.priority as u32;
        if self.aging_interval.is_zero() {
            return base;
        }
        let waited = now.saturating_duration_since(w.enqueued_at).as_secs_f64();
        let bumps = (waited / self.aging_interval.as_secs_f64()) as u32;
        (base + bumps).min(RequestPriority::Critical as u32)
    }

    /// Sequence number of the waiter that should be served next: highest
    /// effective priority, ties broken FIFO (smallest sequence number).
    pub(super) fn best_seq(&self, now: Instant) -> Option<u64> {
        self.waiters
            .iter()
            .max_by(|a, b| {
                self.effective_priority(a, now)
                    .cmp(&self.effective_priority(b, now))
                    .then_with(|| b.seq.cmp(&a.seq))
            })
            .map(|w| w.seq)
    }

    pub(super) fn remove(&mut self, seq: u64) {
        self.waiters.retain(|w| w.seq != seq);
    }
}