summaryrefslogtreecommitdiff
path: root/src/error/failed.rs
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-11-11 01:29:36 -0500
committerOwen Jacobson <owen@grimoire.ca>2025-11-25 20:49:03 -0500
commit33601ef703a640b57e5bd0bf7dbd6d7ffa7377bf (patch)
tree096b997d56959dd88d099f4f96a383daa4dbc39a /src/error/failed.rs
parentf9ecfa3c342d5932edd5f8d3e75e77482dca472d (diff)
Define a generic "Failed" case for app-level errors (and a few others).
We were previously exporting root causes from one layer of abstraction to the next. For example, anything that called into the database could cause an `sqlx::Error`, so anything that transitively called into that logic exported a `Database(sqlx::Error)` error variant of its own, using `From` to map errors from inner type to outer type. This had a couple of side effects. First, it required each layer of error handling to carry with it a `From` implementation unwrapping and rewrapping root causes from the next layer down. This was particularly apparent in the event and boot endpoints, which had separate error cases unique to crypto key processing errors solely because they happened to involve handling events that contained those keys. There were others, including the pervasive `Database(sqlx::Error)` error variants. Separately, none of the error variants introduced for this purpose were being used for anything other than printing to stderr. All the complexity of From impls and all the structure of the error types was being thrown away at top-level error handlers. This change replaces most of those error types with a generic `Failed` error. A `Failed` carries with it two pieces of information: a (boxed) underlying error, of any boxable `Error` type, and text meant to explain the context and cause of an error. Code which acts on errors can treat `Failed` as a catch-all case, while individually handling errors that signify important cases. Errors can be moved into or out of the `Failed` case by refactoring, as needed. The design of `Failed` is heavily motivated by [anyhow's `context` system][context] as a way for the programmer to capture immediate intention as an explanation for some underlying error. However, instead of accepting the full breadth of types that implement `Display`, a `Failed` can only carry strings as explanation. We don't need the generality at this time, and the implementation underlying it is pretty complex for what it does. [context]: https://docs.rs/anyhow/latest/anyhow/struct.Error.html#method.context This change also means that the full source chain for an error is now available to top-level error handlers, allowing more complete error messages. For example, starting `pilcrow` with an invalid network listen address produces Failed to bind to www.google.com:64209 Caused by: Can't assign requested address (os error 49) instead of the previous Error: Io(Os { code: 49, kind: AddrNotAvailable, message: "Can't assign requested address" }) which previously captured the same _cause_, but without the formatting (see previous commit) and without the _context_ (this commit). Similar improvements are available for many of the error scenarios Pilcrow is designed to give up on. When deciding which errors to use `Failed` with, I've used the heuristic that if something can fail for more than one underlying reason, and if the caller will only ever need to be able to differentiate those reasons after substantial refactoring anyways, then the reasons should collase into `Failed`. If there's either only a single underlying failure reason possible, or only errors arising out of the function body possible, then I've left error handling alone. In the process I've refactored most request-handler-level error mappings to explicitly map `Failed` to `Internal`, rather than having a catch-all mapping for all unhandled errors, to make it easier to remember to add request-level error representations when adding app-level error cases. This also includes helper traits for `Error` and `Result`, to make constructing `Failed` (and errors that include `Failed` as an alternative) easier to do, and some constants for the recurring error messages related to transaction demarcation. I'm not completely happy with the repetitive nature of those error cases, but this is the best I've arrived at so far. As errors are no longer expected to be convertible up the call stack, the `NotFound` and `Duplicate` helper traits for database errors had to change a bit. Those previously assumed that they would be used in the context of an error type implementing `From<sqlx::Error>` (or from another error type with similar characteristics), and that's not the case any more. The resulting idiom for converting a missing value into a domain error is `foo.await.optional().fail(MESSAGE)?.ok_or(DOMAIN ERROR)?`, which is rather clunky, but I've opted not to go further with it. The `Duplicate` helper is just plain gone, as it's not easily generalizable in this structure and using `match` is more tractable for me. Finally, I've changed the convention for error messages from `all lowercase messages in whatever tense i feel like at the moment` to `Sentence-case messages in the past tense`, frequently starting with `Failed to` and a short summary of the task at hand. This, as above, makes error message capitalization between Pilcrow's own messages and messages coming from other libraries/the Rust stdlib much more coherent and less jarring to read.
Diffstat (limited to 'src/error/failed.rs')
-rw-r--r--src/error/failed.rs155
1 files changed, 155 insertions, 0 deletions
diff --git a/src/error/failed.rs b/src/error/failed.rs
new file mode 100644
index 0000000..4d55552
--- /dev/null
+++ b/src/error/failed.rs
@@ -0,0 +1,155 @@
+use std::{borrow::Cow, error::Error, fmt};
+
+use super::BoxedError;
+
+// Don't bother allocating a String for each error if we have a static error message, which is
+// a fairly common use case.
+pub type Message = Cow<'static, str>;
+
+// Intended to represent a generic failure caused by some underlying cause, tagged with a
+// context-appropriate message to help operators and developers diagnose and repair the issue.
+// Unlike `Internal`, this error type has no default HTTP representation - it's not meant for that
+// use case: the intended audience for a `Failed` and its underlying cause is the service operator,
+// not the conversational users or client developers.
+//
+// The underlying cause will be used as the `Failed` error's `source()`, and the provided message
+// will be used as its `Display` implementation.
+//
+// Ways to make a `Failed`:
+//
+// * Call `ResultExt::fail` or `ResultExt::fail_with` on a result to convert its error type (most
+// preferred).
+// * Call `ErrorExt::fail` to convert an existing error into `Failed`.
+// * Call `Result::map_err` with `Failed::with_message` or `Failed::with_message_from` to convert
+// its error type to `Failed`.
+// * Call `Failed::new` with a message and an existing error (least preferred).
+//
+// Ways to use `Failed`:
+//
+// * As the error type of a `Result` return value, when all errors that can arise in a function
+// are of interest to the operator only but there are multiple failure types to contend with.
+// * As the error type of a `Result` return value, when there's only one error that can arise but
+// you want to annotate that error with a contextual message, like what the function was doing at
+// the time.
+// * As a variant in am error enum, when _some_ errors are only of operator interest but other
+// errors may need to be broken out for matching, or returned to clients.
+//
+// When not to use `Failed`:
+//
+// * When the caller needs to match on the specific error type. `Failed`, by design, assumes that
+// nothing up the call stack will care about the structure of the error, and while you _can_ get
+// at the underlying error to check what you ahve by going through the `source()` chain, it's
+// tricky to do well. If the specific error is relevant to the caller, surface it through the
+// error type, instead.
+#[derive(Debug)]
+pub struct Failed {
+ source: BoxedError,
+ message: Message,
+}
+
+impl Failed {
+ pub fn new<M, E>(message: M, source: E) -> Self
+ where
+ M: Into<Message>,
+ E: Into<BoxedError>,
+ {
+ Self {
+ source: source.into(),
+ message: message.into(),
+ }
+ }
+}
+
+// Adapters for use with `Result::map_err`.
+impl Failed {
+ // The returned adaptor wraps an error with `Failed`, using the provided message.
+ pub fn with_message<M, E>(message: M) -> impl FnOnce(E) -> Self
+ where
+ M: Into<Message>,
+ E: Into<BoxedError>,
+ {
+ |source| Self::new(message, source)
+ }
+
+ // The returned adaptor wraps an error with `Failed`, using a message from the provided message
+ // callback.
+ pub fn with_message_from<F, M, E>(message_fn: F) -> impl FnOnce(E) -> Self
+ where
+ F: FnOnce() -> M,
+ M: Into<Message>,
+ E: Into<BoxedError>,
+ {
+ |source| Self::new(message_fn(), source)
+ }
+}
+
+impl Error for Failed {
+ fn source(&self) -> Option<&(dyn Error + 'static)> {
+ Some(self.source.as_ref())
+ }
+}
+
+impl fmt::Display for Failed {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.message.fmt(f)
+ }
+}
+
+pub trait ErrorExt {
+ // Convenience for `Failed::new(message, err).into()`, when wrapping an existing error into
+ // `Failed`.
+ fn fail<M, T>(self, message: M) -> T
+ where
+ M: Into<Message>,
+ T: From<Failed>;
+}
+
+impl<E> ErrorExt for E
+where
+ E: Into<BoxedError>,
+{
+ fn fail<M, T>(self, message: M) -> T
+ where
+ M: Into<Message>,
+ T: From<Failed>,
+ {
+ Failed::new(message, self).into()
+ }
+}
+
+pub trait ResultExt {
+ type Ok;
+
+ // Convenience for `.map_err(Failed::with_message(message))`, when a result's error type can be
+ // collapsed into `Failed`.
+ fn fail<M>(self, message: M) -> Result<Self::Ok, Failed>
+ where
+ M: Into<Message>;
+
+ fn fail_with<F, M>(self, message_fn: F) -> Result<Self::Ok, Failed>
+ where
+ F: FnOnce() -> M,
+ M: Into<Message>;
+}
+
+impl<T, E> ResultExt for Result<T, E>
+where
+ E: Into<BoxedError>,
+{
+ type Ok = T;
+
+ fn fail<M>(self, message: M) -> Result<T, Failed>
+ where
+ M: Into<Message>,
+ {
+ self.map_err(Failed::with_message(message))
+ }
+
+ fn fail_with<F, M>(self, message_fn: F) -> Result<T, Failed>
+ where
+ F: FnOnce() -> M,
+ M: Into<Message>,
+ {
+ self.map_err(Failed::with_message_from(message_fn))
+ }
+}