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}