slau
5 days ago
I disagree that the status quo is “one error per module or per library”. I create one error type per function/action. I discovered this here on HN after an article I cannot find right now was posted.
This means that each function only cares about its own error, and how to generate it. And doesn’t require macros. Just thiserror.
shepmaster
4 days ago
> I create one error type per function/action
I do too! I've been debating whether I should update SNAFU's philosophy page [1] to mention this explicitly, and I think your comment is the one that put me over the edge for "yes" (along with a few child comments). Right now, it simply says "error types that are scoped to that module", but I no longer think that's strong enough.
[1]: https://docs.rs/snafu/latest/snafu/guide/philosophy/index.ht...
nilirl
4 days ago
Just wanted to say, you single handedly made me a rust programmer.
Your answers on stack overflow and your crates have helped me so much. Thank you!
shepmaster
4 days ago
You are quite welcome; thanks for the kind words!
conaclos
4 days ago
I accept, however this requires to create many types with corresponding implementations (`impl From`, `impl Display`, ...). This is a lot of boilerplate.
shepmaster
4 days ago
In addition to the sibling comment mentioning thiserror, I also submit my crate SNAFU (linked in my ancestor comment). Reducing some of the boilerplate is a big reason I enjoy using it.
colanderman
4 days ago
thiserror automates all of that: https://docs.rs/thiserror/latest/thiserror/ Highly recommended.
conaclos
3 days ago
Sure, but it's an additional dependency. I would prefer it if some of this machinery were added to `core`.
WhyNotHugo
4 days ago
> I disagree that the status quo is “one error per module or per library”.
It is the most common approach, hence, status quo.
> I create one error type per function/action. I discovered this here on HN after an article I cannot find right now was posted.
I like your approach, and think it's a lot better (including for the reasons described in this article). Sadly, there's still very few of us taking this approach.
YorickPeterse
4 days ago
I suspect you're referring to this article, which is a good read indeed: https://mmapped.blog/posts/12-rust-error-handling
atombender
4 days ago
With that scheme, what about propagating errors upwards? It seems like you have to wrap all "foreign" error types that happen during execution with an explicit enum type.
For example, let's say the function frobnicate() writes to a file and might get all sorts of file errors (disk full, no permissions, etc.). It seems like those have to be wrapped or embedded, as the article suggests.
But then you can't use the "?" macro, I think? Because wrapping or embedding requires constructing a new error value from the original one. Every single downstream error coming from places like the standard library has to go through a transformation with map_err() or similar.
remram
4 days ago
"?" call "into()" automatically, which covers simple wrapping.
BlackFly
4 days ago
The article expressly suggests not wrapping/embedding those errors but instead putting the errors into context. That suggestions starts with the sentence "Define errors in terms of the problem, not a solution" and explicitly shows a wrapping as an anti-pattern then lays out the better solution. Note that the author's solution serializes the underlying error in some cases to avoid leaking it as a dependency.
You can use ? when you implement From (to get an automatic Into) which which can be as easy as a #[from] annotation with thiserror. You can manually implement From instead of inlining the map_err if you so choose. Then you are only ever using map_err to pass additional contextual information. You usually end up using ? after map_err or directly returning the result.
colanderman
4 days ago
I find that directly propagating errors upwards is an antipattern. In many/most cases, the caller has less information than you do for how to deal with the error. (Your library, after all, is an abstraction, of which such an error is an implementation detail.) It also prevents you from returning your own error condition which isn't one that the downstack error type can represent.
It may be that you do in fact have to throw your hands up, and propagate _something_ to the caller; in those cases, I find a Box<dyn Error> or similar better maintains the abstraction boundary.
mananaysiempre
4 days ago
It’s a nice, well-written and well-reasoned article, and yet after shoveling through all the boilerplate it offers as the solution I can’t help but “WTF WTF WTF ...”[1].
conaclos
4 days ago
Thanks for sharing!
The only thing I disagree with is error wrapping. While I agree that the main error should not expose dependencies, I find it useful to keep a `cause` field that corresponds to the inner error. It's important to trace the origin of an error and have more context about it. By the way, Rust's [Error] trait has a dedicated `cause` method for that!
[Error] https://doc.rust-lang.org/nightly/core/error/trait.Error.htm...
slau
4 days ago
Yes! Thank you!
edbaskerville
4 days ago
I thought I was a pedantic non-idiomatic weirdo for doing this. But it really felt like the right way---and also that the language should make this pattern much easier.
resonious
4 days ago
The "status quo" way erodes the benefit of Rust's error system.
The whole point (in my mind at least) of type safe errors is to know in advance all if the failure modes of a function. If you share an error enum across many functions, it no longer serves that purpose, as you have errors that exist in the type but are never returned by the function.
It would be nice if the syntax made it easier though. It's cumbersome to create new enums and implement Error for each of them.
klodolph
4 days ago
It’s not just syntax, there are semantic problems.
Like, function 1 fails for reason A or B. Function 2 fails for A or C. You call both functions. How do you pattern match on reason A, in the result of your function?
In Go, there’s a somewhat simple pattern for this, which is errors.Is().
Expurple
4 days ago
It's a tradeoff. When you have a "flat" union like `A | B | C` that makes it easy to pattern-match "leaf" errors, you give up the ability to add any additional context in function1 and function2. Although, there are workarounds like
struct Error {
leaf_error: A | B | C,
context: Vec<String>,
}
mananaysiempre
4 days ago
> also that the language should make this pattern much easier
Open sum types? I’m on the fence as to whether they should be inferrable.
Expurple
4 days ago
> I create one error type per function/action. I discovered this here on HN after an article I cannot find right now was posted.
It could be this one: https://sabrinajewson.org/blog/errors
agent327
4 days ago
How does that compose? If you call somebody else's function, do you just create a superset of all possible errors they can return? What if it is a library that doesn't really specify what errors an individual function can return, but just has errors for the whole library?
j-pb
4 days ago
You return an error specific to that function.
If it internally has a `InnerFuncErr::WriteFailed` error, you might handle it, and then you don't have to pass it back at all, or you might wrap it in an `OuterFuncErr::BadIo(inner_err)`or throw it away and make `BadIo` parameterless, if you feel that the caller won't care anyways.
Errors are not Exceptions, you don't fling them across half of your codebase until they crash the process, you try to diligently handle them, and do what makes sense.
So you don't really care about the union.
slau
4 days ago
There's a bunch of different situations that can be discussed, and it's hard to generalise. However:
Your function typically has a specific intent when it tries to call another function. Say that you have a poorly designed function that reads from a file, parses the data, opens a DB connection and stores the data.
Should I really expect an end-user to understand an error generated by diesel/postgres/wtfdb? No, most likely I want to instruct them to generate debug logs and report an issue/contact support. This is most likely the best user experience for an application. In this case, each "action" of the function would "hide" the underlying error––it might provide information about what failed (file not found, DB rejected credentials, what part of the file couldn't be parsed, etc), but the user doesn't care (and shouldn't!) about Rust type of diesel error was generated.
To answer your question specifically, I might go without something like this:
#[derive(Debug, Error, Clone)]
pub enum MyFunctionError {
#[error("unable to read data from file: {0}")]
ReadData(String),
#[error("failed to parse data: {0}")]
Parse(String),
#[error("database refused our connection: {0} (host: {1}, username: {2})")]
DatabaseConnection(String, String, String),
#[error("failed to write rows: {0}")]
WriteData(String),
}
Obviously this is a contrived example. I wouldn't use `#[from]` and just use `.map_err` to give internal meaning to error provenances. `DatabaseConnection` and `WriteData` might have come from the same underlying WTFDbError, but I can give it more meaning by annotating it.When building a library, however, yes, I most likely do want to use `#[from] io::Error` syntax and let the calling library figure out what to do (which might very well giving the user a userful error message and dumping an error log).
thrance
4 days ago
That's the way, but I find it quite painful at time. Adding a new error variant to a function means I now have to travel up the hierarchy of its callers to handle it or add it to their error set as well.
layer8
4 days ago
This is eerily reminiscent of the discussions about Java’s checked exceptions circa 25 years ago.
mananaysiempre
4 days ago
The difference is that Java does not have polymorphism for exception sets (I think? certainly it didn’t for anything back then), so you couldn’t even type e.g. a callback-invoking function properly. Otherwise, yes, as well as the discussions about monad transformers in Haskell from 15 years ago. Effects are effects are effects.
layer8
4 days ago
You can abstract over a finite set of exception types in Java with generics, but it doesn’t always work well for n > 1 and is a bit boilerplate-y. The discussions I was referring to predate generics, however.
Expurple
4 days ago
That's only the case when your per-function error sets are "flat" (directly contain "leaf" errors from many layers below).
You can avoid this issue if you deeply nest the error types (wrap on every level). It you change an error that's not deeply-matched anywhere, you only need to update the direct callers. But "deep" errors have some tradeoffs [1] too
slau
4 days ago
This shouldn’t happen unless you’re actively refactoring the function and introducing new error paths. Therefore, it is to be expected that the cake hierarchy would be affected.
You would most likely have had to navigate up and down the caller chain regardless of how you scope errors.
At least this way the compiler tells you when you forgot to handle a new error case, and where.
mdaniel
4 days ago
Sometimes that error was being smuggled in another broader error to begin with, so if the caller is having to go spelunking into the .description (or .message) to know, that's a very serious problem. The distinction I make is: if the caller knew about this new type of error, what would they do differently?
larusso
4 days ago
Have an example that I can read. I also use this error but struggle a bit when it comes to deciding how fine grained or like in the article, how big an error type should be.
slau
4 days ago
A sibling reminded me of the blog post that convinced me. Here it is: https://mmapped.blog/posts/12-rust-error-handling
LoganDark
4 days ago
`thiserror` is a derive macro :)
akkad33
4 days ago
Why one error type per function. That seems overkill. Can you explain the need
Expurple
4 days ago
See the two articles linked in the sibling comments