Skip to main content

polyoxide_venue/
class.rs

1//! The eight error classes, the trait every error type implements, and the one
2//! error a venue-neutral caller receives.
3
4use std::error::Error;
5use std::fmt;
6use std::sync::Arc;
7use std::time::Duration;
8
9/// What kind of failure an error is, whichever venue or transport produced it.
10///
11/// A caller decides whether to retry, back off, alert or give up from the
12/// class alone, so one policy serves every crate. An HTTP status decides the
13/// class before any body does (see [`class_for_status`](crate::class_for_status)).
14///
15/// `code` fields hold the venue's own error code, never the HTTP status, with
16/// numeric codes written in decimal.
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18#[non_exhaustive]
19pub enum Class {
20    /// No response arrived: a failed connect, a timeout, a reset, a TLS EOF, a
21    /// DNS failure, or a socket that closed.
22    Network,
23    /// The host is unwell or busy: a 408, 425 or 5xx, whatever the body says.
24    Unavailable {
25        /// The venue's error code, when it sent one.
26        code: Option<Arc<str>>,
27    },
28    /// A 429.
29    RateLimited {
30        /// How long the server asked the caller to wait.
31        retry_after: Option<Duration>,
32    },
33    /// A 401 or 403: the credentials were missing, wrong or not allowed.
34    Unauthorized,
35    /// The client refused the request itself, or was misused: local
36    /// validation, a bad URL, a socket used after it closed. No status ever
37    /// produces it.
38    InvalidRequest,
39    /// The venue refused the request: any 4xx without a class of its own, or a
40    /// socket closed with a protocol or application close code.
41    VenueRefusal {
42        /// The venue's error code, when it sent one.
43        code: Option<Arc<str>>,
44    },
45    /// The venue will not serve this caller: a 418 ban, a 451 region block, or
46    /// a firewall refusal a venue documents as one.
47    Restricted,
48    /// A successful response, or a frame, that did not parse.
49    Decode,
50}
51
52impl Class {
53    /// Whether retrying could plausibly succeed: true for
54    /// [`Network`](Self::Network), [`Unavailable`](Self::Unavailable) and
55    /// [`RateLimited`](Self::RateLimited).
56    ///
57    /// This describes the failure, not the operation. A retriable error on an
58    /// order placement still needs the caller to judge whether resubmitting
59    /// is safe.
60    pub fn is_retriable(&self) -> bool {
61        matches!(
62            self,
63            Self::Network | Self::Unavailable { .. } | Self::RateLimited { .. }
64        )
65    }
66
67    /// The wait a [`RateLimited`](Self::RateLimited) class carries, and `None`
68    /// for every other class and for a zero wait.
69    pub fn retry_after(&self) -> Option<Duration> {
70        match self {
71            Self::RateLimited { retry_after } => retry_after.filter(|wait| !wait.is_zero()),
72            _ => None,
73        }
74    }
75
76    /// The venue's code, for [`Unavailable`](Self::Unavailable) and
77    /// [`VenueRefusal`](Self::VenueRefusal).
78    pub fn code(&self) -> Option<&str> {
79        match self {
80            Self::Unavailable { code } | Self::VenueRefusal { code } => code.as_deref(),
81            _ => None,
82        }
83    }
84
85    /// Sets the venue's code on [`Unavailable`](Self::Unavailable) and
86    /// [`VenueRefusal`](Self::VenueRefusal). Every other class is returned
87    /// unchanged, since it has nowhere to put one.
88    pub fn with_code(self, code: impl Into<Arc<str>>) -> Self {
89        match self {
90            Self::Unavailable { .. } => Self::Unavailable {
91                code: Some(code.into()),
92            },
93            Self::VenueRefusal { .. } => Self::VenueRefusal {
94                code: Some(code.into()),
95            },
96            other => other,
97        }
98    }
99
100    /// Sets the wait on [`RateLimited`](Self::RateLimited). A zero wait is
101    /// dropped, since it asks for nothing. Every other class is returned
102    /// unchanged.
103    pub fn with_retry_after(self, retry_after: Option<Duration>) -> Self {
104        match self {
105            Self::RateLimited { .. } => Self::RateLimited {
106                retry_after: retry_after.filter(|wait| !wait.is_zero()),
107            },
108            other => other,
109        }
110    }
111}
112
113/// Implemented by every public error type in the workspace.
114///
115/// Only [`class`](Self::class) is required. The defaults suit almost every
116/// error: an error is a fault, and the only wait it carries is its class's.
117pub trait Classify: Error {
118    /// The class of this error.
119    fn class(&self) -> Class;
120
121    /// Whether this error is a fault, rather than the venue answering as
122    /// designed. False only for defined outcomes, such as a Fill-And-Kill order
123    /// that matched nothing, or a venue that does not serve the caller's
124    /// region. Alerting should key on this; retrying should not.
125    fn is_fault(&self) -> bool {
126        true
127    }
128
129    /// How long the server asked the caller to wait. Defaults to the class's
130    /// own; an error whose wait outlives its class, such as a ban that says
131    /// when it lifts, overrides it. Never `Some` of zero.
132    fn retry_after(&self) -> Option<Duration> {
133        self.class().retry_after()
134    }
135
136    /// Whether retrying could plausibly succeed, read from the class (see
137    /// [`Class::is_retriable`]).
138    fn is_retriable(&self) -> bool {
139        self.class().is_retriable()
140    }
141}
142
143/// An error with its class, from any crate: the one error type a venue-neutral
144/// caller handles.
145///
146/// Built from any [`Classify`] error with `?` or `.into()`, which records the
147/// class, [`is_fault`](Self::is_fault) and [`retry_after`](Self::retry_after)
148/// as the error reported them. It displays as its source does and continues
149/// the source chain from there, so wrapping an error changes nothing an error
150/// reporter prints. The original error stays reachable through `source`, by
151/// downcasting.
152///
153/// It does not implement [`Classify`] itself: that would make the blanket
154/// `From` overlap `From<T> for T`. Read the class from the `class` field.
155#[derive(Debug)]
156pub struct ClassifiedError {
157    /// The error's class.
158    pub class: Class,
159    /// The error that was classified.
160    pub source: Box<dyn Error + Send + Sync + 'static>,
161    fault: bool,
162    retry_after: Option<Duration>,
163}
164
165impl ClassifiedError {
166    /// Classifies an error that does not implement [`Classify`]. It counts as a
167    /// fault, and its wait is its class's.
168    pub fn new(class: Class, source: impl Into<Box<dyn Error + Send + Sync + 'static>>) -> Self {
169        let retry_after = class.retry_after();
170        Self {
171            class,
172            source: source.into(),
173            fault: true,
174            retry_after,
175        }
176    }
177
178    /// Whether the error is a fault, as [`Classify::is_fault`] reported it.
179    pub fn is_fault(&self) -> bool {
180        self.fault
181    }
182
183    /// The wait the server asked for, as [`Classify::retry_after`] reported it.
184    pub fn retry_after(&self) -> Option<Duration> {
185        self.retry_after
186    }
187
188    /// Whether retrying could plausibly succeed (see [`Class::is_retriable`]).
189    pub fn is_retriable(&self) -> bool {
190        self.class.is_retriable()
191    }
192}
193
194impl fmt::Display for ClassifiedError {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        fmt::Display::fmt(&self.source, f)
197    }
198}
199
200impl Error for ClassifiedError {
201    fn source(&self) -> Option<&(dyn Error + 'static)> {
202        self.source.source()
203    }
204}
205
206impl<E> From<E> for ClassifiedError
207where
208    E: Classify + Send + Sync + 'static,
209{
210    fn from(err: E) -> Self {
211        Self {
212            class: err.class(),
213            fault: err.is_fault(),
214            // The trait's contract, held even for an impl that breaks it.
215            retry_after: err.retry_after().filter(|wait| !wait.is_zero()),
216            source: Box::new(err),
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    /// A stand-in for a venue error: a defined outcome with a wait that
226    /// outlives its class, like a ban a venue's firewall sets.
227    #[derive(Debug)]
228    struct Banned;
229
230    impl fmt::Display for Banned {
231        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232            f.write_str("banned")
233        }
234    }
235
236    impl Error for Banned {}
237
238    impl Classify for Banned {
239        fn class(&self) -> Class {
240            Class::Restricted
241        }
242        fn is_fault(&self) -> bool {
243            false
244        }
245        fn retry_after(&self) -> Option<Duration> {
246            Some(Duration::from_secs(120))
247        }
248    }
249
250    #[derive(Debug)]
251    struct Throttled;
252
253    impl fmt::Display for Throttled {
254        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255            f.write_str("throttled")
256        }
257    }
258
259    impl Error for Throttled {}
260
261    impl Classify for Throttled {
262        fn class(&self) -> Class {
263            Class::RateLimited {
264                retry_after: Some(Duration::from_secs(3)),
265            }
266        }
267    }
268
269    #[test]
270    fn only_network_unavailable_and_rate_limited_are_retriable() {
271        let classes = [
272            (Class::Network, true),
273            (Class::Unavailable { code: None }, true),
274            (Class::RateLimited { retry_after: None }, true),
275            (Class::Unauthorized, false),
276            (Class::InvalidRequest, false),
277            (Class::VenueRefusal { code: None }, false),
278            (Class::Restricted, false),
279            (Class::Decode, false),
280        ];
281        for (class, retriable) in classes {
282            assert_eq!(class.is_retriable(), retriable, "{class:?}");
283        }
284    }
285
286    #[test]
287    fn a_code_and_a_wait_land_only_where_a_class_has_room_for_them() {
288        assert_eq!(
289            Class::VenueRefusal { code: None }.with_code("-1121").code(),
290            Some("-1121")
291        );
292        assert_eq!(
293            Class::Unavailable { code: None }
294                .with_code("internal")
295                .code(),
296            Some("internal")
297        );
298        assert_eq!(
299            Class::Unauthorized.with_code("x"),
300            Class::Unauthorized,
301            "a class without a code field is unchanged"
302        );
303
304        let wait = Some(Duration::from_secs(2));
305        assert_eq!(
306            Class::RateLimited { retry_after: None }
307                .with_retry_after(wait)
308                .retry_after(),
309            wait
310        );
311        assert_eq!(
312            Class::RateLimited { retry_after: None }
313                .with_retry_after(Some(Duration::ZERO))
314                .retry_after(),
315            None,
316            "a zero wait asks for nothing"
317        );
318        assert_eq!(
319            Class::RateLimited {
320                retry_after: Some(Duration::ZERO)
321            }
322            .retry_after(),
323            None,
324            "nor when the class was built with one"
325        );
326        assert_eq!(Class::Restricted.with_retry_after(wait), Class::Restricted);
327    }
328
329    #[test]
330    fn the_defaults_read_the_class() {
331        assert!(Throttled.is_fault());
332        assert_eq!(
333            Classify::retry_after(&Throttled),
334            Some(Duration::from_secs(3))
335        );
336        assert!(Classify::is_retriable(&Throttled));
337    }
338
339    #[test]
340    fn a_classified_error_keeps_what_the_error_reported() {
341        let err = ClassifiedError::from(Banned);
342        assert_eq!(err.class, Class::Restricted);
343        assert!(!err.is_fault());
344        assert_eq!(err.retry_after(), Some(Duration::from_secs(120)));
345        assert!(!err.is_retriable());
346        assert!(err.source.downcast_ref::<Banned>().is_some());
347    }
348
349    #[test]
350    fn a_classified_error_displays_as_its_source_and_continues_its_chain() {
351        let err: ClassifiedError = Throttled.into();
352        assert_eq!(err.to_string(), "throttled");
353        assert!(
354            Error::source(&err).is_none(),
355            "Throttled has no source of its own"
356        );
357    }
358
359    /// An impl that breaks the contract by reporting a zero wait.
360    #[derive(Debug)]
361    struct ZeroWait;
362
363    impl fmt::Display for ZeroWait {
364        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
365            f.write_str("zero wait")
366        }
367    }
368
369    impl Error for ZeroWait {}
370
371    impl Classify for ZeroWait {
372        fn class(&self) -> Class {
373            Class::Unavailable { code: None }
374        }
375        fn retry_after(&self) -> Option<Duration> {
376            Some(Duration::ZERO)
377        }
378    }
379
380    #[test]
381    fn a_classified_error_never_keeps_a_zero_wait() {
382        assert_eq!(ClassifiedError::from(ZeroWait).retry_after(), None);
383    }
384
385    #[test]
386    fn an_unclassified_error_takes_the_class_it_is_given() {
387        let err = ClassifiedError::new(
388            Class::RateLimited {
389                retry_after: Some(Duration::from_secs(1)),
390            },
391            "slow down",
392        );
393        assert!(err.is_fault());
394        assert_eq!(err.retry_after(), Some(Duration::from_secs(1)));
395        assert_eq!(err.to_string(), "slow down");
396    }
397
398    #[test]
399    fn the_question_mark_operator_classifies() {
400        fn fails() -> Result<(), ClassifiedError> {
401            let banned: Result<(), Banned> = Err(Banned);
402            banned?;
403            Ok(())
404        }
405        assert_eq!(fails().unwrap_err().class, Class::Restricted);
406    }
407}