Skip to main content

throttle_net/
error.rs

1//! The domain error type.
2//!
3//! The acquire path is mostly infallible: it returns a [`Decision`](crate::Decision)
4//! or, for the waiting surface, simply succeeds once tokens are free. The one
5//! failure that no amount of waiting can fix is a request whose cost exceeds the
6//! limiter's capacity — that is reported as a [`ThrottleError`] rather than left
7//! to spin forever.
8//!
9//! [`ThrottleError`] implements [`error_forge::ForgeError`], so it carries the
10//! same kind/retryability metadata as every other domain error in the portfolio
11//! stack.
12
13use core::fmt;
14
15use error_forge::ForgeError;
16
17/// An acquisition that cannot complete.
18///
19/// The enum is `#[non_exhaustive]`: later phases introduce new failure modes
20/// (deadlines, a tripped circuit breaker, a closed limiter), so a `match` on it
21/// must include a wildcard arm.
22///
23/// # Examples
24///
25/// ```
26/// # async fn run() {
27/// use throttle_net::{Throttle, ThrottleError};
28///
29/// // Capacity is 5; asking for 9 can never be satisfied.
30/// let throttle = Throttle::per_second(5);
31/// let err = throttle.acquire_with_cost(9).await.unwrap_err();
32/// assert!(matches!(err, ThrottleError::CostExceedsCapacity { cost: 9, capacity: 5 }));
33/// # }
34/// ```
35#[non_exhaustive]
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ThrottleError {
38    /// The requested cost is larger than the limiter's capacity, so the bucket
39    /// can never hold enough tokens to grant it. Reduce the cost or raise the
40    /// limiter's capacity. This is a configuration mismatch, not a transient
41    /// condition, so it is **not** retryable.
42    CostExceedsCapacity {
43        /// The number of tokens the caller asked for.
44        cost: u32,
45        /// The limiter's maximum capacity, which `cost` exceeded.
46        capacity: u32,
47    },
48    /// A circuit breaker is open and shed this request without touching the
49    /// wrapped limiter. The breaker is failing fast to give the downstream time
50    /// to recover; retry once it has had a chance to close again. This **is**
51    /// retryable, after the suggested wait.
52    CircuitOpen {
53        /// How long until the breaker is expected to allow a trial request
54        /// (move to half-open). [`Duration::ZERO`](core::time::Duration::ZERO)
55        /// means it may already be admitting a trial.
56        retry_after: core::time::Duration,
57    },
58    /// A bounded queue was full and its overflow policy rejected this request.
59    /// Transient — capacity may free up — so it is retryable.
60    QueueFull,
61    /// A queued request's deadline passed before it could be served. Not
62    /// retryable as-is: the deadline is in the past.
63    DeadlineExceeded,
64}
65
66impl fmt::Display for ThrottleError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::CostExceedsCapacity { cost, capacity } => write!(
70                f,
71                "requested cost {cost} exceeds limiter capacity {capacity}; it can never be granted"
72            ),
73            Self::CircuitOpen { retry_after } => write!(
74                f,
75                "circuit breaker is open; request shed, retry in {retry_after:?}"
76            ),
77            Self::QueueFull => {
78                f.write_str("queue is full; request rejected by the overflow policy")
79            }
80            Self::DeadlineExceeded => {
81                f.write_str("request deadline passed before it could be served")
82            }
83        }
84    }
85}
86
87impl std::error::Error for ThrottleError {}
88
89impl ForgeError for ThrottleError {
90    fn kind(&self) -> &'static str {
91        match self {
92            Self::CostExceedsCapacity { .. } => "CostExceedsCapacity",
93            Self::CircuitOpen { .. } => "CircuitOpen",
94            Self::QueueFull => "QueueFull",
95            Self::DeadlineExceeded => "DeadlineExceeded",
96        }
97    }
98
99    fn caption(&self) -> &'static str {
100        "Throttle acquisition error"
101    }
102
103    fn is_retryable(&self) -> bool {
104        match self {
105            // A configuration mismatch will not fix itself.
106            Self::CostExceedsCapacity { .. } => false,
107            // The downstream may recover; retry after the breaker cools down.
108            Self::CircuitOpen { .. } => true,
109            // Capacity may free up.
110            Self::QueueFull => true,
111            // The deadline is already in the past.
112            Self::DeadlineExceeded => false,
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::ThrottleError;
120    use error_forge::ForgeError;
121
122    #[test]
123    fn test_display_names_both_values() {
124        let msg = ThrottleError::CostExceedsCapacity {
125            cost: 9,
126            capacity: 5,
127        }
128        .to_string();
129        assert!(msg.contains('9'));
130        assert!(msg.contains('5'));
131    }
132
133    #[test]
134    fn test_forge_kind_matches_variant() {
135        let err = ThrottleError::CostExceedsCapacity {
136            cost: 1,
137            capacity: 0,
138        };
139        assert_eq!(err.kind(), "CostExceedsCapacity");
140    }
141
142    #[test]
143    fn test_capacity_mismatch_is_not_retryable() {
144        // Retrying the same oversized cost on the same limiter never succeeds.
145        let err = ThrottleError::CostExceedsCapacity {
146            cost: 9,
147            capacity: 5,
148        };
149        assert!(!err.is_retryable());
150    }
151}