Opinions on error-stack?

So, there's this error-stack error handling crate

I used it in a few tiny projects more then a year ago and was delighted, as it seemed to strike the right balance between having to specify context/source over and over (snafu-like), and being able to ignore it all the time (like in anyhow), while supporting both "enumerate all cases" vs "just yeet an type-erased error and don't worry about it".)

Since then I was busy with a larger project that has a pre-estabilished anyhow based error handling, so I don't get much opportunity to work with error-stack, but I keep wondering - is it the right way? Did I miss anything great/terrible about it?

Did anyone gave it a try? Thoughts?

4 Likes

My personal favourite is thiserror crate.
I like being able to handle all possible errors in one place, rather than sprinkling verbose context messages throughout the code base.

I've used it in a small project and found that I love the idea and the discipline it promotes, but ultimately find it quite confusing and a maintenance burden.

I think it's something definitely, 100% worth considering for large projects. But I'd have to get more experience using it before I was totally happy with how to use it.

Its quite easy to overcomplicate error handling using error-stack. I was excited to try it on a "real" project but I don't think it was large enough to really see the benefit. I could have been using it wrong though! :smiley: what do you think?

GitHub - drmason13/scut - a silly CLI tool to "download" and "upload" hotseat game saves to Dropbox. It cheats by just moving then in and out of a dropboxsync folder :slight_smile:

I deliberately avoided the "one error enum to rule them all" approach. So there are a few high level errors in error.rs and then each module will define it's own errors, for example: (at the bottom of)
https://github.com/drmason13/scut/blob/main/src/command/download.rs there's an enum with how downloads might fail.

1 Like

It's definitely more involving than just going anyhow::Result everywhere. Though remember that it's not necessary to use enums and list each error case. One can just go struct SomeError; per each layer.

But I really liked the error messages, and the ability to attach extra information. I was able to to get much more helpful error messages, which is important for tools that are more complex and user is expected to hit lots of "this didn't work because you're doing things wrong" cases.

BTW. In your code you seem to have lots of .into_report() because some parts of the code are not using error-stack. Just noticing it. Otherwise it comes down to some .change_context(DownloadError::Read)? between layers (which is also quite a lot actually, as errors tend to propagate upwards the layers).

I've looked at error-stack some a few times (mostly w.r.t. its support of "tree" errors with multiple sources), but I mostly don't see the benefit of using Result<T, Report<E>> over just Result<T, E>. But I also don't write much code in that space between library (where using a specific error type is desirable for interoperability) and application dispatch (where being able to do anything more than yeet errors to the top level is rare), so maybe I'm just not the target audience for error-stack. My code so far buckets nicely into either worth sticking with fully typed error composition (e.g. thiserror) or fully dynamic error context (e.g. anyhow), so the value-add of error-stack kinda doing both at the same time (change_context for static, attach for dynamic) eludes me.

Or in other words, I agree with eyre's conclusion that an "error" and an "error report" are separate things, and think error-stack conflates the two unnecessarily. Though if the goal is for hit unwrap/expect/error!/etc to automatically give the better report context without needing to remember to use the report adapter, then I suppose I see a benefit of pervasively using the report type.

2 Likes

To me it seems like the coolest parts need the provider API, which unfortunately is still nightly-only. I haven't played with it in large part for that reason. On current stable, the Context impl you get for any Error + Send + Sync + 'static doesn't do much, you can't look through the chain for existing backtraces/span traces, you can't attach std::io::ErrorKinds or grpc error codes to things, etc.

Also its backtraces are std::backtrace::Backtrace and I'm currently disappointed with its formatting (e.g. see rust-lang/rust#105413) and flexibility (e.g. can't extract addresses frame-by-frame to fill a pprof protobuf) but hopefully that will improve.

2 Likes

I'm still confused about the whole Context thing. I understand some pieces, but it doesn't build a full picture. I would appreciate something explaining the history and the goal std::any::Provider, Context, how it supposed to fit together more broadly and in error handling etc.

I haven't been involved with the provider API and can't really describe its history (other than pointing you at the rather verbose RFC PR comment thread), but here's what I think its value will be.

Let's say I have a long chain of errors (as in the most immediately accessible one's Error::source returns the next and so on) like my_app::Error -> my_lib::Error -> popular_lib::Error -> obscure_lib::Error -> lib_i_really_didnt_realize_i_was_using::Error -> etc.

