Skip to main content

deps_core/
rate_limit.rs

1//! Local, process-lifetime rate-limit short-circuiting shared across ecosystem crates.
2//!
3//! Extracted from `deps-github-actions` and `deps-gitlab-ci` (#1205), which had
4//! independently implemented the identical mechanism.
5
6use crate::error::RateLimitEvidence;
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// Default cooldown, in seconds, a [`RateLimitGate`] keeps short-circuiting further requests
11/// after a rate-limit/auth-rejection trip, before allowing another live request.
12///
13/// Extracted from `deps-github-actions` and `deps-gitlab-ci`'s identical
14/// `RATE_LIMIT_COOLDOWN_SECS` constants (#1480 item 8) — long enough to meaningfully stop
15/// hammering a workspace with many unique packages, short enough to recover without a
16/// restart.
17pub const DEFAULT_COOLDOWN_SECS: u64 = 300;
18
19fn now_epoch_secs() -> u64 {
20    SystemTime::now()
21        .duration_since(UNIX_EPOCH)
22        .map_or(0, |d| d.as_secs())
23}
24
25/// Locally short-circuits further requests for a fixed cooldown once an upstream has
26/// rate-limited (or auth-rejected) this client.
27///
28/// A *mechanism*, not a policy: the gate holds no notion of scope. The caller decides
29/// whether one gate covers a whole process (`deps-github-actions`: one GitHub API, one
30/// gate) or one host each (`deps-gitlab-ci`, spec §9.3) by choosing where it stores it.
31///
32/// Both load and store use [`Ordering::Relaxed`]: a stale read costs at most one extra
33/// doomed request (if a trip is not yet visible) or one extra short-circuit (if a clear
34/// is not yet visible) — never a correctness violation, so no stronger ordering is needed.
35///
36/// # Examples
37///
38/// ```
39/// use deps_core::rate_limit::RateLimitGate;
40///
41/// let gate = RateLimitGate::new(300);
42/// assert!(!gate.is_tripped());
43/// gate.trip();
44/// assert!(gate.is_tripped());
45/// ```
46#[derive(Debug)]
47pub struct RateLimitGate {
48    /// Unix-epoch seconds at which the gate clears; `0` means "not tripped".
49    reset_at: AtomicU64,
50    cooldown_secs: u64,
51    /// Whether the trip currently in effect was backed by confirmed evidence rather than an
52    /// inferred guess (#1295 critic S3) — meaningless while [`Self::is_tripped`] is `false`.
53    /// Sees its own `Ordering::Relaxed` store *before* `reset_at`'s on [`Self::trip_verified`]/
54    /// [`Self::trip`], so a reader that observes `is_tripped() == true` on the same thread that
55    /// tripped it has always also observed the right `verified` value for that trip — the two
56    /// stores can still reorder across threads (this gate's whole contract already tolerates a
57    /// stale read costing at most one extra doomed request, never a correctness violation).
58    verified: AtomicBool,
59}
60
61impl RateLimitGate {
62    /// Creates an untripped gate whose [`Self::trip`]/[`Self::trip_verified`] blocks further
63    /// requests for `cooldown_secs`.
64    #[must_use]
65    pub const fn new(cooldown_secs: u64) -> Self {
66        Self {
67            reset_at: AtomicU64::new(0),
68            cooldown_secs,
69            verified: AtomicBool::new(false),
70        }
71    }
72
73    /// Whether a request should be short-circuited right now.
74    #[must_use]
75    pub fn is_tripped(&self) -> bool {
76        let reset_at = self.reset_at.load(Ordering::Relaxed);
77        reset_at != 0 && now_epoch_secs() < reset_at
78    }
79
80    /// Whether the trip currently in effect was tripped via [`Self::trip_verified`] rather
81    /// than the plain [`Self::trip`] (#1295 critic S3). Meaningless when [`Self::is_tripped`]
82    /// is `false` — a caller short-circuiting on a tripped gate should build its error to match
83    /// this, instead of always reporting an unverified guess for every call within the
84    /// cooldown window regardless of how the *first* call actually confirmed it.
85    #[must_use]
86    pub fn verified(&self) -> bool {
87        self.verified.load(Ordering::Relaxed)
88    }
89
90    /// [`Self::verified`], projected onto [`RateLimitEvidence`] — lets a caller building the
91    /// error for an already-tripped gate write `some_rate_limit_error(gate.evidence())` instead
92    /// of re-deriving the `if gate.verified() { .. } else { .. }` branch at every call site
93    /// (#1480 item 8: this replaces `GithubActionsRegistry::rate_limited_error`'s and
94    /// `GitlabCiRegistry::fetch_route`'s identical hand-rolled versions of this projection).
95    /// Meaningless when [`Self::is_tripped`] is `false`, exactly like [`Self::verified`] itself.
96    #[must_use]
97    pub fn evidence(&self) -> RateLimitEvidence {
98        if self.verified() {
99            RateLimitEvidence::Confirmed
100        } else {
101            RateLimitEvidence::Inferred
102        }
103    }
104
105    /// Trips the gate until `now + cooldown_secs`, recording that this trip was *not* backed
106    /// by confirmed evidence (see [`Self::verified`]).
107    pub fn trip(&self) {
108        self.trip_with(false);
109    }
110
111    /// Like [`Self::trip`], but records that this trip *was* backed by confirmed evidence —
112    /// [`Self::verified`] then returns `true` for as long as this trip stays in effect.
113    pub fn trip_verified(&self) {
114        self.trip_with(true);
115    }
116
117    fn trip_with(&self, verified: bool) {
118        self.verified.store(verified, Ordering::Relaxed);
119        self.reset_at
120            .store(now_epoch_secs() + self.cooldown_secs, Ordering::Relaxed);
121    }
122
123    #[cfg(test)]
124    fn trip_until_epoch_secs(&self, reset_at: u64) {
125        self.reset_at.store(reset_at, Ordering::Relaxed);
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_rate_limit_gate_starts_untripped() {
135        let gate = RateLimitGate::new(300);
136        assert!(!gate.is_tripped());
137    }
138
139    #[test]
140    fn test_rate_limit_gate_trips_and_stays_tripped_within_cooldown() {
141        let gate = RateLimitGate::new(300);
142        gate.trip();
143        assert!(gate.is_tripped());
144    }
145
146    #[test]
147    fn test_rate_limit_gate_clears_after_reset_time_passes() {
148        let gate = RateLimitGate::new(300);
149        gate.trip_until_epoch_secs(1); // far in the past
150        assert!(!gate.is_tripped());
151    }
152
153    #[test]
154    fn test_rate_limit_gate_starts_unverified() {
155        let gate = RateLimitGate::new(300);
156        assert!(!gate.verified());
157    }
158
159    #[test]
160    fn test_rate_limit_gate_trip_is_unverified() {
161        let gate = RateLimitGate::new(300);
162        gate.trip();
163        assert!(gate.is_tripped());
164        assert!(!gate.verified());
165    }
166
167    #[test]
168    fn test_rate_limit_gate_trip_verified_is_verified() {
169        let gate = RateLimitGate::new(300);
170        gate.trip_verified();
171        assert!(gate.is_tripped());
172        assert!(gate.verified());
173    }
174
175    /// #1295 critic S3: a later plain `trip()` (e.g. a subsequent unverified 403) must not
176    /// leave a stale `verified: true` from an earlier confirmed trip — each trip call
177    /// overwrites the flag for the whole cooldown window it starts.
178    #[test]
179    fn test_rate_limit_gate_trip_after_trip_verified_clears_verified_flag() {
180        let gate = RateLimitGate::new(300);
181        gate.trip_verified();
182        assert!(gate.verified());
183        gate.trip();
184        assert!(!gate.verified());
185    }
186}