[HN Gopher] Error Stacking in Rust
___________________________________________________________________
Error Stacking in Rust
Author : samanthasu
Score : 121 points
Date : 2024-12-19 01:47 UTC (21 hours ago)
(HTM) web link (greptime.com)
(TXT) w3m dump (greptime.com)
| samanthasu wrote:
| A good error report is not only about how it gets constructed,
| but what is more important, to tell what human can understand
| from its cause and trace. In this example, we analyzed and showed
| how to design stacked errors and what should be considered in
| this process.
| gfreezy wrote:
| async fn handle_request(req: Request) -> Result<Output> {
| let msg = decode_msg(&req.msg).context(DecodeMessage)?; //
| propagate error with new stack and context
| verify_msg(&msg)?; // pass error to the caller directly
| process_msg(msg).await? // pass error to the caller directly
| } async fn decode_msg(msg: &RawMessage) ->
| Result<Message> {
| serde_json::from_slice(&msg).context(SerdeJson) // propagate
| error with new stack and context }
|
| how to capture the virtual stack when `verify_msg` returns an
| error? Do you have some lint to make sure every error is attached
| with a context?
| shepmaster wrote:
| I don't think you need a lint. When you define the error type
| returned by `handle_request`, you decide how the error type
| returned by `handle_request` will be incorporated. If you've
| decided to implement `From` then you've decided you don't
| want/need to add context. Otherwise, the compiler will give you
| an error when you use `?`.
|
| The time I can think this won't work is when you are reusing
| error types across places. Recently, I've been experimenting
| with creating a lot of error types, so far as one unique error
| type per function. I haven't done this for long enough to have
| a real report, but I haven't hated it so far.
| namjh wrote:
| > Consequently, this also means you cannot define two error
| variants from the same source type. Considering you are
| performing some I/O operations, you won't know whether an error
| is generated in the write path or the read path. This is also an
| important reason we don't use thiserror: the context is blurred
| in type.
|
| This is true only if you add #[from] attribute to a variant.
| Implementing std::convert::From is completely optional.
| Personally I don't prefer it too as it ambiguates the context. I
| only use it for "trivially" wrapped errors like eyre::Report.
| skavi wrote:
| Yup. I absolutely would throw `#[from]` on everything when I
| started using thiserror, but now only do so in incredibly
| obvious cases like enum CarWontMove {
| EngineTroubles(EngineTroubles),
| WheelsFellOff(WheelsFellOff), }
|
| Even then, there's often some additional context you can affix
| at that higher level.
| shepmaster wrote:
| SNAFU follows much the same idea: we have an attribute you
| can add [0] when you want to allow directly implementing
| `From`. Like thiserror, you can also mark an error as
| transparent [1] when even the error existing doesn't provide
| useful information.
|
| [0]: https://docs.rs/snafu/latest/snafu/derive.Snafu.html#dis
| abli...
|
| [1]: https://docs.rs/snafu/latest/snafu/derive.Snafu.html#del
| egat...
| shepmaster wrote:
| Hey all, I'm the author of SNAFU (mentioned in the article). I'm
| off to bed now, but I'd be happy to try and answer any questions
| people might have sometime tomorrow.
|
| I'm glad to see SNAFU was useful to others!
| zamalek wrote:
| Its looks really neat! Two questions:
|
| * can it be used as a build dependency (i.e symbols from the
| snafu crate don't appear in the generated code).
|
| * I assume you have to use one of the macros (ensure! or
| location!) when constructing an error that contains a location?
| shepmaster wrote:
| It can't be used as a literal build dependency [0], no.
| However, the fact that your crates uses SNAFU should [1] be
| completely hidden from your users. From the outside, you just
| return a regular enum or struct as your error type. If you
| were to look at the symbols in the resulting binary, I would
| expect that you could see references to the trait method
| `snafu::ResultExt::context` (and similar functions across
| similar types) depending on how well the code was inlined. If
| you use other features like `snafu::Location` or
| `snafu::Report`, those would definitely show up.
|
| You don't have to use the macros, no. When you define your
| error type, you can mark a field as `#[snafu(implicit)]` [2].
| When the error is generated, that field will be implicitly
| generated via a trait method. The two types this is available
| for are backtraces and locations, but you could create your
| own implementations such as grabbing the current timestamp or
| a HTTP request ID.
|
| [0]: https://doc.rust-lang.org/cargo/reference/specifying-
| depende...
|
| [1]: There's one tiny leak I'm aware of, which is that your
| error type will implement the `snafu::ErrorCompat` trait,
| which is just a light polyfill for some features not present
| on the standard library's `Error` trait. It's a slow-burn
| goal to remove this at some point, likely when the error
| "provider API" stabilizes.
|
| [2]: https://docs.rs/snafu/latest/snafu/derive.Snafu.html#con
| trol...
| Sytten wrote:
| What is really annoying with thiserror is the wizard refusal to
| give us an easy way to print the error chain. No I dont want to
| convert it to anyhow just to print the error...
| lumost wrote:
| Rust is full of these, I've found the community simply falls
| back on user error to understand rust when vexed by in my
| opinion basic software operations.
|
| As someone who works extensively in cpp/java/python. I want so
| much to love rust, but unfortunately I haven't found it to be
| productive after 6+ side projects.
| nixpulvis wrote:
| Rust's community is slightly more fragmented than it should
| be. The community being built while the language was changing
| so dramatically (e.g. async) didn't help, but it also is part
| of what lead to Rust in the first place.
|
| But it's still somewhat young, lots of stuff is being built.
| So some of the lack of productivity probably just comes from
| not knowing the right stacks yet.
| lumost wrote:
| It's young, but my experience has been that developer
| ergonomics is not a focus, to the extent that c++ has a
| much stronger devex story.
| zote wrote:
| URL changed, I think: https://greptime.com/blogs/2024-05-07-rust-
| error-handling
| joshka wrote:
| It's technically feasible to add SpanTrace support to thiserror
| fairly easily (30 mins work - Issue:
| https://github.com/dtolnay/thiserror/issues/400, PR:
| https://github.com/dtolnay/thiserror/pull/401). This would solve
| part of the problem in a way that is meaningfully good for that
| side of the ecosystem. I suspect you could probably do something
| similar for Snafu
| shepmaster wrote:
| Without deeply looking into it, I'd expect that to integrate
| with SNAFU, you could basically write something like this:
| struct SpanTraceWrapper(tracing_error::SpanTrace);
| impl snafu::GenerateImplicitData for SpanTraceWrapper {
| fn generate() -> Self {
| Self(tracing_error::SpanTrace::capture()) }
| }
|
| And then you can use it as #[derive(Debug,
| Snafu)] struct SomeError {
| #[snafu(implicit)] span_trace: SpanTraceWrapper,
| }
|
| This will capture the `SpanTrace` whenever `SomeError` is
| constructed (e.g. `thing().context(SomeSnafu)` or
| `SomeSnafu.fail()`.
| joshka wrote:
| Neat :)
| lilyball wrote:
| > _Then, to be able to translate the stack pointer we will need
| to include a large debuginfo in our binary. In GreptimeDB, this
| means increasing the binary size by >700MB (4x compared to 170MB
| without debuginfo)._
|
| Surely that's comparing full debuginfo, right? Backtraces just
| need symbols, not full debuginfo, and there's no way the symbols
| are 4x the size of the binary.
| dwattttt wrote:
| There's also split-debuginfo, which allows emission of debug
| info into a separate file, rather than needing to distribute it
| in the binary. Then they could capture stack traces, and
| resolve the symbols later if necessary. That would also address
| their concern about how long it takes to capture a stack trace,
| because just gathering the addresses themselves is quick.
| exDM69 wrote:
| Why does adding `backtrace` to thiserror/anyhow require adding
| debug symbols?
|
| You'll certainly need it if you want to have human readable
| source code locations, but doesn't it work with addresses only?
| Can't you split off the debug symbols and then use `addr2line` to
| resolve source code locations when you get error messages from
| end users running release builds?
| delusional wrote:
| Your binary usually won't get loaded at the same address in
| memory. The addresses would be useless without the memory map.
|
| That's solvable though. The bigger problem is how you unwind
| the stack. the stack is not generally unwindable, unless you're
| the compiler. Debug symbols include information from the
| compiler about the stack sizes and shapes to help backtrace
| with unwinding the stack. It's quite possible to include such
| symbols in the final binary without adding debug symbols, a lot
| of compilers just don't have a specification for that.
| exDM69 wrote:
| > Your binary usually won't get loaded at the same address in
| memory.
|
| The addresses you typically see in a backtrace error message
| (with debug syms disabled) are relative to the sections in
| the binary file, the runtime address it was loaded at has
| already been taken into account and subtracted. At least
| that's how you typically see a backtrace address in a typical
| native app on Linux.
|
| > The bigger problem is how you unwind the stack.
|
| Rust can unwind the stack on panic when built without debug
| symbols.
| umanwizard wrote:
| You don't need debug symbols to unwind the stack, you just
| need the .eh_frame section, which compilers emit by default
| regardless of whether you're building with debug symbols.
|
| Source: I work on a profiler (Parca) that does stack
| unwinding. It works fine on Rust binaries with or without
| debug symbols.
| pornel wrote:
| It should be possible (it'd need to also save memory map), but
| for some reason Rust's standard library wants to resolve human-
| readable paths at runtime.
|
| Additionally, Rust has absurdly overly precise debug info.
|
| Even set to minimum detail, it's still huge, and still keeps
| all of the layers of those "zero-cost" abstractions that were
| removed from the executable, so every `for` loop and every
| arithmetic operation has layers upon layers of debug junk.
|
| External debug info is also more fragile. It's chronically
| broken on macOS (Rust doesn't test it with Apple's tools). On
| Linux, it often needs to use GNU debuginfo and be placed in
| system-wide directories to work reliably.
| exDM69 wrote:
| > (it'd need to also save memory map
|
| Typically the memory map is only required when capturing the
| backtrace and when outputting the stack frames' addresses
| relative the the binary file sections are
| given/stored/printed (with the load time address subtracted).
| E.g. SysRq+l on Linux. This occurs at runtime so saving the
| memory map is not necessary in addition to the relative
| addresses.
|
| Not sure if this is viable on all the platforms that Rust
| supports.
|
| > but for some reason Rust's standard library wants to
| resolve human-readable paths at runtime.
|
| Ah, I see that Rust's `std::backtrace::Backtrace` is missing
| any API to extract address information and it does not print
| the address infos either. Even with the `backtrace_frames`
| feature you only get a list of frames but no useful info can
| be extracted.
|
| Hopefully this gets improved soon.
|
| > External debug info is also more fragile.
|
| I use external debug info all the time because uploading
| binaries with debug symbols to the (embedded) devices I run
| the code on is prohibitively expensive. It needs some extra
| steps in debugging but in general it seems to work reliably
| at least on the platforms I work with. The debugger client
| runs on my local computer with the debug symbols on disk and
| the code runs under a remote debugger on the device.
|
| I'm sure there are flaky platforms that are not as reliable.
| ekimekim wrote:
| Ok, so the original idea of Result<T, Error> was that you have to
| consider and handle the error at each place.
|
| But then people realised that 99% of the time you just want to
| handle the error by passing it upwards, and so ? was invented.
|
| But then people realised that this loses context of where the
| error occured, so now we're inventing call stacks.
|
| So it seems that what people actually want is errors that by
| default get transferred to their caller and by default show the
| call stack where they occured. And we have a name for
| that...exceptions.
|
| It seems that what we're converging towards is really not all
| that different from checked exceptions, just where the error type
| is an enum of possible errors (which can be non-exhaustive)
| instead of a list of possible exception types (which IIUC was the
| main problem with java's checked exceptions).
| tux3 wrote:
| It does seem to be converging somewhere, but a major difference
| that I really like is pushing humans a little more to care
| about errors, instead of just letting whatever bubble up from
| wherever until a catch(...) somewhere.
|
| With checked exceptions, it's very common for the user to end
| up with only a cryptic message from a leaf function deep inside
| something, and that's very hard to interpret.
|
| Having a manual stack of meaningful messages that add context
| is so nice as a user. Even if I do get the stacktrace in a
| program that threw a deep exception, you typically won't
| understand anything as a user without access to the code, the
| stack trace for exceptions is just not meant for human
| consumption.
| shepmaster wrote:
| > pushing humans a little more to care about errors
|
| This is 100% a reason that I like using SNAFU. The term I use
| for this is a "semantic stack trace" -- a lot of the time,
| the person experiencing the error doesn't care that it
| occurred in "foo.rs" or "fn bar()" or "line 123". Instead,
| they care what the program is trying to do ("open the
| configuration file", "download the update file").
|
| When I'm putting effort into my errors, I basically never use
| `snafu::Location` or `snafu::Backtrace`. My error stacks
| should always be unique -- any stack can exactly point to a
| trace through my program.
| jcelerier wrote:
| But... It's not the user that is seeing this, it's the
| developer. You catch at the top of your event loop and you
| log the stack trace to some place that can be reached by
| the dev team, be it Jira, some crash reporting tool, etc.
| jandrese wrote:
| Yeah, but lots of diagnostic work is done by end users in
| the real world. Users rarely have good access to the
| developer team, if the team even still exists. Usually
| there are layers of insulation that mean your problem
| might be looked at in a few weeks or months only if the
| company thinks it might be interesting. Meanwhile you
| have your problem to fix and it is off to stack traces
| and access logs to try to figure out what went wrong.
| Maybe some library updated. Maybe there was a permissions
| change. Maybe some policy change at the OS level. Maybe
| some external resource went away or changed syntax. It is
| up to you as the end user to figure it out and fix it, or
| at least figure out a unique enough error message that
| you can Google to find someone else with the same
| problem.
|
| There is nothing more frustrating than a dialog box that
| says "An error occurred" and then the program shuts down.
| Frankly I'd rather it crashed hard, at least then I might
| have some evidence to sift through in the blast zone.
| Groxx wrote:
| > _Yeah, but lots of diagnostic work is done by end users
| in the real world. Users rarely have good access to the
| developer team, if the team even still exists._
|
| And hiding details prevents them from being able to know
| if error X is different from error Y, yes.
|
| It's an unhandled error at that point. You _do not know_
| what is relevant, essentially by definition, because
| otherwise you would have handled it.
|
| Display messages are almost completely unrelated to error
| handling, and have almost completely unrelated needs. If
| you decide to combine them, I'm pretty convinced that
| it's ALWAYS better to show ALL context somewhere, because
| otherwise troubleshooting frequently becomes impossible.
| It doesn't have to be a megabyte of stack trace info in a
| dialog box shown all the time, save it to a file and link
| to it or something.
| gpderetta wrote:
| The end user might not care, but as the developer I very
| much care about having a line-accurate backtrace.
| shepmaster wrote:
| When presented with a bug from the field, I also care
| about finding the path through my code where it occurred,
| but rarely do I need to know that `foo` called
| `foo_with_caching` called `foo_with_caching_recursive`.
| When reading a backtrace, I skip over amounts of
| "implementation details" to get a big picture. For me,
| the exact functions / files / line numbers are not
| relevant, doubly so if I'm working in a situation where
| the error message isn't tied to a specific git commit and
| the functions/files/lines have moved over time.
|
| To reiterate my point from above though, my error stacks
| are all unique -- seeing the stack will point me to an
| exact line in my code where the error occurred, even
| though I don't include function/file/line as-is.
| kelnos wrote:
| I don't really agree. Well, I do agree that often if I'm
| looking at a backtrace, I will be skipping over a lot of
| stack frames to find the "simplified" path that still is
| most useful.
|
| But functions? Yep, absolutely need them. Files? Not
| quite so much, since it's rare that I'd use the same
| function name between files. (But sure, throw it in
| anyway.) Line numbers? No, those can be a big help. If a
| user reports an issue to me, the first thing I will ask
| them (if they didn't fill out the issue template
| properly) is what version they're using (and what git
| hash, if they've self-compiled from a random git
| checkout). So I can check out the same version on my
| laptop, and having a line-accurate trace can be very
| helpful.
|
| > _To reiterate my point from above though, my error
| stacks are all unique_
|
| To reiterate mine, my error stacks often aren't unique,
| and crafting them such that they would be seems like
| pointless make-work when there are tools can make it so I
| don't need to care about this.
|
| I really don't get this resistance against including this
| information. It adds little to binary size and remove
| little from performance, so why not include it? I agree
| that backtraces do add a lot to binary size and can
| murder performance, but this "StackedError" concept with
| function/file/line information seems like essentially the
| perfect compromise. Just... include it, and stop worrying
| about it.
| kelnos wrote:
| The problem with encoding only "what the program is trying
| to do" in the error is that it only helps users when it's
| an "expected" situation. For the "open the configuration
| file" example, it's usually something the user can
| understand and fix on their own: file is missing, bad
| permissions, etc.
|
| But errors also need to be useful when reporting bugs to
| the author of the software. Error context and the error
| message can't always tell me what specific call stack
| caused the error, and I will most likely need that when
| tracking it down. I hesitate to want a backtrace included,
| as generating those is usually bad for performance, but I
| think SNAFU's "location" concept is a great compromise.
|
| I see your reply further down about "Users rarely have good
| access to the developer team", but I just don't buy that
| line of reasoning. As a developer, I both want to make it
| as easy as possible for my users to solve problems on their
| own (so: informative error messages that give the user a
| good chance of figuring it out themselves), but I'm only
| human, and I know all the software I write has bugs. So I
| want my error reporting to have enough information such
| that the user can contact me and give me as much
| information as possible about the error, without needing a
| lot of back and forth, or without me needing to ask them to
| run things in a debugger or use a special build.
|
| And on top of that, a lot of code is written inside a
| company, either as a network service, or tooling used only
| by people inside that company. The developers are very
| close to the use of that code, and having a lot of
| information come with errors is essential.
|
| > _My error stacks should always be unique -- any stack can
| exactly point to a trace through my program._
|
| That seems like more effort expended when using
| `snafu::Location` would suffice, without doing extra work
| that is IMO useless. I'd rather concentrate on other things
| and have my tools do fiddly, repetitive work for me.
| kibwen wrote:
| _> show the call stack where they occured. And we have a name
| for that...exceptions._
|
| Getting a stack trace isn't a distinguishing feature of
| exceptions; stack traces predate the notion of exceptions. The
| distinguishing feature of exceptions is that they're a parallel
| return path all the way back up to `main` that you can ignore
| if you don't care to handle the error, or intercept at any
| level if you do. For some contexts I think this is fine
| (scripting languages), and for other contexts I think that
| being forced to acknowledge errors in the main return path is
| preferable.
| danenania wrote:
| I think a lot of it is psychological. Being forced to ask
| yourself "what _do_ I want to happen if there 's an error
| here?" every single time seems to go a very long way. If the
| answer is "ignore it" or "bubble it up" then fine, but at
| least you considered and explicitly answered that question
| rather than totally forgetting that an unhappy path exists.
| Default consider vs. default ignore.
| ekimekim wrote:
| That's interesting. To me stack traces + default pass up the
| stack _are_ the distinguishing features of exceptions.
|
| Suppose we had a version of the ? operator that automatically
| appended a call stack to the error value returned. Are you
| saying that that's not "an exception" because I still need to
| write ? after each falliable function? Or because it's still
| part of the return type? Or is it specifically only an
| exception if it works via stack unwinding?
| jgilias wrote:
| Yes and no. When a language has exceptions the code is
| perpetually wrapped in a fallible computational context. When
| the Result is reified as a type, you have the option (ha!) to
| write code that the type system guarantees won't fail. This is
| nice.
|
| Let's not talk about panics, shall we?
| zokier wrote:
| That's not particularly novel observation; people have been
| pointing out the equivalence between checked exceptions and
| Result types for pretty much forever. See for example this
| thread from _decade_ ago:
| https://news.ycombinator.com/item?id=9545647
| anon-3988 wrote:
| I have a theory that what people actually want is something ala
| named exceptions + forced try catch with pattern matching +
| automaitally derived return Type.
| PittleyDunkin wrote:
| > But then people realised that 99% of the time you just want
| to handle the error by passing it upwards
|
| This seems like a gross exaggeration
|
| > So it seems that what people actually want is errors that by
| default get transferred to their caller
|
| Hell no
| IshKebab wrote:
| > So it seems that what people actually want is errors that by
| default get transferred to their caller and by default show the
| call stack where they occured. And we have a name for
| that...exceptions.
|
| You've drawn the wrong conclusion - we don't want that _by
| default_. We want to chose. In most cases we 'll just return
| the error to the caller, but we don't want it to be the default
| so we can miss critical points where we didn't want to do that.
| ragnese wrote:
| You're not far off. This is one of my favorite topics in
| programming language design discussions, and I have opinions
| that some may even say are "controversial". For what it's
| worth, I've been writing Rust in production since 2016 (not
| 100% of my time since then, but I've had a good amount of
| experience with some decently long-lived projects of varying
| complexity).
|
| First, I assert that Java's checked exceptions are a solidly
| _good_ feature. Of course it has flaws. The whole rest of the
| language is also full of flaws, so that 's not surprising.
|
| Second, I assert that there are two things that have caused the
| vast majority of hate toward Java's checked exceptions:
| programmers not being taught/shown how and when they're
| intended to be used, and that oft-circulated interview
| transcript from 2003 where Anders Hejlsberg asserts that
| checked exceptions are language design "dead end". I don't
| think he was right in 2003, and I especially don't think the
| opinion is correct today in light of how much strong static
| typing has really gained favor with the programming community.
| But, that opinion really took off and we spent years and years
| seeing that assessment repeated as a truism, which I think is
| why it took so long to finally start experimenting with
| statically typed failure modes again (e.g., Rust and Swift).
|
| Now, here's where I'll get controversial about Rust error
| handling. I'll try _really_ hard to keep this from turning into
| an entire dissertation, but I 'll elaborate if anyone asks.
|
| It is often a mistake to implement the `From` trait for error
| types and use the `?` operator everywhere. Error types in an
| API need to be aware of the context in which they occur, so
| just converting by type only often doesn't make sense. You may
| encounter a `FooError` type while your app is doing _totally_
| different things, so it 's likely that not every `FooError`
| occurrence _means_ the same thing to whoever is calling into
| your code. Also, sometimes you can actually _handle_ an error,
| and getting into the muscle memory habit of just tacking `?` on
| to everything can lead to mistakenly propagating errors that
| you might have better handled by doing something else
| (including perhaps panicking).
|
| There does seem to be a trend toward automatically adding stack
| traces in Rust errors. This is completely misguided, IMO. And
| this may be my MOST controversial opinion: stack traces almost
| *never* belong in a `Result<>` error type. Result types should
| be relevant to your "domain" (borrowing the term from "Domain
| Driven Design" even though I do NOT advocate for DDD in
| general).
|
| Think about it this way: designing an API is about abstraction.
| So if you write a integer division function that takes two
| arguments and divides them, it might return `Result<i64,
| DivideByZero>`. If the caller passes in a 0 divisor, then what
| business is it of theirs to see what your private functions are
| called, how many of them are called, and what line of your file
| they were defined on? That's the leakiest of leaky
| abstractions.
|
| You might be thinking: "But, if I see an result/error value
| that I didn't expect while running my program, the stack trace
| will help me track down the issue!" Yeah, no kidding. So, let's
| _also_ start adding stack traces to our successful values, too!
| After, all, if I call my division function and get back a
| `Result::Ok` with a weird number that I didn 't expect, I might
| want to trace that back, too, right? (This suggestion is
| sarcastic to prove a point. It should, hopefully, sound
| ridiculous to add stack traces to every return value from every
| function.)
|
| The issue is that Rust's Result (and Java's checked exceptions)
| require a different paradigm. A Result is in the type signature
| because it's part of your domain's API design. It's just
| values. It's not *for* debugging. You use a debugger for that
| or programmatically panic when something is truly unexpected
| and get the stack trace from that.
|
| Which leads to the corollary to the previous controversial
| opinion: Rust has unchecked exceptions; they're called panics
| and they are 100% *okay to use* in the vast majority of
| applications that the vast majority of day-job programmers work
| on.
|
| Obviously, context matters, and there are some places where
| panicking is unacceptable. But, Result is for expected domain
| failures. Panics are for programmer errors and unrecoverable
| constraint violations. And I'm not advocating for panics to be
| "lazy". Rust code that refuses to ever panic (as far as they
| know, but I hope they aren't indexing any vecs/arrays just in
| case!) usually leads to overly polluted error types where it
| ends up being difficult to understand what errors are actually
| meaningful and what errors are never actually going to happen.
| Instead of inspecting errors and figuring out which to handle
| and how, I've seen things just snowball into a giant mess of
| nested enums with sometimes redundant error "branches" and
| missed opportunities to actually handle some cases. If you, as
| the programmer, know for sure that you just added something to
| a HashMap earlier in your function and you know you didn't
| remove it, then for the love of all things sacred, just write
| `map.get("my-key").unwrap()` (or
| `.expect("message")`--whatever) instead of making the caller
| have to consider an error that will never happen, is not their
| fault, and that they can't do anything about!
|
| And, if you do have a situation where panicking is unacceptable
| (you must be using `#![no_std]`, right??), then don't make a
| bunch of different error types for all of the possible
| programmer bugs. Just make a single umbrella `FatalError` type
| and use that.
|
| For further reading, I really like this piece from the book
| Real World OCaml, which also has a Result type and exceptions:
| https://dev.realworldocaml.org/error-handling.html.
| Specifically, the very last section at the bottom of the page,
| titled: "Choosing an Error-Handling Strategy". (The old version
| of that page used to be more plain HTML and the sections had
| anchors so I could link directly to that section...)
|
| And for further reading about error handling strategy in a no-
| panic context, I really like the approach described here:
| https://sled.rs/errors
| ThatGeoGuy wrote:
| _You might be thinking: "But, if I see an result/error value
| that I didn't expect while running my program, the stack
| trace will help me track down the issue!" Yeah, no kidding.
| So, let's also start adding stack traces to our successful
| values, too! After, all, if I call my division function and
| get back a `Result::Ok` with a weird number that I didn't
| expect, I might want to trace that back, too, right? (This
| suggestion is sarcastic to prove a point. It should,
| hopefully, sound ridiculous to add stack traces to every
| return value from every function.)_
|
| I don't think I disagree with the ends you're proposing
| (don't add stack traces to every value, don't add stack
| traces specifically to Result::Err(E) variants); however,
| this is a bad way to justify it. Tools like dtrace / bpftrace
| do exactly this kind of stack tracing for both success and
| error cases across entire systems. This is a good thing(tm),
| and is actually very useful for both debugging, performance
| profiling, and understanding what your code is really doing
| on the hardware.
|
| So I guess I disagree with how you're framing it. I would
| argue that adding stack traces to every value in Rust would
| be bad because it is a lot of overhead for something your
| kernel can and will do better.
|
| _The issue is that Rust 's Result (and Java's checked
| exceptions) require a different paradigm. A Result is in the
| type signature because it's part of your domain's API design.
| It's just values. It's not _for* debugging. You use a
| debugger for that or programmatically panic when something is
| truly unexpected and get the stack trace from that.*
|
| This really is the gist of it. However, I will say that in my
| experience the reason that Result types are nice (over e.g.
| exceptions) is that putting the error cases in the type
| contract means that you can have the compiler check when
| someone hasn't handled an error case (? and unwrap are
| "handling" it even if they may not always be appropriate), as
| well as statically verify which variants may be unused. One
| very frustrating thing I've had to encounter in C++ is
| finding a whole list of different errors that have been
| duplicated as multiple different opaque (e.g. behind a
| unique_ptr<std::exception> or some such) exceptions across
| the codebase.
|
| Being able to know what variants of error can come out of an
| API is great! It just happens that working with a rich type
| system like Rust makes it possible to do all manner of things
| that languages-with-only-exceptions cannot.
| ragnese wrote:
| Yeah, fair point about dtrace, et al, but I think my
| statement is still fine in context, since we're
| specifically talking about these Rust libraries that
| collect stack traces for error types.
|
| And I agree and love having statically checked failure
| modes! So, if you're choosing to panic in Rust, it better
| be because of something that is really not able to be
| handled at all (caveat: the top-level event loop or
| whatever could catch panics/exceptions, print a "Oops!
| Something went wrong!" message to the user and then either
| die or try to keep going, etc, but no handling
| panics/exceptions in "middle" layers.).
| agos wrote:
| characterizing people who think checked exceptions as either
| bad programmers or unable to have their own opinion on the
| matter does not do a great service to your argument
| ragnese wrote:
| Yeah, that whole statement there is probably unnecessary
| and I can see it being off-putting. I'll edit it if I still
| can.
|
| However, I just want to make it clear that I wasn't
| intending to call anyone a "bad programmer". At least not
| in a personally insulting way. We've all been in a position
| where we were uninitiated at something. And most of us have
| been in a situation where we've jumped into a new
| programming language without having any kind of "formal"
| education on the design, philosophy, and intended best
| practices. For example, with Java, one _should_ read
| documents like: https://docs.oracle.com/javase/tutorial/ess
| ential/exceptions..., especially this part: https://docs.or
| acle.com/javase/tutorial/essential/exceptions....
|
| So, again, that part wasn't actually meant as an insult.
| We're all uneducated about many things at every point in
| our lives. And I think that lack of education or guidance
| on designing error types and handling has caused a lot of
| people to end up burying themselves in checked exception
| hell, and dismissing the whole thing because of that
| frustration.
|
| The other part about cargo-culting... well, yeah, that was
| me insulting people.
| ekimekim wrote:
| > Result is for expected domain failures. Panics are for
| programmer errors and unrecoverable constraint violations.
|
| The problem is that "unrecoverable constraint violations"
| happen a lot in practice when you're dealing with
| filesystems, networking...anything that isn't pure
| computation.
|
| Suppose I have a function that calls other functions that
| themselves make 3 database queries, two HTTP requests, and
| reads/writes from a cache directory. It considers all of them
| (except perhaps the caching) unrecoverable in the context of
| that function. What should it do?
|
| I see three reasonable options:
|
| (1). return a simple error type saying "Networking failure",
| "IO Error", etc if any of those fail
|
| (2). return a complex error type that exposes the internal
| details of all the different things it's doing and which one
| failed and why
|
| (3). panic if any of them fail
|
| I would argue that (1) is unfit for purpose as you have no
| idea what's actually going wrong.
|
| And (3) is currently very heavily discouraged, though I think
| if I'm understanding your argument right it probably makes
| the most sense. However it leaves your top-level function in
| the awkward position of needing to make that panic part of
| its API contract, without the type system to help. It's also
| highly limiting because the caller now can't distinugish
| between programmer errors and possibly-transient
| environmental conditions like a service outage.
|
| (2) is what I'd expect to see in practice right now, and
| that's what leads to these automatic stack traces, etc. But
| none of these feel like good options. Ideally I'd want
| something that is:
|
| - Debuggable (like (2) and (3))
|
| - Part of the type system (like (1) and (2))
|
| - Still allows introspection by the caller (like (1) and (2))
|
| - Doesn't require a ton of boilerplate at each level (like
| (3), and possibly (1))
|
| (edited for formatting)
| kelnos wrote:
| No, I don't think you understood the GP's argument. Network
| and filesystem errors are not always "unrecoverable
| constraint violations". They're often just simple errors --
| things that you should expect to happen, even -- and your
| (1), or, better, (2), are the most appropriate reactions to
| those.
|
| "Unrecoverable constraint violations" occur, for example,
| when you've done a sanity check on some data structure and
| found that it's in a state that should be impossible, and
| so continuing from there is unsafe.
|
| Even then, you may choose to handle them in a better way
| than simply aborting the program. For example, if I'm
| writing a HTTP service that is backed by a database, and I
| get a customer request that results in me finding that a
| column in the database is NULL when it shouldn't be, I'll
| probably just return a 500 error to the customer rather
| than panic!(). The assumption is that even though there's a
| problem with this particular data, that might be the result
| of an almost-never-hit edge case, and we can still serve
| other customer requests just fine.
|
| Sure, a simple single-user command-line application may
| choose to panic!() if a critical data file can't be opened
| from the filesystem. Maybe that is an "unrecoverable
| constraint violation" sometimes. But I think there's a lot
| of nuance you're missing.
| kelnos wrote:
| > _First, I assert that Java 's checked exceptions are a
| solidly good feature._
|
| I agree in theory, but I think they're very poorly
| implemented, and the syntax and tooling around handling them
| is terrible. And, frankly, those flaws (yes, I agree
| everything has flaws) make the overall feature mostly
| useless, unfortunately. It really doesn't matter where you
| think all the hate comes from; the hate is there, and it
| means that very few people use checked exceptions, except for
| where they're required to when stdlib methods throw them.
| Ultimately that's all that matters. If no one uses the
| feature, then it's not a useful feature, regardless of the
| reasons.
|
| > _The issue is that Rust 's Result (and Java's checked
| exceptions) require a different paradigm. A Result is in the
| type signature because it's part of your domain's API
| design._
|
| Correct, but in Java, checked exceptions are _also_ a part of
| the API and ABI, so there 's really little difference there,
| outside of ergonomics. (Which IMO are one of the most
| important parts!)
|
| > _(This suggestion is sarcastic to prove a point. It should,
| hopefully, sound ridiculous to add stack traces to every
| return value from every function.)_
|
| I don't think that proves a point. Sure, you can argue every
| proposal into absurdity; it doesn't make the suggestion
| itself bad.
|
| > _Rust has unchecked exceptions; they 're called panics and
| they are 100% _okay to use* in the vast majority of
| applications that the vast majority of day-job programmers
| work on.*
|
| Yes, and this really bothers me. I wish more people would
| annotate their functions with `#[no_panic]`. Actually, I wish
| that was the default, and if you want to write a function
| that panics or calls functions that can panic, you need to
| annotate the function with `#[can_panic]`, and the compiler
| should enforce that, and `rustdoc` should surface that in all
| documentation.
| packetlost wrote:
| Checked exceptions that don't automatically propagate up the
| call stack to be specific. There's a subtle but _incredibly_
| important difference between just "exceptions" and what you're
| describing.
| tonyhart7 wrote:
| You can use 1 type of error enum for your app
|
| for example me, Yes my code can fail and only have 1 type eg:
| AppError
|
| but I can supplement that with db error,cache error,serde error
| etc
| maxk42 wrote:
| There's a critical difference between exceptions and what's
| happening in this article: exceptions create de facto
| nondeterministic behavior in programs. They cause every line in
| a function to potentially result in a return from the function
| with an unexpected type. Rust's error handling requires
| explicit return statements and explicit return types. This
| critical difference results in code that is far easier to
| document, reason about, and slightly better performance as
| well.
| kelnos wrote:
| GP specifically said _checked_ exceptions, which don 't
| create the problems you describe. (They do create _other_
| problems, of course.)
|
| And exceptions don't have to be slower than putting errors in
| return values.
|
| (Having said that, I am still not a proponent of exceptions
| for error handling.)
| ljm wrote:
| Go's approach has been to treat errors as a linked list, and
| thus one would explicitly create a chain of errors by wrapping
| each one as it passes up the stack. The end result would be an
| error like 'Error Z: Error Y: Error X', as each error in the
| list is 'unwrapped'.
|
| The lack of any kind of caller information when creating an
| error makes it quite important to write decent error messages,
| which I think is actually quite hard to do.
|
| At the same time I think it depends on what you're building: a
| library should have good errors (ideally well-typed ones too),
| but in an application you'd benefit from adding logging at each
| point in the stack (which can then contain caller information
| like file and line number) rather than just doing the logging
| at a system boundary; maybe set it at debug level. Then use
| tracing for the rest of it (for extra visibility in stuff like
| Sentry).
|
| At least, I feel like that's how you'd be encouraged to do it
| in Go considering the opinions of Go's creators.
| Groxx wrote:
| Java's main issue is that its `throws` isn't generic. It forces
| middleware-like code to choose between `throws Exception` and
| runtime-only plus boxing... both of which lose ALL details and
| ruin your compile-time safety.
|
| IMO it just poisoned the well, and now everyone* _thinks_ they
| don 't like checked exceptions, when really they just don't
| like Java's badly crippled version.
| ragnese wrote:
| You can have generic `throws` markers; e.g.,
| interface Frobinicator<E extends Exception> {
| void frobinicate() throws E; }
| Groxx wrote:
| Which gives you a single exception type, not a list.
| Squashing the list of possibilities rather uselessly.
|
| You can work around this with N `T extends Exception`s, but
| now you have to pick the correct one all the time. And e.g.
| using it in a `map`-style stream with a final collected
| throw means picking whether you're adding type N or not. Or
| possibly multiple new types. It rapidly grows to be
| unusable.
|
| You also can't make a `class MyException<T>`. Or do a
| `catch (T e)`. There are a lot of blockages in practice to
| trying to do any of this - exceptions are very special in
| the type system, which is the problem.
| kelnos wrote:
| I get what you're saying, but this is still very different from
| (checked) exceptions, both in syntax and ergonomics.
|
| Java's checked exceptions are the worst. Having to declare
| every exception thrown as a part of your API/ABI makes for
| brittle, difficult-to-evolve interfaces.
|
| Rust's Result and '?' syntax sidesteps a few of these issues.
| You can "add" underlying errors to the error return of your
| function without changing its API/ABI. You don't need to add a
| bunch of try/catch blocks, cluttering and confusing the code,
| in order to make sense of this and convert exceptions into
| whatever your API/ABI specifies. Rust's 'From<>' trait is damn-
| near magical when it comes to error conversion and propagation.
|
| I get that not everyone is a functional programming enthusiast,
| but you can't do FP with exceptions. (Well, you can, via a sort
| of Try monad like Scala has, but it's error-prone and ugly to
| deal with.) With Result, you can, and it works seamlessly with
| the rest of the language and syntax.
|
| I don't think Rust's error model is perfect, but it's miles
| ahead of what I've worked with in most other languages.
| DavidWilkinson wrote:
| Interesting approach! We had a similar journey at HASH to
| figuring out how we deal with stacked errors (as well as
| collecting parallel errors), developed the `error-stack` crate to
| solve for it. It works by abstracting over the boilerplate needed
| to stack errors by wrapping errors in a `Report`. Each time you
| change the context (which is equivalent to wrapping an error) the
| location is saved as well, with optional spantrace and backtrace
| support. It also supports supplying additional attachments, to
| enrich errors. We spent quite a bit of time on the user output,
| as well (both for `Debug` and `Display`) so hopefully the results
| are somewhat pleasant to work with and read.
| gregwebs wrote:
| This seems like a user implement of Zig error return traces:
| https://ziglang.org/documentation/master/#Error-Return-Trace...
| k_bx wrote:
| The simplest thing that "just work" for me is replacing ? with
| .context(h!())? and this macro:
|
| #[macro_export]
|
| macro_rules! h { () => {
| concat!("at ", file!(), " line ", line!(), " column ", column!())
| };
|
| and then using anyhow::Result.
|
| Solves 99% problems in error handling
| dmart wrote:
| Using #[from] in a thiserror enum is an antipattern, IMO. I kind
| of wish it wasn't included at all because it leads people to this
| design pattern where errors are just propagated upwards without
| any type differentiation or additional context.
|
| You can absolutely have two different enum variants from the same
| source type. It would look something like:
| #[derive(Debug, Error)] pub(crate) enum MyErrorType {
| #[error("failed to create staging directory at {}",
| path.display())] CreateStagingDirectory{
| source: std::io::Error, path: std::path::PathBuf,
| }, #[error("failed to copy files to staging
| directory")] CopyFiles{ source:
| std::io::Error, } }
|
| This does mean that you need to manually specify which error
| variant you are returning rather than just using ?:
| create_dir(path).map_err(|err|
| MyErrorType::CreateStagingDirectory { source: err,
| path: path.clone() })?;
|
| but I would argue that that is the entire point of defining a
| specific error type. If you don't care about the context and only
| that an io::Error occurred, then just return that directly or use
| a type-erased error.
| shepmaster wrote:
| This is one of the things I like about SNAFU: it makes this
| preferred pattern the default and makes it nicer to use. For
| example, your usage would look something like this with SNAFU:
| create_dir(path).context(CreateStagingDirectorySnafu { path
| })?;
|
| Note a few points:
|
| 1. No need to use the closure
|
| 2. No need to carry the source error over yourself (`context`
| does this for you)
|
| 3. No need to explicitly call `clone` on the path (`context`
| does this for you)
| carlsverre wrote:
| Inspired by this blog post I just added an `#[implicit]` field
| feature to the `thiserror` crate. It makes it easy to
| automatically annotate errors with things like code location (per
| this blog post), a timestamp, or a backtrace without requiring
| further modifications to the thiserror crate. I'm hoping that
| dtolnay will consider it. You can find my PR here:
| https://github.com/dtolnay/thiserror/pull/402
___________________________________________________________________
(page generated 2024-12-19 23:02 UTC)