Now let's say I'm having trouble understanding what went wrong. The chain of basically module/library boundaries isn't quite enough, given that I don't know the innards of these libraries. Maybe a stack trace will be more useful. But generating one at my_app::Error instantiation really isn't that helpful. I probably want the deepest one.

And I don't think the backtrace should be just printed as part of the Display logic. In part because the backtraces are super redundant. In regular synchronous code, most of the time, the deepest one has all the info, and the others are essentially subsets of that, because the point where those errors are instantiated (converted from deeper ones) are near where that code called into the code that made the deeper one. So it's better if you can access it in a richer way than just Display and remove redundancies, structure it nicely, etc. So I want a way to specifically request the backtrace.

Unfortunately, there's no Error::backtrace I can call. I think in part this is because of some complexity about core vs std split. I think it's also partly about a desire to solve the problem for things other than just backtraces. So let's generalize a bit and say there's some well-known type I want to get from an Error of unknown concrete type. And even if there were a say BacktraceGenerator trait, there's no ability to downcast to a trait.

The provider API will be a way for this unknown concrete error type to say "hey, I can produce a Backtrace", or a SpanTrace, or a std::io::ErrorKind, or whatever. Then I can call that from my application code without having some monstrous application-specific error printing dispatch logic that I have to keep extending with weird concrete types (maybe even of multiple versions of the same crate because that happens).

1 Like

error-stack was exactly what I needed for reporting syntax errors in an mp4 parser. The idea is that these parse errors will show up in client debug logs, and the extra context added to the error stack allows me to point to exactly what was apparently wrong in the input mp4. It also aids in writing and running tests to figure out what the parser got tripped up on. An example:

Missing required `stco` box at /rustc/84c898d65adf2f39a5a98507f1fe0ce10a2b8dbc/library/core/src/convert/mod.rs:726:9
 - while parsing `stbl` box field `co` at mp4san/src/parse/stbl.rs:15:10
 - while parsing `stbl` box field `children` at mp4san/src/parse/stbl.rs:8:24
 - while parsing `minf` box field `children` at mp4san/src/parse/minf.rs:9:24
 - while parsing `mdia` box field `children` at mp4san/src/parse/mdia.rs:9:24
 - while parsing `trak` box field `children` at mp4san/src/parse/trak.rs:9:24
 - while parsing `moov` box field `children` at mp4san/src/parse/moov.rs:10:24

Unfortunately, error-stack unconditionally depends on Backtrace and ended up adding too much code bloat to the client app in question, so I had to implement an error-stack-lite myself, if you will. Of course backtraces could be a toggle-able feature of error-stack but I haven't gotten around to filing a bug/PR for that yet.

4 Likes

For myself and interested people here's a quick anyhow vs error-stack comparison.

anyhow

use anyhow::ensure;
use anyhow::Context;
use anyhow::Result;

fn inner_body() -> Result<()> {
    ensure!(false, "something went wrong in inner body");

    Ok(())
}

fn body() -> Result<()> {
    inner_body()
        .context("inner body error")
        .context("there's something more I want to tell you")?;

    Ok(())
}

fn main() -> Result<()> {
    body().context("body error")?;
    Ok(())
}

error-stack

use error_stack::{ensure, Result, ResultExt};
use thiserror::Error;

#[derive(Debug, Error)]
#[error("InnerBody Error")]
struct InnerBodyError;

fn inner_body() -> Result<(), InnerBodyError> {
    ensure!(false, InnerBodyError);

    Ok(())
}

#[derive(Debug, Error)]
enum BodyError {
    #[error("Body Error: Inner")]
    InnerBodyError,
    #[error("Body Error: Outter")]
    OuterBodyError,
}

fn body() -> Result<(), BodyError> {
    inner_body()
        .change_context(BodyError::InnerBodyError)
        .attach_printable("there's something more I want to tell you")?;

    Ok(())
}

#[derive(Debug, Error)]
#[error("App Error")]
struct AppError;
fn main() -> Result<(), AppError> {
    body().change_context(AppError)?;
    Ok(())
}

Differences

error-stack will not compile if you don't .change_context between layers. .contexts calls are optional with anyhow, so you have to remember about them.

error-stack allows attaching extra data to errors, and they get grouped into a corresponding layer when printing. In anyhow, I guess one can keep adding more .contexts.

