The Missing Piece in Rust Error Handling

68 points by snej a day ago on lobsters | 21 comments

abrambleninja | a day ago

Wow, this is very cool, thanks so much for sharing! I've had this exact frustration with error types before and this seems like a really nice way to handle it. The ability to define errors as a small set of them that actually can occur at a given call instead of packing it into module-level enum with irrelevant errors is so nice for being able to refine what actually happens. I'm very excited to give this a go!

Another annoyance that I've run into for error handling is that it is annoying to write code that returns multiple errors. Say you're writing a backend CRUD application and you have to verify the fields of a create request. It would be nice to be able to return all fields that were in violation of the constraints rather than just the one, but it's difficult to do that with Result since ? returns early. I've seen some solutions using some sort of error vec crate, but they've never felt very elegant to me. I'm curious to see if some constructs in this crate have any hints at a solution.

goldstein | 13 hours ago

exposing ParseIntError in this way feels iffy: this isn’t a failure to parse just any int, this is a failure to parse a port in particular. suppose your address contains two ints (maybe you’re connecting to a redis db and you have a port and a db number): now ParseIntError is ambiguous. this breaks either ? or automatic widening: now you need to specify which particular int failed to parse, by doing something like .map_err(ParsePortError) manually (just #[context] won’t do if you want to change this programmatically, because #[context] doesn’t change types).

for that reason, I think impls like From<ParseIntError> for ConnectionError are anti-patterns, and generally you should try to wrap “low-level” errors like these into more semantic types (or variants, you can have ConnectionError::ParsePort(ParseIntError) and ConnectionError::ParseDb(ParseIntError)). would be nice to see some infrastructure for that, although I’m not sure if it’s doable without some heavy macro use.

(a separate question is whether you should expose underlying errors at all in any particular case: maybe redis will transition to named dbs, and you’d wish you just left that error opaque)

mcmah309 | 10 hours ago

I agree with your assertion. The example situation was not the best. It was more there to show the flow/api of eros. In real code don't expose ParseIntError unless the caller cares about that exact error type. The 1. philosophy in the README is listed as "Error types only matter when the caller cares about the type, otherwise this just hinders ergonomics and creates unnecessary noise."

goldstein | 2 hours ago

so how would you approach exposing “port parsing failed” but not ParseIntError in programmatically-accessible way with eros? manual newtype + .map_err()?

wrs | a day ago

This looks fantastic! Is there a downside?

I'm vaguely wondering if there could be an anyhow adapter so you could transform errors into this when you call existing code.

madsmtm | 23 hours ago

I would've said a downside would be that you'd have to order your types in a certain way for e.g. .widen() and .narrow()... But it seems like it works regardless of ordering? The eros library seems to have a bunch of hacks tech to make your errors an actually unordered set.

Maybe compiler errors are a downside? I haven't tested it, but I'd suspect that if you happen to specify the same type twice / accidentally specify non-error-types / similar invalid combinations?

Regardless, I'd love to read a blog post about how all of this works.

I couldn't agree more with you that a post covering that logic would be great extra reading. From a cursory glance, it seems that this has a manual implementation for each count of different errors that is being returned via a eros::Result type? So, I would assume since this goes A-Z that would mean the limit is returning 26 distinct types of errors from a function. (Not that I could anticipate that bound ever being insufficient.)

mcmah309 | 10 hours ago

Yes that is correct

mcmah309 | 10 hours ago

Author here. Compiler errors can be a downside, since the type system is used pretty extensively here. But that really is only on the development front. For users, the api straight forward and the api will give you straightforward things like "the trait bound i32: SendSyncError is not satisfied"

OCaml has this approach to errors built-in thanks do its polymorphic variants feature. The downside in my experience is that when one can more easily make unions between sets of errors one will take that approach. Without that point where the programmer is given a moment to explicitly design the error it tends to end up under-designed, lacking any additional useful context that could have been added otherwise.

mcmah309 | 10 hours ago

Author here. The only downside in my opinion is that is does not work on no_std without alloc. But even this I just figured out with the inspiration of a colleague. Hopefully a PR for this will be up in the coming days -- A lot of pieces to get right and conditional compilation, but the api will be the same. Overall for eros, I meticulously designed the api and am very happy with it. Full flexibility to the developer.

Yes there is an anyhow adapter behind the anyhow feature flag https://github.com/mcmah309/eros#anyhow

yshui | 22 hours ago

IIUC this is essentially anyhow with compile time type tags. The only thing I would worry about is how long it will take to compile, since the traits look a bit difficult to solve.

To my inexpert eye it looks like the subset search (the aforementioned unordered set trick) is quadratic to solve — but at least not exponential, and perhaps not a problem with a typical number of error types in the set? (I also see it’s arbitrarily limited to 26 error types, because the tuple generics have to be done explicitly.)

mcmah309 | 10 hours ago

Somone just benchmarked this. After the first compilation, the same as time as anyhow. https://github.com/dpc/eros-bench

yshui | 3 hours ago

Well that mean incremental compilation is still fast. But look at the numbers, first time compilation is apparently taking 2-4x as long compared to anyhow. And I think if you want a more like-for-like comparison you should look at thiserror rather than anyhow. And I would also very much interested to see how the compile time grows w.r.t. number of error types in the union.

reivilibre | 8 hours ago

I really like error_set and seeing this is by the same author gives me some confidence!

It looks a lot like terrors that I'm curious about the differences.

And how does this fit in with error_set? Does it make the unions defined by error_set obsolete? But I guess it's still useful for writing the error structs...

I'd like to see a real project written with this stuff to pick up some patterns. I'll probably have a play at some point :)

This is super-cool and I'm very tempted to go try and apply this to a few projects. (But uh I have to do "real work" now.)

One possible downside I see: when you have to craft container error types or manually convert among them, you are kinda forced to think about context propagation. A bare "FileNotFound" error needs to be annotated from the caller to indicate which file was not found, or otherwise the message shown to the user will be near-useless. Merely being able to propagate io::Error doesn't carry this friction, so it'll be propagated without details.

Now, crafting container error types doesn't solve the problem above and it's still possible to end up with insufficiently-specified errors. But at least it offers a sort of checkpoint. @mcmah309, what is your experience with this?

mcmah309 | 5 hours ago

If I understand correctly, this sounds like an issue regardless of using eros or not. You are saying io:Error does not carry all the information you need? In that case, wouldn't wrapping in a container type be enough -- e.g. FileNotFound(io::Error, String) (or even deconstructing the io:Error to whatever shape you want). This is what I would do if I had a recovery tool or message tool up the call stack that could do something based on that type. But if you just want to propagate the information, I wouldn't expose a plain io:Error if it is not actionable, but either way you could just add context .with_context(|| format!("Reading file {file_name}")). Depending on the recipient of this you could use .with_user_context instead. Another alternative is using the #[context] macro. See https://github.com/mcmah309/eros#context-macro and https://github.com/mcmah309/eros#context-placement-two-approaches

intarga | 4 hours ago

this is very similar to how error handling works in hare

alper | 8 hours ago

ErrorUnion is an open sum type.

I was a bit confused whether this was an existing Rust feature or a "new invention".

Is it built using Rust unions? (Something I don't think I've ever seen used.)

It feels adjacent to Roc's open tag unions?

academician | 7 hours ago

The source is here. It doesn't use Rust's union, but instead uses a TypeSet which in turn uses some type system magic to turn the set of errors into a compile-time linked list (of up to 26 elements).