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}