error-stack supports both struct SomeError; and enum SomeOtherError; which is handy. One could mix anyhow::Error and this-error's Errors wrapping anyhow::Error as a source, I guess.

When writting context(...) for anyhow it's unclear to me if I'm supposed to talk about the operation that failed, or the operation around it. With error-stack the type system forces me to the right(?) thing.

I'm not in love in these CLI-arrows style of error-stack output, but grouping into layers, extra attachments and pointer to code for each error layer are a great UX, IMO.

4 Likes

This is very interesting, how large is the overhead of capturing the stack traces though? This might matter if an error is relatively common and can be handled at a higher layer (rather than ending up erroring out the request/operation/program). Such as an item not being found in a file/container/thing that a higher level simply uses as a cache.

I think both anyhow and error-stack potentially suffers from this, if I understand things correctly.

Isn't that turned on with RUST_BACKTRACE=1 anyway?

1 Like

Oh. Didn't realise that. Still, it is sometimes useful to log back traces for most, but not all, errors (i.e. only unexpected errors). I don't know that there is a good solution for that though.

I never checked, but I can't remember backtrace handling ever being a perf problem.

In short: backtrace capture is fairly cheap. You probably don't want to do so in a hot loop, but otherwise it'll be fine. (But benchmarks trump theory.) Printing a backtrace can be fairly expensive, but doing with any regularity would be irregular and probably indicates a more pressing issue than performance. In long:

Support for performant backtrace captures has gotten better over time. But it's enough of an impact that backtrace capture defaults to off, even though panics (the only way std generates a backtrace) are by definition unexpected fatal errors (at least when they hit the panic handler[1]). Given the authority of a time machine, I'd at least capture backtraces in debug mode, and perhaps even only make backtrace capture default to off when disabling debug symbols.

However, of note is that an error backtrace (as opposed to a panic backtrace) is typically captured by user code, not by the stdlib, at least until 1.65. This means that in debug mode the backtrace code doesn't get optimized, and that's a noticeable performance impact; backtrace capture was one of the primary motivators behind being able to specify enabling optimization for only some dependencies (in order to optimize the backtrace crate). Using the std backtrace functionality removes that pitfall, but you'll still need to use the 3rd party backtrace for the time being if you want customized display like with color-backtrace.

Additionally to note, backtraces are actually done in two phases. The initial phase ("capture") can be relatively simple and quick, and if you handle the error without ever inspecting the backtrace, that's all that needs to be done. The second phase ("resolution") is the typically expensive part, mapping from instruction addresses back to more meaningful source locations. Thankfully this only needs to be done once per backtrace and only if you want to display the backtrace (which typically means logging a fatal error for later diagnostics), but it still isn't something you want to be doing often.


  1. If you squint enough, cancelling an async task by dropping the impl Future is unwinding the task. Making sync CPU-bound tasks async purely in order to inject cancellation opportunities (never yielding otherwise) is extremely heavyweight, and unwinding sync code by panicking (or rather resume_unwind; bypass the panic hook and backtrace) looks fairly reasonable in analogy; the behavior of the unwind is essentially identical, even though the method of unwinding differs. If you would have exception unwinding enabled anyway, either because you want to catch actual panics and/or the target ABI requires the unwinding metadata to exist, doing so can seem outright desirable. Rust encourages you to not use sync unwinding for expected control flow, especially in libraries since panics can be configured to immediately abort instead of unwind, but permits code to make this choice if it do desires. rust-analyzer does so and uses unwinding to cancel stale query computation. To temper the choice somewhat: cancellation as present in r-a is unrecoverable in the same way a bug panic is; the best r-a can possibly do when a query panics (either bug or cancellation) is to quickly discard that request and move on to the next one. ↩ī¸Ž

5 Likes

(apologies for the double post, but very distinct content.)

To add a little extra context to this, in the rare case where I've I've written proper "application code" that has enough layers doing more than just script-style plumbing and could benefit from attaching a higher level context stack I typically use tracing for structured log capture and function instrumentation, and notably tracing-error to capture a high-level structured span trace in my application error reports (typically via color-eyre), which automatically captures structured context from the functions annotated with #[tracing::instrument]. This information is both significantly more immediately relevant and more useful than a backtrace (including function argument values), and eliminates most need to manually provide .context() except when capturing a library error into an eyre error report.

