zero_engine_client/rate_budget.rs
1//! Token-bucket rate budget sitting between [`crate::HttpClient`] and
2//! the network.
3//!
4//! # Why a CLI-side bucket exists at all
5//!
6//! The engine has its own rate limiter; it will return 429 if the
7//! operator hammers an endpoint. A CLI-side bucket is not a
8//! redundancy — it is a **courtesy layer** that:
9//!
10//! 1. **Refuses visibly, not silently.** An operator who types
11//! `/status` twenty times in five seconds must read a refusal
12//! line naming the budget, not watch the prompt freeze for a
13//! retry loop. The freeze is the classic mystery stall that
14//! erodes trust in a CLI; a typed `rate: exhausted — retry in Ns`
15//! is fixable.
16//! 2. **Protects the engine's own budget from blocking other
17//! operators.** The engine's bucket is per-operator; our CLI
18//! getting rate-limited by a local heuristic preserves headroom
19//! for Auto-mode + Telegram paths that operate without typing
20//! operators hammering them.
21//! 3. **Is an anchor for the status bar segment.** `rate:N/M`
22//! paints the current bucket fill in the always-visible tier;
23//! this module is the source of truth the widget reads.
24//!
25//! # Determinism under test
26//!
27//! The bucket's time source is a `Clock` trait — production uses
28//! `SystemClock` (thin wrapper over `std::time::Instant`), tests use
29//! `ManualClock` and advance explicitly. Every budget assertion in
30//! the test suite is wall-clock-free and therefore flake-free.
31//!
32//! # Thread safety
33//!
34//! `RateBudget` is `Arc<Inner>` where `Inner` holds a `parking_lot::
35//! Mutex<State>`. The critical section is a handful of float math
36//! ops; `parking_lot`'s uncontended lock is ~25 ns, well under the
37//! network RTT the bucket guards. A sharded / atomic design would
38//! buy nothing and cost legibility.
39//!
40//! # What the bucket is **not**
41//!
42//! - Not a global limiter. Multiple operators, multiple CLIs, even
43//! multiple `HttpClient`s in the same process each hold their
44//! own bucket. Cross-process coordination would require IPC that
45//! buys less than it costs at M2 scale.
46//! - Not persistent. A CLI restart gets a fresh full bucket. An
47//! operator who exhausted their budget and then restarted the CLI
48//! could bypass the local refusal — but they would still be
49//! subject to the engine's own limiter, so the net effect is a
50//! 5-10 second delay + the engine's 429 path running in lieu of
51//! ours. The complexity of persisting to `~/.zero/state/rate.json`
52//! is not worth that narrow hole.
53
54use std::sync::Arc;
55use std::time::{Duration, Instant};
56
57use parking_lot::Mutex;
58
59/// Wall-clock abstraction so the bucket can be exercised in tests
60/// without sleeping. Only `now()` is on the trait surface; every
61/// internal math op is pure and does not need further mocking.
62pub trait Clock: Send + Sync + 'static {
63 /// A monotonically-nondecreasing instant. Callers must rely on
64 /// the return values' ordering, not their absolute origin.
65 fn now(&self) -> Instant;
66}
67
68/// Wall-clock implementation — the one production always uses.
69#[derive(Debug, Default, Clone, Copy)]
70pub struct SystemClock;
71
72impl Clock for SystemClock {
73 fn now(&self) -> Instant {
74 Instant::now()
75 }
76}
77
78/// Manual clock for tests. Wrap `Arc<ManualClock>`; a test that
79/// wants to advance the clock holds a handle next to the
80/// `RateBudget` it feeds into.
81///
82/// Uses a `Mutex<Instant>` rather than atomics because the `Instant`
83/// type is not trivially atomic across platforms and a test-only
84/// code path is not where cycle-shaving matters.
85#[derive(Debug)]
86pub struct ManualClock {
87 now: Mutex<Instant>,
88}
89
90impl ManualClock {
91 /// Seed at an arbitrary instant. Callers should treat the
92 /// seed as opaque — only deltas matter.
93 #[must_use]
94 pub fn new() -> Arc<Self> {
95 Arc::new(Self {
96 now: Mutex::new(Instant::now()),
97 })
98 }
99
100 /// Advance the clock by `d`. Useful for `advance(Duration::
101 /// from_secs(5))` to let the bucket refill, etc.
102 pub fn advance(&self, d: Duration) {
103 let mut t = self.now.lock();
104 *t += d;
105 }
106}
107
108impl Clock for ManualClock {
109 fn now(&self) -> Instant {
110 *self.now.lock()
111 }
112}
113
114/// Per-endpoint cost table. The costs themselves live here as
115/// `const` so tests, docs, the status bar widget, and the budget
116/// itself all read from a single source — otherwise a change to
117/// the cost of `/v2/status` in the client and a stale cost in the
118/// doctor row is a silent drift waiting to happen.
119///
120/// Costs follow M2_PLAN §1:
121///
122/// - **1 point:** the cheap rollups the CLI reads for chrome —
123/// `/`, `/health`, `/status`, `/risk`, `/positions`, `/brief`,
124/// `/regime`, `/approaching`, `/rejections`, `/hl/status`, `/hl/account`,
125/// `/hl/reconcile`, `/immune`, `/live/cockpit`, `/live/certification`,
126/// `/live/canary-policy`, `/runtime/parity`, `/market/quote`, `/operator/state`,
127/// `/operator/context`, and `POST /operator/events` (append-only, cheap).
128/// - **2 points:** endpoints that trigger meaningful engine work —
129/// `/evaluate/{coin}` (runs the verdict pipeline against live
130/// features) and `/pulse` (journals out a recent event cross-
131/// section).
132/// - **3 points:** composite endpoints — `/v2/status`, which joins
133/// several sub-objects into a single payload.
134///
135/// The path's query string is stripped before lookup so that
136/// `/evaluate/BTC?foo=1` costs the same as `/evaluate/BTC`.
137#[must_use]
138pub fn cost_of(path: &str) -> u32 {
139 // Strip the query string if present; match only on the path
140 // prefix so /evaluate/{coin} covers every coin without a
141 // per-coin row.
142 let path = path.split('?').next().unwrap_or(path);
143 if path.starts_with("/evaluate") || path == "/pulse" || path.starts_with("/pulse?") {
144 2
145 } else if path == "/v2/status" {
146 3
147 } else {
148 1
149 }
150}
151
152/// Default bucket capacity — the burst size. 60 tokens lets a busy
153/// operator run ~20 `/v2/status` renders (cost 3 each) in a tight
154/// burst without hitting the floor, which covers the observed
155/// peak-typing pattern (rapid `/status` + `/risk` + `/positions`
156/// walk at session open).
157pub const DEFAULT_CAPACITY: u32 = 60;
158
159/// Default refill rate — 1 token per second. 60 per minute
160/// sustained matches the engine's per-operator 429 floor observed
161/// in the existing Python surface (see `engine/zero/auth.py`'s
162/// `_RATE_LIMIT` constant, ~60 reqs/min). Staying under it means
163/// the CLI-side bucket trips before the engine's ever does,
164/// guaranteeing the operator sees a typed refusal rather than a
165/// blanket 429.
166pub const DEFAULT_REFILL_PER_SECOND: f64 = 1.0;
167
168#[derive(Debug)]
169struct State {
170 /// Current token count. Stored as `f64` so the per-tick
171 /// accrual (`refill_per_sec * elapsed_seconds`) does not
172 /// truncate to zero between two sub-second calls — a common
173 /// mistake in integer-bucket implementations is losing
174 /// sub-second accrual. Capped at `capacity` on every refill.
175 tokens: f64,
176 last_refill: Instant,
177}
178
179struct Inner {
180 capacity: u32,
181 refill_per_second: f64,
182 clock: Arc<dyn Clock>,
183 state: Mutex<State>,
184}
185
186// Hand-rolled `Debug` because `dyn Clock` is intentionally not
187// `Debug`-bound (minimal trait surface) and the auto-derive would
188// refuse. The fields we can render are the ones a `#[derive]`
189// would have surfaced anyway.
190impl std::fmt::Debug for Inner {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.debug_struct("Inner")
193 .field("capacity", &self.capacity)
194 .field("refill_per_second", &self.refill_per_second)
195 .field("state", &self.state)
196 .finish_non_exhaustive()
197 }
198}
199
200/// A cloneable, thread-safe token bucket. Cheap to clone (bumps an
201/// `Arc`); the inner state is shared, which is the whole point.
202#[derive(Clone)]
203pub struct RateBudget {
204 inner: Arc<Inner>,
205}
206
207impl std::fmt::Debug for RateBudget {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 let snap = self.snapshot();
210 f.debug_struct("RateBudget")
211 .field("capacity", &snap.capacity)
212 .field("refill_per_second", &snap.refill_per_second)
213 .field("tokens", &snap.tokens)
214 .finish()
215 }
216}
217
218/// Read-only view of the bucket state. Returned by
219/// [`RateBudget::snapshot`] for the doctor row and the status-bar
220/// widget — neither should hold the internal mutex.
221#[derive(Debug, Clone, Copy, PartialEq)]
222pub struct BudgetSnapshot {
223 pub capacity: u32,
224 pub refill_per_second: f64,
225 /// Current tokens, floored to integer for display. Callers who
226 /// want the raw float do not exist today; if one shows up,
227 /// add a separate accessor rather than growing this row.
228 pub tokens: u32,
229}
230
231impl BudgetSnapshot {
232 /// Fraction of capacity still available, in `0.0..=1.0`. The
233 /// status-bar widget uses this to pick a color band:
234 ///
235 /// - ≥ 0.25 → primary
236 /// - 0.10..0.25 → caution
237 /// - < 0.10 → alert
238 /// - 0.0 → `EXH` (rendered by the widget, not this number)
239 #[must_use]
240 pub fn headroom(&self) -> f64 {
241 if self.capacity == 0 {
242 0.0
243 } else {
244 f64::from(self.tokens) / f64::from(self.capacity)
245 }
246 }
247}
248
249/// What `try_consume` returns when the bucket cannot satisfy the
250/// requested cost. `retry_after` is how long (rounded up) until
251/// enough tokens will have accrued to complete the call.
252///
253/// Rounding up matters: an operator-visible "retry in 0s" is a
254/// lie when the real answer is "retry in 400 ms" — we floor to
255/// the next whole second so the countdown in the status bar and
256/// the HttpError it becomes never advertise a shorter wait than
257/// is actually needed.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct Exhausted {
260 pub retry_after: Duration,
261}
262
263impl RateBudget {
264 /// Build a bucket with the default capacity + refill. Clock
265 /// is [`SystemClock`] (the only right answer in production).
266 #[must_use]
267 pub fn default_system() -> Self {
268 Self::with_clock(
269 DEFAULT_CAPACITY,
270 DEFAULT_REFILL_PER_SECOND,
271 Arc::new(SystemClock),
272 )
273 }
274
275 /// Build a bucket with explicit capacity, refill rate, and
276 /// clock. Panics if `capacity == 0` (a zero-capacity bucket
277 /// is never usable and always exhausts; callers intending
278 /// "infinitely permissive" should not wire the bucket in at
279 /// all) or `refill_per_second < 0.0` (a negative refill is
280 /// incoherent). A refill of 0.0 is permitted — useful in tests
281 /// that want to prove the exhaustion path without accrual
282 /// confounding the observation.
283 ///
284 /// # Panics
285 ///
286 /// If `capacity == 0` or `refill_per_second.is_sign_negative()`
287 /// or `refill_per_second.is_nan()`. All three are programmer
288 /// errors caught at construction rather than surfaced as
289 /// silent "never allows anything" behavior downstream.
290 #[must_use]
291 pub fn with_clock(capacity: u32, refill_per_second: f64, clock: Arc<dyn Clock>) -> Self {
292 assert!(capacity > 0, "rate-budget capacity must be > 0");
293 assert!(
294 refill_per_second.is_finite() && !refill_per_second.is_sign_negative(),
295 "rate-budget refill must be a finite, non-negative float (got {refill_per_second})"
296 );
297 let now = clock.now();
298 let state = State {
299 tokens: f64::from(capacity),
300 last_refill: now,
301 };
302 Self {
303 inner: Arc::new(Inner {
304 capacity,
305 refill_per_second,
306 clock,
307 state: Mutex::new(state),
308 }),
309 }
310 }
311
312 /// Attempt to consume `cost` tokens. Returns `Ok(())` on
313 /// success (the bucket has been debited), or `Err(Exhausted)`
314 /// with a floor-rounded `retry_after` when the bucket cannot
315 /// satisfy the cost.
316 ///
317 /// A cost that exceeds `capacity` can never be satisfied —
318 /// the caller gets an `Exhausted { retry_after }` shaped as
319 /// "forever-ish" (`Duration::MAX`). This is a misconfiguration
320 /// signal, not a transient error; the caller should surface it
321 /// loudly. In practice the cost table's highest value (3) is
322 /// well below the default capacity (60), so this path is a
323 /// developer-error canary, not a production concern.
324 pub fn try_consume(&self, cost: u32) -> Result<(), Exhausted> {
325 let cost_f = f64::from(cost);
326 let mut state = self.inner.state.lock();
327 self.refill_locked(&mut state);
328
329 if state.tokens >= cost_f {
330 state.tokens -= cost_f;
331 return Ok(());
332 }
333
334 // Cost exceeds what even a full bucket can satisfy —
335 // permanent exhaustion (as far as this bucket is
336 // concerned). Returning `Duration::MAX` is intentional:
337 // downstream HttpError surfaces it as "call the budget
338 // broken," not as a countdown the operator might wait
339 // through.
340 if cost_f > f64::from(self.inner.capacity) {
341 return Err(Exhausted {
342 retry_after: Duration::MAX,
343 });
344 }
345
346 // The bucket will have `cost_f - state.tokens` more tokens
347 // after `deficit / refill_per_second` seconds. Floor to the
348 // next whole second so the caller's countdown is never
349 // shorter than the truth; if refill is zero, we stall
350 // forever (and signal it via `Duration::MAX`).
351 let deficit = cost_f - state.tokens;
352 let retry = if self.inner.refill_per_second > 0.0 {
353 let secs = (deficit / self.inner.refill_per_second).ceil();
354 // `secs` is finite and non-negative here (deficit > 0,
355 // refill > 0). `Duration::try_from_secs_f64` clamps to
356 // the representable range on the high end and would
357 // fail on NaN — which we already ruled out. A plain
358 // `unwrap_or(Duration::MAX)` is both honest (saturates
359 // to the "forever" sentinel on pathological refills)
360 // and keeps clippy happy without an allow-list dance
361 // around manual `as u64` casts.
362 let candidate = Duration::try_from_secs_f64(secs).unwrap_or(Duration::MAX);
363 candidate.max(Duration::from_secs(1))
364 } else {
365 Duration::MAX
366 };
367 Err(Exhausted { retry_after: retry })
368 }
369
370 /// Refund `cost` tokens — used when an outer rate limiter
371 /// (the engine's own 429) fires after we already debited our
372 /// local bucket. Without this, every 429 would double-charge
373 /// the operator: once against our bucket, once against the
374 /// engine's. Capped at `capacity` so a runaway refund bug
375 /// cannot inflate the bucket beyond its design size.
376 pub fn refund(&self, cost: u32) {
377 let mut state = self.inner.state.lock();
378 state.tokens = (state.tokens + f64::from(cost)).min(f64::from(self.inner.capacity));
379 }
380
381 /// Snapshot for display. Runs the refill pass so the returned
382 /// token count reflects elapsed time, not the last `try_consume`
383 /// ago — a status bar that reads `rate:40/60` when the real
384 /// answer is `rate:55/60` paints a fake scarcity.
385 #[must_use]
386 pub fn snapshot(&self) -> BudgetSnapshot {
387 let mut state = self.inner.state.lock();
388 self.refill_locked(&mut state);
389 BudgetSnapshot {
390 capacity: self.inner.capacity,
391 refill_per_second: self.inner.refill_per_second,
392 // Saturate: f64 → u32 via `as u32` with the caller
393 // guaranteeing the value is within the bucket range.
394 // `state.tokens` is in `[0, capacity]` by construction
395 // so saturation is belt-and-braces only.
396 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
397 tokens: state.tokens.floor().max(0.0).min(f64::from(u32::MAX)) as u32,
398 }
399 }
400
401 /// Force the bucket full. Only wired through
402 /// `zero doctor --fix` (M2_PLAN §1's clear-counter action) —
403 /// operator confirmation is required before this runs, because
404 /// bypassing the local bucket has no production use case. The
405 /// name is loud on purpose.
406 pub fn reset_to_full(&self) {
407 let mut state = self.inner.state.lock();
408 state.tokens = f64::from(self.inner.capacity);
409 state.last_refill = self.inner.clock.now();
410 }
411
412 /// Advance `state.tokens` by the accrual since `last_refill`
413 /// and update `last_refill` to the current clock reading.
414 ///
415 /// Accrual is `elapsed_seconds * refill_per_second`, capped at
416 /// `capacity`. Called under the state lock; the `_locked`
417 /// suffix reminds the reader of that invariant.
418 fn refill_locked(&self, state: &mut State) {
419 let now = self.inner.clock.now();
420 let elapsed = now.duration_since(state.last_refill);
421 if elapsed.is_zero() {
422 return;
423 }
424 let accrual = elapsed.as_secs_f64() * self.inner.refill_per_second;
425 state.tokens = (state.tokens + accrual).min(f64::from(self.inner.capacity));
426 state.last_refill = now;
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 fn bucket(cap: u32, refill: f64) -> (RateBudget, Arc<ManualClock>) {
435 let clock = ManualClock::new();
436 let b = RateBudget::with_clock(cap, refill, clock.clone());
437 (b, clock)
438 }
439
440 #[test]
441 fn costs_follow_spec_table() {
442 // Cheap rollups.
443 assert_eq!(cost_of("/status"), 1);
444 assert_eq!(cost_of("/risk"), 1);
445 assert_eq!(cost_of("/positions"), 1);
446 assert_eq!(cost_of("/brief"), 1);
447 assert_eq!(cost_of("/regime"), 1);
448 assert_eq!(cost_of("/operator/state"), 1);
449 assert_eq!(cost_of("/operator/events"), 1);
450 assert_eq!(cost_of("/approaching"), 1);
451 assert_eq!(cost_of("/rejections"), 1);
452 assert_eq!(cost_of("/hl/status"), 1);
453 assert_eq!(cost_of("/hl/status?symbol=BTC"), 1);
454 assert_eq!(cost_of("/hl/account"), 1);
455 assert_eq!(cost_of("/hl/reconcile"), 1);
456 assert_eq!(cost_of("/live/cockpit"), 1);
457 assert_eq!(cost_of("/live/certification"), 1);
458 assert_eq!(cost_of("/live/canary-policy"), 1);
459 assert_eq!(cost_of("/runtime/parity"), 1);
460 assert_eq!(cost_of("/market/quote?symbol=BTC"), 1);
461
462 // Engine-work endpoints.
463 assert_eq!(cost_of("/evaluate/BTC"), 2);
464 assert_eq!(cost_of("/evaluate/BTC?side=long"), 2);
465 assert_eq!(cost_of("/pulse"), 2);
466 assert_eq!(cost_of("/pulse?limit=50"), 2);
467
468 // Composite.
469 assert_eq!(cost_of("/v2/status"), 3);
470 }
471
472 #[test]
473 fn new_bucket_is_full() {
474 let (b, _clock) = bucket(10, 1.0);
475 assert_eq!(b.snapshot().tokens, 10);
476 }
477
478 #[test]
479 fn consume_debits_tokens() {
480 let (b, _clock) = bucket(10, 0.0);
481 assert!(b.try_consume(3).is_ok());
482 assert_eq!(b.snapshot().tokens, 7);
483 assert!(b.try_consume(7).is_ok());
484 assert_eq!(b.snapshot().tokens, 0);
485 }
486
487 #[test]
488 fn consume_exhaustion_returns_floored_retry_after() {
489 // Cap 10, no refill → consume all, next call says "never."
490 let (b, _clock) = bucket(10, 0.0);
491 assert!(b.try_consume(10).is_ok());
492 let err = b.try_consume(1).unwrap_err();
493 assert_eq!(err.retry_after, Duration::MAX);
494 }
495
496 #[test]
497 fn consume_exhaustion_countdown_rounds_up() {
498 // Cap 10, 1 token/sec. Consume 10, ask for 3. The bucket
499 // will have 3 tokens after 3 seconds. Countdown must be 3 s,
500 // not 2 s, not 4 s.
501 let (b, _clock) = bucket(10, 1.0);
502 b.try_consume(10).unwrap();
503 let err = b.try_consume(3).unwrap_err();
504 assert_eq!(err.retry_after, Duration::from_secs(3));
505 }
506
507 #[test]
508 fn consume_exhaustion_fractional_deficit_rounds_up() {
509 // Cap 10, 2 tokens/sec. Consume 10, ask for 3. Refill rate
510 // says "1.5 seconds" — the operator-visible retry must be
511 // 2 s (ceiling), never 1 s.
512 let (b, _clock) = bucket(10, 2.0);
513 b.try_consume(10).unwrap();
514 let err = b.try_consume(3).unwrap_err();
515 assert_eq!(err.retry_after, Duration::from_secs(2));
516 }
517
518 #[test]
519 fn refill_accrues_over_clock_advance() {
520 let (b, clock) = bucket(10, 1.0);
521 b.try_consume(10).unwrap();
522 clock.advance(Duration::from_secs(5));
523 // 5 tokens accrued.
524 assert_eq!(b.snapshot().tokens, 5);
525 }
526
527 #[test]
528 fn refill_caps_at_capacity() {
529 let (b, clock) = bucket(10, 1.0);
530 b.try_consume(2).unwrap();
531 // Advance by far more than the capacity deficit — the
532 // bucket must not overflow.
533 clock.advance(Duration::from_secs(1000));
534 assert_eq!(b.snapshot().tokens, 10);
535 }
536
537 #[test]
538 fn sub_second_accrual_does_not_floor_to_zero() {
539 // A naive integer-bucket would lose the 100 ms accrual
540 // here and sit at `tokens = 0` forever under rapid polling.
541 // The float-accumulator implementation must track it.
542 let (b, clock) = bucket(10, 10.0); // 10 tokens/sec
543 b.try_consume(10).unwrap();
544 clock.advance(Duration::from_millis(100)); // 1 token
545 clock.advance(Duration::from_millis(100)); // 2 tokens
546 assert_eq!(b.snapshot().tokens, 2);
547 }
548
549 #[test]
550 fn refund_restores_tokens_without_exceeding_capacity() {
551 let (b, _clock) = bucket(10, 0.0);
552 b.try_consume(5).unwrap();
553 b.refund(5);
554 assert_eq!(b.snapshot().tokens, 10);
555 // Extra refund is a no-op (bug shield).
556 b.refund(5);
557 assert_eq!(b.snapshot().tokens, 10);
558 }
559
560 #[test]
561 fn reset_to_full_refills_the_bucket() {
562 let (b, _clock) = bucket(10, 0.0);
563 b.try_consume(10).unwrap();
564 assert_eq!(b.snapshot().tokens, 0);
565 b.reset_to_full();
566 assert_eq!(b.snapshot().tokens, 10);
567 }
568
569 #[test]
570 fn headroom_bands_are_legible() {
571 let snap = BudgetSnapshot {
572 capacity: 60,
573 refill_per_second: 1.0,
574 tokens: 60,
575 };
576 assert!((snap.headroom() - 1.0).abs() < f64::EPSILON);
577 let half = BudgetSnapshot { tokens: 30, ..snap };
578 assert!((half.headroom() - 0.5).abs() < f64::EPSILON);
579 let empty = BudgetSnapshot { tokens: 0, ..snap };
580 assert!(empty.headroom().abs() < f64::EPSILON);
581 }
582
583 #[test]
584 fn cost_above_capacity_returns_permanent_exhaustion() {
585 // A 60-token bucket cannot satisfy a 61-cost call. The
586 // misconfiguration signal must be a `Duration::MAX` retry
587 // so downstream renderers flag it as a config bug, not a
588 // countdown the operator might wait out.
589 let (b, _clock) = bucket(60, 1.0);
590 let err = b.try_consume(61).unwrap_err();
591 assert_eq!(err.retry_after, Duration::MAX);
592 }
593
594 #[test]
595 #[should_panic(expected = "capacity must be > 0")]
596 fn zero_capacity_panics_at_construction() {
597 let _ = RateBudget::with_clock(0, 1.0, Arc::new(SystemClock));
598 }
599
600 #[test]
601 #[should_panic(expected = "refill must be a finite, non-negative float")]
602 fn negative_refill_panics_at_construction() {
603 let _ = RateBudget::with_clock(10, -1.0, Arc::new(SystemClock));
604 }
605}