Pros and cons of std::panic::catch_unwind

Today I thought I would experiment with catch_unwind. I understand that in Rust, explicit handling of exceptions/errors using Result has long been the preferred solution, but catch_unwind seems to work just fine for me as well.

Right now I have a slightly uneasy feeling, and am deciding whether to proceed with it or to stick with Result. What's your experience been? Should I turn back before it's too late?! It certainly saves a certain amount of typing. What are the disadvantages?

Use Result wherever you can. This is idiomatic, and self-documenting. Using panics for "normal" error handling will be surprising to every Rustacean reading your code.

Panicking/unwinding in Rust is for bugs. It should happen only if the programmer wrote something incorrectly.

catch_unwind exists for last-resort cases where you can't ensure "proper" handling of errors (e.g. it's necessary in web servers to isolate failing tasks and prevent them from taking down the whole server).

9 Likes

If you’re writing a binary and you just want simple error handling because the specifics of which the returned error isn’t that important but you want to show the error message and maybe perform some cleanup, just use the anyhow crate. It makes error handling pretty simple. You lose some of the benefits of creating a custom Error type such as letting you handle different kinds of errors in particular ways, but that often isn’t important in a binary. And you can always incrementally add back in custom error types for parts of the code if it turns out to be necessary.

1 Like

Panicking will always print a message, this is a bad thing for expected errors like the remote end of a tcp connection closing the connection. If using RUST_BACKTRACE=1 panic messages even have a backtrace. (I have export RUST_BACKTRACE=1 in my zsh profile to force it for all rust programs, useful if something panicked when I wasn't trying to debug it yet.) Unwinding is slow and on Windows I believe backtrace generation can take seconds in some cases. catch_unwind doesn't work when you compile with -Cpanic=abort. -Cpanic=abort reduces the binary size and improves runtime performance by a couple of percent.

The only valid use cases for catch_unwind IMHO are for FFI boundaries to propagate the panic in a way that is not UB like unwinding over extern "C" functions is (will be aborting in the future) and when you have got something like a server where you can simply return a 500 http error code and should likely keep serving requests even after a bug caused the current request handler to panic.

1 Like

It's a parser/interpreter, running as a web server. The errors have the name of the routine or batch being parsed, type-checked or executed, with an associated line number.

Either method seems to work perfectly well, but there is less "boilerplate" in the exception version of the code - I don't have to declare a result for each function (many functions don't have a result at all), and I don't have to type a load of "Ok"s and "Err"s and question marks. I think it's easier to read the code, as there is less "noise", and it's slightly easier to write it ( I often forget "?" until the compiler reminds me! ).

My preference currently is for the exception based code, unless there is a solid technical reason to use Results - I haven't found one yet. But this seems to go against the Rust idiom. So I am hesitating.

That is my scenario, and given I need to handle exceptions anyway, it seems simple to handle them uniformly, and save quite a lot of typing. I haven't noticed unwinding being slow. Exceptions should be very, very rare ( apart from trivial syntax/type errors in the interpreted language, and even those should be just a handful per week ).

In your case the errors from the parser/interpreter are expected and not the result of bugs in your server. You probably need to handle both kinds of errors separately anyway. You probably don't want to give a 500 internal server error when the user input is invalid. It should probably be more something like 400 bad request with a nice error message.

Assuming that you are using rocket, panics are already handled for you and turned into 500 internal server error.

3 Likes

Common cases are:

For logical errors, use Result. Example: user supplied invalid date as an input -> Err(InvalidDate).

For environmental errors, use Result. Example: database process you are communicating with gets killed, and the corresponding read from connection to the database returns an error -> Err(io::Error).

For programmer's bugs, use panic!. Example: assert!, unreachable! when an internal program invariant is violated.

Less common cases:

If you are writing something critical, where it is super-important that fatal environmental errors shut the thing down and and are not silently converted to data corruption, it might be better to panic on fatal environmental errors. Logic errors are recoverable (and are often recovered), using the same Result for them creates a risk of mishandling the error. Although, if you are in control of such correctness-critical code, the best solution is to use Result<Result<T, LogicalError>, FatalEnvironmentError> (see Error Handling in a Correctness-Critical Rust Project | sled-rs.github.io).

If you are writing something critical and embedded into another application where it's paramount that you don't bring the host down, it might be better to use Result for programming errors. Ie, you can ban [] operator (which pancs), and just always use get. The better strategy is to panic in debug and return a neutral answer in release: The Use Of assert() In SQLite

If you are writing something where availability is important, you need to make sure that bugs in software don't bring the software down. If that's the case, you need your application architecture to support strict state management, such that most of the code can't mess-up state. Than you can wrap that "most of the code" into a catch_unwind. Example: a web-servise where each request is running inside catch_unwind inside transaction. Recovering from bugs is fine as transaction gets aborted.

Esoteric cases:

rust-analyzer had a great success with using catch_unwind to implement cancellation. Cancellation is not an error, it's a serendipitous success, but unwinding is a good match for it.

3 Likes

Right, but they should never happen. All the exceptions are handled ( unless something very awful happened ) and are specific to the interpreted code that is being compiled or executed. They are not really "user" errors either, they are programmer errors ( by the software engineer who is maintaining the system, usually me, and in any case, most of the code is machine written, so they are not really expected ).

I'd be curious about what you'd think of using #[fehler] to replace the exceptions with it. You will still have to use ? (with a #![warn(unused_must_use)] you should always be able spot the "statements" where you might forget them), but the signature of the function will be a bit more lightweight, you won't need to Ok() wrap, and you will be able to use throw!(err) rather than return Err(err);, basically "tainting" the Err variant as more of an exception.

I would have a lot of "#[throws(MyError)]" lines throughout the code. Nothing major, but still a bit of a distraction. I think on grounds of simplicity/non-obscurity I'd prefer not to use macros unless there is a significant benefit. As far as I can tell, catch-unwind fits my needs perfectly well.

The fehler "About" claims "Rust doesn't have exceptions"! I think that's a trifle misleading. It has two official exception handling systems, Result/Err/? and the more recent catch_unwind.

I can see that for some situations catch_unwind might be quite unsuitable. I don't think I am in one of them. Or if I am, I haven't realised it yet!

One con that hasn't been mentioned yet is that “double panic” (panicking during unwinding) will always abort the entire process, and cannot be caught. So if you are using panicking a lot for error handling, and relying on unwinding, then you have to be very careful about what code runs in destructors.

1 Like

Of course, internal server errors should never happen, but they occasionally do anyway. There's a difference between "user pasted his thesis into the input field instead of an expression" and "programmer forgot to check the size and indexed into an empty slice". The first kind of error is not exceptional, it is expected that users will occasionally do dumb stuff accidentally or on purpose, and the user should expect some feedback about what they did wrong. The second kind of error is exceptional, its existence should have the least possible impact on happy path performance, and whatever internal server details are relevant should be logged and the user shown a 500 page.

Of course you can distinguish between kinds of panics. That means you must be doing some checking at catch_unwind, which means you're either using panic_any with custom types and downcasting it (ew) or parsing the string representation (double ew). I guess I can see how not having to use ? or -> Result<T> might add up over a deep call stack but... in terms of error handling, I guess I think of most of the boilerplate being in creating custom error types, messages and conversions, which just surfaces in another form if you are doing matching on panic types at the top level.

Worth it? :man_shrugging:

1 Like

I think "no panics in drop" is a good strategy no matter how errors are handled. Because it's such a minefield otherwise (unless you really don't mind immediate aborts).

I agree, and this strategy is fairly easy to follow in idiomatic Rust code, where panics are rare and can only result from incorrect code. But it would become harder to follow if you are using panics pervasively for all types of error handling, so almost any function might panic even in correct code.

3 Likes

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.