color-eyre demo image

I also only really have worked on solo projects (I can usually trust myself to use impl Error (thiserror-y) error types for reasonably recoverable tail-ish errors and error reports (anyhow-y) when the error starts propagating between application layers) or already established projects (which have an established big-picture error handling pattern already), so to repeat, I'm likely not the intended audience/consumer for error-stack. To my sensibility, error-stack seems primarily aimed at the multi-team application stack use case where a) maintaining a manual cross-layer error context stack is beneficially more useful than an automatic stack backtrace or log spantrace, and b) enforcing the use of error-stack contexts in in-house code is an easier policy to maintain than trying to specify policy for how/when to define custom error enums versus bubble an existing error type versus use an application error report type.

If anything, I would liken it to having a "colored" anyhow; certain module/crate scopes within the larger application define their error context (e.g. as you might a thiserror aggregate error for the scope), and within that scope it's treated like an anyhow error, attaching dynamic context, but when you move to a different major context, the compiler will ensure you remember to add that context information in order to match the new report "color." It's less like a stack of "library" (thiserror-ish) errors, and more like a stack of "application" (anyhow-ish) errors. You get the convenience of anyhow within one context but the strictness of thiserror when switching contexts.

I also suppose that working with enum LibErrorKind { Io, Format, Other } can be easier than with enum LibError { Io(io::Error), Format(serde_format::Error), Other(Box<dyn 'static + Error>) }. Plus the benefit of having a stack-small (single nonnull pointer) report type to propagate doesn't go unappreciated; Rust code typically isn't particularly copy-efficient by default. (As opposed to C++ which ime on average ends up encouraging allocating more and copy/moving objects less than equivalent Rust code written at a similar level of idiomatic abstraction.)

As with most things, it depends on context[1]. If you have a library/abstraction boundary where most errors can be produced by most fallible operations (the quintessential example being that IO that seems local might actually entail an internet operation, or any other kind of context mixing), then using a single uniform error is preferable to trying to capture subtly different sets of potential errors from the same pool. Similarly, high-level convenience APIs that bundle multiple individually accomplishable fallible steps together generally benefit from using a shared high-level bundled error type instead of more specific ones; the caller is generally just interested in that you failed rather than how you failed, so making dispatch on how you failed slightly less convenient in order to simplify the API can be useful.

For many other cases, it's much preferable to have a specific error kind. Generally when a) there's a relatively small set of potential ways an operation could fail, b) in which of those ways you failed is potentially relevant to how the caller can address the failure, and c) the pool of potential failures is mostly distinct from how other code might fail.

So, I guess, use your best judgement. Add as much context as reasonably possible without significantly hurting your downstream usability or making your own code unmaintainable. (And also maybe refrain from embedding context which the caller provides by reference, to avoid causing unneeded clones when the caller still has that context.)


  1. heh, relevant ↩ī¸Ž

3 Likes

Thanks! I thought back trace capture was expensive unless you compile with frame pointers, which is relatively rare to do on x86 and x86-64 due to the low number of general purpose registers.

When you don't have a frame pointer in a register you end up needing to refer to DWARF debug info (on *nix) which requires interpreting instructions for an obscure virtual machine describing stack and register allocations. This is slow and complex, and was one of the motivations for the recent custom ORC metadata and unwinder in the Linux kernel.

At least that is what I heard for C/C++ code. Does rust do this differently, such that it is less of an issue?

Backtrace capture is done by whatever the usual way of doing so on the target is, and that does mean that walking the stack is nontrivial. But it's still cheaper than actually resolving the capture. (Also AIUI it often goes off of unwind metadata rather than debug.)

1 Like

I'm curious what people will think of my new crate stacked_errors - Rust . I haven't publicized it yet because the latest version had some major changes and needs some time to test using it in my other crates. The motivation for its design is that normal backtraces and single point Location or span gathering is useless in deep async call tasks. tracing-error and async-backtrace are too heavy handed with their macros and require a lot to attach mid-stack information. Mine requires just a .stack() or .stack_err(...) call to attach a Location, and at the same time do conversion without the map_err wrangling that you would need with most one-Error-to-rule-them-all types. Note that my Error type is intended more for higher-level crates that pull a lot of different things together (e.g. making calls to tokio and axum and file operations within the same function and needing a way to merge errors).

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.