Skip to main content

errlanes/
lane.rs

1use std::{borrow::Cow, error::Error, fmt, sync::Arc};
2
3use crate::fail::Level;
4
5/// Which of the four lanes an outcome travels in.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum Lane {
8    Rejected,
9    Denied,
10    Transient,
11    Fatal,
12}
13
14impl Lane {
15    pub fn as_str(self) -> &'static str {
16        match self {
17            Lane::Rejected => "rejected",
18            Lane::Denied => "denied",
19            Lane::Transient => "transient",
20            Lane::Fatal => "fatal",
21        }
22    }
23}
24
25impl Lane {
26    /// The lane's operator level. `Rejected` is the lane default; a concrete
27    /// rejection overrides it through [`crate::Rejection::level`].
28    pub fn level(self) -> Level {
29        match self {
30            Lane::Rejected => Level::Warn,
31            Lane::Denied => Level::Warn,
32            Lane::Transient => Level::Info,
33            Lane::Fatal => Level::Error,
34        }
35    }
36}
37
38impl fmt::Display for Lane {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(self.as_str())
41    }
42}
43
44/// Why a [`Transient`] outcome may succeed on retry.
45#[non_exhaustive]
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum TransientKind {
48    OptimisticConflict,
49    SerializationFailure,
50    Deadlock,
51    PoolTimeout,
52    ConnectionLost,
53    UpstreamUnavailable,
54    Congestion,
55    Other,
56}
57
58impl TransientKind {
59    pub fn as_str(self) -> &'static str {
60        match self {
61            TransientKind::OptimisticConflict => "optimistic_conflict",
62            TransientKind::SerializationFailure => "serialization_failure",
63            TransientKind::Deadlock => "deadlock",
64            TransientKind::PoolTimeout => "pool_timeout",
65            TransientKind::ConnectionLost => "connection_lost",
66            TransientKind::UpstreamUnavailable => "upstream_unavailable",
67            TransientKind::Congestion => "congestion",
68            TransientKind::Other => "other",
69        }
70    }
71
72    /// A transient that carries no evidence about the operation itself, only
73    /// about shared capacity — a retry loop backs off without spending its
74    /// budget, where a conflict kind (`Deadlock`, `SerializationFailure`,
75    /// `OptimisticConflict`) is evidence the operation can be re-run at
76    /// once.
77    pub fn is_congestion(self) -> bool {
78        matches!(self, TransientKind::PoolTimeout | TransientKind::Congestion)
79    }
80
81    /// Postgres aborted the attempt because it lost a race with a concurrent
82    /// transaction: a deadlock victim (`40P01`) or a serialization failure
83    /// (`40001`). Two things follow that no other transient guarantees. The
84    /// server confirmed the rollback, so the attempt is safe to re-run even
85    /// when the failure surfaced at `COMMIT`, where any other error is
86    /// ambiguous. And the failure says nothing about the data involved, only
87    /// about the interleaving, so there is nothing in it to attribute to one
88    /// row or to bisect for.
89    ///
90    /// `OptimisticConflict` is deliberately not contention: it is raised
91    /// above Postgres by a version check and names one stale row, so a batch
92    /// search *can* isolate it, and a plain re-run with the same stale state
93    /// would only conflict again.
94    pub fn is_contention(self) -> bool {
95        matches!(
96            self,
97            TransientKind::Deadlock | TransientKind::SerializationFailure
98        )
99    }
100
101    /// The one row of the sqlx lane table (`sqlx.rs`, private) a consumer
102    /// with a non-sqlx Postgres driver could still want: which
103    /// [`TransientKind`] a Postgres SQLSTATE code maps to, independent of
104    /// `sqlx::Error`. Lives here, not behind the `sqlx` feature, so it is
105    /// reachable without that dependency.
106    pub fn from_sqlstate(code: &str) -> Option<Self> {
107        match code {
108            "40001" => Some(TransientKind::SerializationFailure),
109            "40P01" => Some(TransientKind::Deadlock),
110            "57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006" | "08001" | "08004" => {
111                Some(TransientKind::ConnectionLost)
112            }
113            _ => None,
114        }
115    }
116}
117
118impl fmt::Display for TransientKind {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(self.as_str())
121    }
122}
123
124/// A failure that may succeed if the same operation is retried.
125#[derive(Debug, Clone)]
126pub struct Transient {
127    pub kind: TransientKind,
128    /// A non-PII breadcrumb, e.g. `"customers/<id> seq 42"`.
129    pub context: Option<Cow<'static, str>>,
130    source: Option<Arc<dyn Error + Send + Sync + 'static>>,
131}
132
133impl Transient {
134    pub fn new(kind: TransientKind) -> Self {
135        Self {
136            kind,
137            context: None,
138            source: None,
139        }
140    }
141
142    /// [`Transient::new`] plus [`Transient::with_source`], as one call — the
143    /// common shape at a classification site that has the error in hand.
144    pub fn from_error(kind: TransientKind, e: impl Error + Send + Sync + 'static) -> Self {
145        Self::new(kind).with_source(e)
146    }
147
148    pub fn with_source(mut self, e: impl Error + Send + Sync + 'static) -> Self {
149        self.source = Some(Arc::new(e));
150        self
151    }
152
153    pub fn with_context(mut self, c: impl Into<Cow<'static, str>>) -> Self {
154        self.context = Some(c.into());
155        self
156    }
157
158    pub fn source_arc(&self) -> Option<&Arc<dyn Error + Send + Sync>> {
159        self.source.as_ref()
160    }
161
162    pub fn is_congestion(&self) -> bool {
163        self.kind.is_congestion()
164    }
165
166    pub fn is_contention(&self) -> bool {
167        self.kind.is_contention()
168    }
169}
170
171impl fmt::Display for Transient {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        write!(f, "transient({})", self.kind)?;
174        if let Some(ctx) = &self.context {
175            write!(f, ": {ctx}")?;
176        }
177        Ok(())
178    }
179}
180
181impl Error for Transient {
182    fn source(&self) -> Option<&(dyn Error + 'static)> {
183        self.source
184            .as_ref()
185            .map(|s| s.as_ref() as &(dyn Error + 'static))
186    }
187}
188
189/// Why a [`Fatal`] outcome is a bug, a misconfiguration, or corrupt state.
190#[non_exhaustive]
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
192pub enum FatalKind {
193    Invariant,
194    Config,
195    CorruptState,
196    Dependency,
197    Panic,
198    Exhausted,
199    /// A `Denied` narrowed at a boundary with no subject to deny — code
200    /// running as the system. The `Denied` is the source. Mirrors
201    /// `Exhausted`: just as the struct `Exhausted` is the source of a
202    /// `Fatal(Exhausted)`, the struct `Denied` is the source of a
203    /// `Fatal(Denied)`.
204    Denied,
205}
206
207impl FatalKind {
208    pub fn as_str(self) -> &'static str {
209        match self {
210            FatalKind::Invariant => "invariant",
211            FatalKind::Config => "config",
212            FatalKind::CorruptState => "corrupt_state",
213            FatalKind::Dependency => "dependency",
214            FatalKind::Panic => "panic",
215            FatalKind::Exhausted => "exhausted",
216            FatalKind::Denied => "denied",
217        }
218    }
219}
220
221impl fmt::Display for FatalKind {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        f.write_str(self.as_str())
224    }
225}
226
227/// A failure that will not succeed on retry: a bug, a misconfiguration, or
228/// corrupt state.
229///
230/// `kind`, `context` and `source` are for operators (traces, logs) and for
231/// tests (which may `downcast_ref` the source to assert what happened).
232/// Production code never inspects a `Fatal`'s payload: the response to a
233/// `Fatal` is the same regardless of its source — stop, surface, page. If you
234/// find yourself needing the payload, the outcome was a value or a
235/// `Rejection` and the API should be changed, not the call site.
236#[derive(Debug, Clone)]
237pub struct Fatal {
238    pub kind: FatalKind,
239    pub context: Option<Cow<'static, str>>,
240    source: Option<Arc<dyn Error + Send + Sync + 'static>>,
241    /// Set only by a narrowing that stores a domain value (a `Rejection`)
242    /// as `source` purely for programmatic access (`downcast_ref` in a
243    /// handler or a test) — never for display. `Rejection::Display` is
244    /// documented as allowed to embed caller-supplied input, so
245    /// [`crate::dynamic::message_chain`] must not walk past this `Fatal`
246    /// into it; `message()`/`record` would otherwise leak that text into an
247    /// operator-facing field. Not part of equality or ordering; `source()`
248    /// and `downcast_ref` are unaffected either way.
249    opaque_source: bool,
250}
251
252impl Fatal {
253    pub fn new(kind: FatalKind) -> Self {
254        Self {
255            kind,
256            context: None,
257            source: None,
258            opaque_source: false,
259        }
260    }
261
262    /// [`Fatal::new`] plus [`Fatal::with_source`], as one call — the common
263    /// shape at a classification site that has the error in hand.
264    pub fn from_error(kind: FatalKind, e: impl Error + Send + Sync + 'static) -> Self {
265        Self::new(kind).with_source(e)
266    }
267
268    /// As [`Transient::with_source`]. Takes `self` so a `Fatal` built from a
269    /// lane table can be given its source afterwards.
270    pub fn with_source(mut self, e: impl Error + Send + Sync + 'static) -> Self {
271        self.source = Some(Arc::new(e));
272        self
273    }
274
275    /// Explicit escape hatch for a source that only arrives boxed. There is
276    /// no `From<Box<dyn Error>>` — a caller must say out loud that it is
277    /// discarding whatever lane the boxed error might have carried.
278    pub fn from_boxed(kind: FatalKind, e: Box<dyn Error + Send + Sync>) -> Self {
279        Self {
280            kind,
281            context: None,
282            source: Some(Arc::from(e)),
283            opaque_source: false,
284        }
285    }
286
287    /// `Fatal::new(kind)` with the error's whole `Display` chain as context —
288    /// the by-reference form for a boundary holding a `&dyn Error` it cannot
289    /// keep as `source`. The same fold [`Fault::classify`](crate::Fault::classify)
290    /// makes in its rule 3, with the kind chosen by the caller.
291    pub fn from_dyn(kind: FatalKind, e: &(dyn Error + 'static)) -> Self {
292        Self::new(kind).with_context(crate::dynamic::message_chain(e))
293    }
294
295    pub fn invariant(msg: impl Into<Cow<'static, str>>) -> Self {
296        Self {
297            kind: FatalKind::Invariant,
298            context: Some(msg.into()),
299            source: None,
300            opaque_source: false,
301        }
302    }
303
304    pub fn with_context(mut self, c: impl Into<Cow<'static, str>>) -> Self {
305        self.context = Some(c.into());
306        self
307    }
308
309    /// Marks this `Fatal`'s `source` opaque to display: `source()` still
310    /// returns it (so a handler or test can still `downcast_ref` it), but
311    /// [`crate::dynamic::message_chain`] stops at this `Fatal`'s own
312    /// `Display` rather than walking into it. For a narrowing that stores a
313    /// `Rejection` as the source — see the field doc on `opaque_source`.
314    pub(crate) fn with_opaque_source(mut self) -> Self {
315        self.opaque_source = true;
316        self
317    }
318
319    pub(crate) fn has_opaque_source(&self) -> bool {
320        self.opaque_source
321    }
322}
323
324impl fmt::Display for Fatal {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        write!(f, "fatal({})", self.kind)?;
327        if let Some(ctx) = &self.context {
328            write!(f, ": {ctx}")?;
329        }
330        Ok(())
331    }
332}
333
334impl Error for Fatal {
335    fn source(&self) -> Option<&(dyn Error + 'static)> {
336        self.source
337            .as_ref()
338            .map(|s| s.as_ref() as &(dyn Error + 'static))
339    }
340}
341
342/// Authorization failure. Never retried, always audited.
343#[derive(Debug, Clone, Default)]
344pub struct Denied {
345    pub object: Option<Cow<'static, str>>,
346    pub action: Option<Cow<'static, str>>,
347    context: Option<Cow<'static, str>>,
348    source: Option<Arc<dyn Error + Send + Sync + 'static>>,
349}
350
351impl Denied {
352    pub fn new() -> Self {
353        Self::default()
354    }
355
356    pub fn from_error(e: impl Error + Send + Sync + 'static) -> Self {
357        Self::new().with_source(e)
358    }
359
360    pub fn with_object(mut self, object: impl Into<Cow<'static, str>>) -> Self {
361        self.object = Some(object.into());
362        self
363    }
364
365    pub fn with_action(mut self, action: impl Into<Cow<'static, str>>) -> Self {
366        self.action = Some(action.into());
367        self
368    }
369
370    pub fn with_source(mut self, e: impl Error + Send + Sync + 'static) -> Self {
371        self.source = Some(Arc::new(e));
372        self
373    }
374
375    pub fn with_context(mut self, c: impl Into<Cow<'static, str>>) -> Self {
376        self.context = Some(c.into());
377        self
378    }
379
380    pub fn context(&self) -> Option<&str> {
381        self.context.as_deref()
382    }
383
384    pub fn source_arc(&self) -> Option<&Arc<dyn Error + Send + Sync>> {
385        self.source.as_ref()
386    }
387
388    pub(crate) fn into_fatal(mut self) -> Fatal {
389        let context = self.context.take();
390        let fatal = Fatal::from_error(FatalKind::Denied, self);
391        match context {
392            Some(c) => fatal.with_context(c),
393            None => fatal,
394        }
395    }
396}
397
398impl fmt::Display for Denied {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        f.write_str("denied")?;
401        if self.action.is_some() || self.object.is_some() {
402            f.write_str(": ")?;
403            if let Some(action) = &self.action {
404                write!(f, "{action} ")?;
405            }
406            if let Some(object) = &self.object {
407                write!(f, "on {object}")?;
408            }
409        }
410        Ok(())
411    }
412}
413
414impl Error for Denied {
415    fn source(&self) -> Option<&(dyn Error + 'static)> {
416        self.source
417            .as_ref()
418            .map(|s| s.as_ref() as &(dyn Error + 'static))
419    }
420}
421
422/// A [`Transient`] lane that never succeeded within the retry budget.
423#[derive(Debug, Clone)]
424pub struct Exhausted {
425    pub attempts: u32,
426    pub last: Transient,
427}
428
429impl fmt::Display for Exhausted {
430    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
431        write!(
432            f,
433            "exhausted after {} attempts: {}",
434            self.attempts, self.last
435        )
436    }
437}
438
439impl Error for Exhausted {
440    fn source(&self) -> Option<&(dyn Error + 'static)> {
441        Some(&self.last)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    /// Regression: `from_sqlstate` must be reachable with no `sqlx` feature
450    /// at all -- it lives in this unconditionally-compiled module precisely
451    /// so a non-sqlx Postgres driver can classify a SQLSTATE without taking
452    /// on the `sqlx` dependency. Compiling this module (this test included)
453    /// under the default feature set, which does not enable `sqlx`, is the
454    /// proof.
455    #[test]
456    fn from_sqlstate_is_reachable_without_the_sqlx_feature() {
457        assert_eq!(
458            TransientKind::from_sqlstate("40P01"),
459            Some(TransientKind::Deadlock)
460        );
461        assert_eq!(TransientKind::from_sqlstate("not-a-code"), None);
462    }
463
464    #[test]
465    fn contention_is_the_server_confirmed_abort_subset_of_transient() {
466        assert!(TransientKind::Deadlock.is_contention());
467        assert!(TransientKind::SerializationFailure.is_contention());
468        assert!(!TransientKind::OptimisticConflict.is_contention());
469        assert!(!TransientKind::ConnectionLost.is_contention());
470        assert!(!TransientKind::PoolTimeout.is_contention());
471        assert!(!TransientKind::Congestion.is_contention());
472        assert!(!TransientKind::Other.is_contention());
473        // Exactly the two SQLSTATEs a commit-time retry may trust.
474        assert!(
475            TransientKind::from_sqlstate("40P01")
476                .unwrap()
477                .is_contention()
478        );
479        assert!(
480            TransientKind::from_sqlstate("40001")
481                .unwrap()
482                .is_contention()
483        );
484        assert!(
485            !TransientKind::from_sqlstate("08006")
486                .unwrap()
487                .is_contention()
488        );
489        assert!(
490            !TransientKind::from_sqlstate("57P01")
491                .unwrap()
492                .is_contention()
493        );
494    }
495}