Skip to content

Commit 0149c21

Browse files
authored
Merge pull request #56 from semiotic-ai/fix/rate-limit-zero-validation
fix: reject zero arguments in RateLimitLayer constructors
2 parents f85b32a + 6d09f95 commit 0149c21

3 files changed

Lines changed: 87 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4545

4646
### Fixed
4747

48+
- `RateLimitLayer::new`, `::per_second`, and `::with_min_delay` now panic
49+
at construction when given zero arguments, instead of silently building
50+
a degenerate layer. Previously `RateLimitLayer::per_second(0)` produced
51+
a token bucket with capacity zero and a refill rate of zero, so the
52+
first acquire computed `wait_nanos = 1.0 / 0.0 = +inf` and the service
53+
stalled indefinitely on the first request; `with_min_delay(Duration::
54+
ZERO)` produced one whose `refill_rate` was `+inf` and whose refill
55+
arithmetic tainted the token count with `NaN`, leaving every subsequent
56+
acquire returning implementation-defined wait times. An operator who
57+
loaded a per-chain pacing config from a TOML/YAML file where
58+
`min_delay_ms` defaulted to `0` — or who wrote `with_rate_limit(0)`
59+
thinking it disabled the rate-limit budget — would see flaky throughput
60+
or a hung provider with no error message pointing at the misconfigured
61+
layer. The constructors now reject both shapes with a panic that names
62+
the invalid axis and points operators at the documented "leave the axis
63+
unset on `ProviderConfig` to disable" idiom, so misconfiguration
64+
surfaces at construction time rather than as runtime pathology. Closes
65+
#53.
4866
- Provider pools built through `ProviderPoolBuilder::with_rpc_policy`
4967
now honour the policy's per-chain rate-limit delay. Operators who
5068
configured `SemioscanConfigBuilder::chain_rate_limit(...)` or

src/provider/factory.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use super::AnyHttpProvider;
3030
/// Precedence: when both axes are set, `rate_limit_per_second` wins and
3131
/// `min_delay` is dropped with a warn. This matches the documented
3232
/// `ProviderPoolBuilder` precedence and the historical HTTP behaviour.
33+
#[track_caller]
3334
pub(super) fn rate_limit_layer_for(
3435
rate_limit_per_second: Option<u32>,
3536
min_delay: Option<Duration>,

src/transport/rate_limit.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ impl RateLimitLayer {
5151
/// * `requests` - Maximum number of requests allowed in the given period
5252
/// * `period` - The time period for the rate limit
5353
///
54+
/// # Panics
55+
///
56+
/// Panics if `requests` is `0` or `period` is [`Duration::ZERO`]. Either
57+
/// value would produce a degenerate token bucket whose math cannot
58+
/// represent a finite rate: a zero budget would stall every request
59+
/// indefinitely on the first acquire, and a zero refill period would
60+
/// taint the refill rate with `inf`/`NaN` so subsequent acquires return
61+
/// implementation-defined wait times. If you want to disable pacing,
62+
/// leave the relevant axis on [`ProviderConfig`](crate::provider::ProviderConfig)
63+
/// unset (i.e. `None`) instead of passing zero.
64+
///
5465
/// # Example
5566
///
5667
/// ```rust
@@ -63,7 +74,18 @@ impl RateLimitLayer {
6374
/// // Allow 100 requests per minute
6475
/// let layer = RateLimitLayer::new(100, Duration::from_secs(60));
6576
/// ```
77+
#[track_caller]
6678
pub fn new(requests: u32, period: Duration) -> Self {
79+
assert!(
80+
requests > 0,
81+
"RateLimitLayer requires requests > 0; got 0. \
82+
To disable rate limiting, leave the axis unset on ProviderConfig instead of passing zero."
83+
);
84+
assert!(
85+
!period.is_zero(),
86+
"RateLimitLayer requires period > 0; got Duration::ZERO. \
87+
To disable pacing, leave min_delay unset on ProviderConfig instead of passing Duration::ZERO."
88+
);
6789
Self {
6890
state: Arc::new(Mutex::new(RateLimitState::new(requests, period))),
6991
}
@@ -73,6 +95,11 @@ impl RateLimitLayer {
7395
///
7496
/// This is a convenience constructor for common rate limiting scenarios.
7597
///
98+
/// # Panics
99+
///
100+
/// Panics if `requests` is `0`. See [`RateLimitLayer::new`] for the
101+
/// reasoning and how to express "no rate limit" instead.
102+
///
76103
/// # Example
77104
///
78105
/// ```rust
@@ -81,6 +108,7 @@ impl RateLimitLayer {
81108
/// // 25 requests per second
82109
/// let layer = RateLimitLayer::per_second(25);
83110
/// ```
111+
#[track_caller]
84112
pub fn per_second(requests: u32) -> Self {
85113
Self::new(requests, Duration::from_secs(1))
86114
}
@@ -90,6 +118,11 @@ impl RateLimitLayer {
90118
/// This is useful when you want to ensure a fixed delay between
91119
/// consecutive requests rather than allowing bursts.
92120
///
121+
/// # Panics
122+
///
123+
/// Panics if `delay` is [`Duration::ZERO`]. See [`RateLimitLayer::new`]
124+
/// for the reasoning and how to express "no pacing" instead.
125+
///
93126
/// # Example
94127
///
95128
/// ```rust
@@ -99,6 +132,7 @@ impl RateLimitLayer {
99132
/// // At least 100ms between requests (max 10 req/s)
100133
/// let layer = RateLimitLayer::with_min_delay(Duration::from_millis(100));
101134
/// ```
135+
#[track_caller]
102136
pub fn with_min_delay(delay: Duration) -> Self {
103137
Self::new(1, delay)
104138
}
@@ -269,6 +303,40 @@ mod tests {
269303
assert!(layer.state.lock().await.capacity == 25);
270304
}
271305

306+
// The three panic tests below pin the constructor contract documented in
307+
// each `# Panics` section. Without them, `RateLimitLayer::per_second(0)`
308+
// builds a layer that stalls every request indefinitely on the first
309+
// acquire (capacity = 0, refill_rate = 0 ⇒ wait_nanos = +inf), and
310+
// `RateLimitLayer::with_min_delay(Duration::ZERO)` builds one whose
311+
// refill math taints `tokens` with `NaN` so every subsequent acquire
312+
// returns implementation-defined wait times. Catching this at
313+
// construction makes the misconfiguration visible immediately instead
314+
// of as flaky throughput or a hung provider in production.
315+
316+
#[test]
317+
#[should_panic(expected = "requests > 0")]
318+
fn test_rate_limit_layer_new_rejects_zero_requests() {
319+
let _ = RateLimitLayer::new(0, Duration::from_secs(1));
320+
}
321+
322+
#[test]
323+
#[should_panic(expected = "period > 0")]
324+
fn test_rate_limit_layer_new_rejects_zero_period() {
325+
let _ = RateLimitLayer::new(10, Duration::ZERO);
326+
}
327+
328+
#[test]
329+
#[should_panic(expected = "requests > 0")]
330+
fn test_rate_limit_per_second_rejects_zero() {
331+
let _ = RateLimitLayer::per_second(0);
332+
}
333+
334+
#[test]
335+
#[should_panic(expected = "period > 0")]
336+
fn test_rate_limit_with_min_delay_rejects_zero() {
337+
let _ = RateLimitLayer::with_min_delay(Duration::ZERO);
338+
}
339+
272340
#[tokio::test]
273341
async fn test_rate_limit_enforces_rate() {
274342
// Service that returns immediately

0 commit comments

Comments
 (0)