Use checked exception to keep local errors from being propagated by ? operator

The issue of "global error type" with ? operator

The sled project published Error Handling in a Correctness-Critical Rust Project introducing its error handling method. Summarize as follows:

  1. Errors can be categorized into fatal and non-fatal(local). We must propagate fatal errors and handle the local errors.

  2. Most catastrophic system failures are the result of incorrect handling of non-fatal errors.

  3. There is a tendency in the Rust community to throw all errors into a single global error type, which is a big enum that holds the various possible errors that may have been encountered at any point anywhere in the program.

  4. The ? operator is convenient for propagating errors. As code changes over time, a may_throw_local_errors()? may be added in the body of a throws_only_fatal_errors() by accident. A "big enum" in the latter's signature cannot help the compiler to catch this kind of mistakes.

Example of propagating local errors by accident

pub struct FatalError1;
pub struct FatalError2;
pub struct LocalError1;
pub struct LocalError2;

// The global error type is a big enum
pub enum Error {
    A( FatalError1 ),
    B( FatalError2 ),
    C( LocalError1 ),
    D( LocalError2 ),
}

fn handle_c( c: LocalError1 );
fn handle_d( d: LocalError2 );

fn throws_only_fatal_errors_v1() -> Result<(), Error> {
    may_throw_a()?; // if returns Err, only Err::A is possible. The same below.
    may_throw_b()?;
    may_throw_ab()?;

    may_throw_abcd().map_err( |e| match e {
        Error::C(c) => handle_c(c),
        Error::D(d) => handle_d(d),
        _ => (),
    });
}

// All is fine until code changes
fn returns_only_fatal_errors_v2() -> Result<(), Error> {
    may_throw_a()?;
    may_throw_b()?;
    may_throw_c()?;   // <----- unhandled local error propagated
    may_throw_ab()?;

    may_throw_abcd().map_err( |e| match e {
        Error::C(c) => handle_c(c),
        Error::D(d) => handle_d(d),
        _ => (),
    });
}

Don't put errors in Err variant that are not expected to be propagated

The sled's article suggested using nested Results to separate local errors from fatal errors:

pub struct A;
pub struct B;
pub struct C;
pub struct D;

pub enum FatalError {
    A( A ),
    B( A ),
}

pub enum LocalError {
    C( C ),
    D( D ),
}

fn handle_c( c: C );
fn handle_d( d: D );

fn may_throw_a() -> Result<(), A>;
fn may_throw_b() -> Result<(), B>;
fn may_throw_c() -> Result<(), C>;
fn may_throw_ab() -> Result<(), LocalError>;
fn may_throw_abcd() -> Result<Result<(),LocalError>, FatalError>; // nested!

fn returns_only_fatal_errors_v3() -> Result<(), FatalError> {
    may_throw_a()?;
    may_throw_b()?;
    // may_throw_c()?; // <----- compile error
    may_throw_ab()?;

    may_throw_abcd()?
    .or_else( |e| match e {
        Error::C(c) => Ok( handle_c( c )),
        Error::D(d) => Ok( handle_d( d )),
    })
}

Use checked-exception syntax to make error types flattened

The cex crate emulates checked exception handling in Rust. The possible error types are enumerated in function signature:

#[cex]
fn foo() -> Result!( Type throws ErrorA, ErrorB,.. );

Let's use cex to do code refactoring:

use cex::*;

pub struct A;
pub struct B;
pub struct C;
pub struct D;

fn handle_c( c: C );
fn handle_d( d: D );

#[cex] fn may_throw_a()    -> Result!( () throws A   );
#[cex] fn may_throw_b()    -> Result!( () throws B   );
#[cex] fn may_throw_c()    -> Result!( () throws C   );
#[cex] fn may_throw_ab()   -> Result!( () throws A,B );
#[cex] fn may_throw_abcd() -> Result!(
                 Result!( () throws C,D ) throws A,B );

