Skip to main content

yo_common/
error.rs

1//! The error model. P5: errors are values with structure, never a string a
2//! caller has to parse.
3//!
4//! The [`Code`] enum is generated from `errors.toml`. This module is the Rust
5//! shaped wrapper around it, and it carries the four extra fields the C ABI
6//! also carries so that nothing is lost crossing the boundary: a message, a
7//! position, a documentation URL, and a free form detail.
8
9use std::fmt;
10
11include!(concat!(env!("OUT_DIR"), "/code.rs"));
12
13/// An error, with everything a caller or an agent needs to act on it.
14///
15/// Cheap to construct on the failure path and never constructed on the success
16/// path, so the size of this type does not touch the hot path. It is returned
17/// by value rather than boxed because a boxed error means an allocation, and an
18/// allocation on a shard thread aborts (`yo-alloc`).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Error {
21    code: Code,
22    message: String,
23    position: Option<u32>,
24    detail: Option<String>,
25}
26
27impl Error {
28    /// A new error with just a message.
29    ///
30    /// The message is copied into a `String` here, and that allocation is
31    /// wrapped in [`yo_alloc::allow`] because a shard thread that allocates
32    /// aborts and an error is by definition off the path the budget is for. A
33    /// client that sends `INCR` at a key holding a word should get an error
34    /// back, not a server that stops answering everybody else.
35    ///
36    /// The wrap only covers what happens inside this call, so a caller that
37    /// builds its message with `format!` first has already allocated by the
38    /// time it gets here. Use [`Error::fmt`] for those.
39    pub fn new(code: Code, message: impl Into<String>) -> Error {
40        Error {
41            code,
42            message: yo_alloc::allow(|| message.into()),
43            position: None,
44            detail: None,
45        }
46    }
47
48    /// A new error whose message needs formatting, built without allocating
49    /// outside the wrap.
50    ///
51    /// `Error::fmt(code, format_args!("no such thing: {name}"))` is the shape.
52    /// `format_args!` builds nothing, so the only allocation is the one this
53    /// does, and it happens where it is allowed to.
54    pub fn fmt(code: Code, args: fmt::Arguments<'_>) -> Error {
55        Error {
56            code,
57            message: yo_alloc::allow(|| fmt::format(args)),
58            position: None,
59            detail: None,
60        }
61    }
62
63    /// Attach the argument index or byte offset the error is about.
64    ///
65    /// P10: the first error should teach. A position turns "invalid arguments"
66    /// into "argument 3 is invalid", which is the difference between a user
67    /// reading the docs and a user guessing.
68    #[must_use]
69    pub fn at(mut self, position: u32) -> Error {
70        self.position = Some(position);
71        self
72    }
73
74    /// Attach machine readable detail, such as `errno=13 path=/var/lib/app.yo`.
75    #[must_use]
76    pub fn with_detail(mut self, detail: impl Into<String>) -> Error {
77        self.detail = Some(yo_alloc::allow(|| detail.into()));
78        self
79    }
80
81    /// The stable condition code.
82    #[inline]
83    pub const fn code(&self) -> Code {
84        self.code
85    }
86
87    /// Whether the identical call could succeed later.
88    #[inline]
89    pub const fn is_retryable(&self) -> bool {
90        self.code.is_retryable()
91    }
92
93    /// The human readable message, without the code or the URL.
94    #[inline]
95    pub fn message(&self) -> &str {
96        &self.message
97    }
98
99    /// The argument index or byte offset, if the error is about one.
100    #[inline]
101    pub const fn position(&self) -> Option<u32> {
102        self.position
103    }
104
105    /// Machine readable detail, if any.
106    #[inline]
107    pub fn detail(&self) -> Option<&str> {
108        self.detail.as_deref()
109    }
110
111    /// The documentation page for this condition, if it has one.
112    #[inline]
113    pub fn url(&self) -> Option<&'static str> {
114        self.code.url()
115    }
116}
117
118impl fmt::Display for Error {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(f, "{}: {}", self.code.c_name(), self.message)?;
121        if let Some(p) = self.position {
122            write!(f, " (at {p})")?;
123        }
124        if let Some(d) = &self.detail {
125            write!(f, " [{d}]")?;
126        }
127        if let Some(u) = self.code.url() {
128            write!(f, " see {u}")?;
129        }
130        Ok(())
131    }
132}
133
134impl core::error::Error for Error {}
135
136/// The crate wide result type.
137pub type Result<T> = core::result::Result<T, Error>;
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn codes_are_dense_and_stable() {
145        for (i, &c) in Code::ALL.iter().enumerate() {
146            assert_eq!(c.as_u32() as usize, i);
147            assert_eq!(Code::from_u32(c.as_u32()), Some(c));
148        }
149    }
150
151    /// These numbers are on the wire and in every binding. Changing one is a
152    /// breaking change for every language at once, so it fails here first.
153    #[test]
154    fn wire_values_are_frozen() {
155        assert_eq!(Code::Ok.as_u32(), 0);
156        assert_eq!(Code::ShapeMismatch.as_u32(), 1);
157        assert_eq!(Code::Locked.as_u32(), 2);
158        assert_eq!(Code::Busy.as_u32(), 3);
159        assert_eq!(Code::NotFound.as_u32(), 4);
160        assert_eq!(Code::WrongType.as_u32(), 5);
161        assert_eq!(Code::AbiMismatch.as_u32(), 6);
162        assert_eq!(Code::Corrupt.as_u32(), 7);
163        assert_eq!(Code::Full.as_u32(), 8);
164        assert_eq!(Code::Io.as_u32(), 9);
165        assert_eq!(Code::Unsupported.as_u32(), 10);
166        assert_eq!(Code::Invalid.as_u32(), 11);
167        assert_eq!(Code::EpochStalled.as_u32(), 12);
168        assert_eq!(Code::VersionTooNew.as_u32(), 13);
169    }
170
171    #[test]
172    fn an_unknown_code_is_a_value_not_a_panic() {
173        assert_eq!(Code::from_u32(9999), None);
174    }
175
176    #[test]
177    fn retryability_matches_the_model() {
178        assert!(Code::Locked.is_retryable());
179        assert!(Code::Busy.is_retryable());
180        assert!(Code::Io.is_retryable());
181        assert!(Code::EpochStalled.is_retryable());
182        assert!(!Code::ShapeMismatch.is_retryable());
183        assert!(!Code::Corrupt.is_retryable());
184        assert!(!Code::WrongType.is_retryable());
185    }
186
187    #[test]
188    fn display_carries_everything() {
189        let e = Error::new(Code::Invalid, "expected an integer")
190            .at(3)
191            .with_detail("got=abc");
192        let s = e.to_string();
193        assert!(s.contains("YO_ERR_INVALID"), "{s}");
194        assert!(s.contains("expected an integer"), "{s}");
195        assert!(s.contains("at 3"), "{s}");
196        assert!(s.contains("got=abc"), "{s}");
197    }
198
199    /// The rule this is protecting is that a shard thread aborts when it
200    /// allocates, and an error message is a `String`. If building one were not
201    /// allowed, the first client to send `INCR` at a key holding a word would
202    /// take the server down with it, which is a worse failure than the one the
203    /// rule exists to prevent.
204    #[test]
205    fn building_an_error_is_allowed_where_allocating_is_not() {
206        yo_alloc::enter_no_alloc();
207        let e = Error::fmt(Code::Invalid, format_args!("no such thing: {}", "x"))
208            .with_detail("got=abc");
209        assert_eq!(e.message(), "no such thing: x");
210        // And the thread is still forbidden afterwards, because the wrap is
211        // around the allocation and not around the caller.
212        assert!(yo_alloc::is_forbidden());
213        yo_alloc::exit_no_alloc();
214        assert!(!yo_alloc::is_forbidden());
215    }
216
217    #[test]
218    fn errors_that_need_a_page_have_one() {
219        // Anything a user is likely to hit and unlikely to understand needs a
220        // URL. NotFound and Full do not, because they explain themselves.
221        for c in [
222            Code::ShapeMismatch,
223            Code::Locked,
224            Code::Busy,
225            Code::WrongType,
226            Code::AbiMismatch,
227            Code::Corrupt,
228            Code::EpochStalled,
229            Code::VersionTooNew,
230        ] {
231            assert!(c.url().is_some(), "{} has no documentation URL", c.c_name());
232        }
233    }
234}