#[cex]
fn returns_only_fatal_errors_v4() -> Result!( () throws A,B ) {
    may_throw_a()?;
    may_throw_b()?;
    // may_throw_c()?; // compile error, too
    may_throw_ab()?;

    may_throw_abcd()?
    .or_else( |e| #[ty_pat] match e { // ty_pat means type pattern match
        C(c) => Ok( handle_c( c )),
        D(d) => Ok( handle_d( d )),
    })
}

If you are not big fans of nested Results, a #[ty_pat(gen_throws)] can be used with a flat Result type.

#[cex] fn may_throw_abcd_v2() -> Result!( () throws A,B,C,D );

#[cex]
fn returns_only_fatal_errors_v5() -> Result!( () throws A,B ) {
    may_throw_a()?;
    may_throw_b()?;
    // may_throw_c()?; // compile error
    may_throw_ab()?;

    may_throw_abcd_v2()
    .or_else( |e| #[ty_pat(gen_throws)] match e { // generates arms to throw A,B
        C(c) => Ok( handle_c( c )),
        D(d) => Ok( handle_d( d )),
    })
}

The rules about fatal/local errors when using cex:

  1. a #[cex] fn propagates fatal errors enumerated in its signature.

  2. local errors not enumerated in Result!()'s throws type list are handled inside the function body, or some compile errors/warnings will emit, such as "patterns not covered" or "unused std::result::Result that must be used".

2 Likes

Your post never actually states a goal or asks a question, so I can't tell if you're trying to raise awareness of the cex crate or if you're suggesting this functionality should be part of the core Rust language. The former usually goes on URLO, so I'll assume you intended the latter and respond with:

The unfortunate reality is that what counts as a "local" or a "fatal" error is extremely context dependent, almost always including context not present in the crate propagating the error. So in general, every library crate needs to report most errors as if the calling application may or may not consider them fatal, and there's only a minority where it can safely assume one or the other. There are also many applications where locality and fatality of errors are orthogonal rather than exclusive, or where "fatal" and "non-fatal" is simply not the best way to split up possible kinds of errors (for example, the non-Rust code I work with effectively has a "generate a bug ticket" severity level). IMO this is actually one of the big reasons why Result values end up being better than exceptions as the "default" error handling strategy for a programming language ecosystem.

So cex is probably great for some projects where the errors and error handling needs happen to fit this conceptual model, but it's definitely not the right fit for every project and doesn't belong in std or the core language.

4 Likes

I am sorry that you got such impressions.

The API of cex has been greatly simplified and I am quite satisfied with it so no std intergration is required.

One half of this thread is a summary of the error handling method proposed by crate sled of which I am not the author. The other half is one small step in the same direction: flatten the nested Results.

If propagating almost all errors is the case, cex library does not stop the user from marking most of their functions with output type -> Result!( T throws CrateError ) and using ? for propagation. But if local errors do exists, cex will provide extra protecton than "the big enum". It works in both cases.

I don't quite understand.

Cex's Result!() is not a real exception but an std::result::Result.

If this has nothing to do with the development of the rust language and compiler, but instead just to do with using the language, then the post belongs on users and not internals.

2 Likes

Searching with "error handling" has 50+ results.

And I believe I have chosen the correct category: libs × 515 (Discussion around libraries, either in the community or officially supported ).

Fatal errors are called panics. What you call local errors may make sense to handle or not depending on the context. For example inside gui or proxy you will want to handle http errors, but when you have a cli application like rustup that downloads and installs one thing per invocation, then an http error is what you would call a fatal error. Printing an error message and exiting is the only sensible way to handle them.

Moderation note: This seems like something that belongs at https://users.rust-lang.org/ to me. This is an "internals" forum, which is for "everything related to the implementation and design of the Rust programming language."

I invite you to re-post this at the user forum. If you do, I'd encourage adding some questions or prompts to your initial post in order to engage discussion.