Propagating errors from tokio tasks

A nice discovery with tokio is how it gracefully handles panics inside individual tasks, without bringing down the whole app.

However, is there a way of actually propagating other kinds of error from within a task? I'm thinking of a case along the lines of:

task = tokio::spawn(async move {
    // stuff

    let result = match something {
        Ok(x) => / * whatever is wanted */,
        Err(e) => return Err(e),    // this fails
    }

    // more stuff
});

The above formulation doesn't work, and if I understand right that's because the task should resolve to either () or a JoinError. But I can't see how a JoinError can be used to propagate any information about the original error (e.g. I can't return JoinError(e)). Can anyone advise how to achieve the wanted outcome, or what the intended alternative is?

The particular use-case, if it helps, is that the task performs a gRPC request, and if it fails I want to propagate the error Status and message.

I can readily think of ways to work around the problem but I'd like to understand what the intended or idiomatic means of task error propagation is.

Thanks in advance for any advice!

AIUI, how you handle and propogate errors that happen in the process of a Future<Result<_>> is just a subset of the question of how you handles values produced in the process of a Future<T>, because the constraint that your spawned task be Future<()> impacts you the same either way - you either have to have side effects in your task to do something with your value, or put it somewhere for another thread/task to find.

Obviously, if you're doing the latter, you can just have somewhere to put the error code rather than the succesful value. If you're doing the former, then at some point within the task you're going to have to handle the error case, i.e. do something side-effectful and return (). If you (eventually) do non-returning things on both branches, you get a Result<(),()>, which you can then do something like unwrap_or(()) to get the final answer of () that tokio wants.

Something like this:

async fn task() {
    let result = match something {
        Ok(x) => { /* whatever is wanted */ }
        Err(e) => Err(e), // this fails
    };

    let further_result = result.map(infallibly_calculate_new_thing);

    let other_failure : Result<_,_> = calculate_something_fallible(further_result);
    // calculate_something_fallible can use the ? operator, etc

    match further_result {
        Ok(o) => { /* handle success */ }
        e => { /* handle failure */ }
    } // Both paths return ()
}

Typically the solution I see to this is to just return a result from the spawned task. This produces a Result<Result<Ok, Err> JoinError>, but that's ok. If your own error is an io error, you can even use the question mark operator on JoinError because of this impl.

2 Likes

Ah nice, thank you! I'd not adequately grasped how the future was interacting with the return type of the spawned task: the latter was originally returning () so when I put in a return Err line I was getting type errors that I hadn't understood.

Adding an Ok(()) at the end of the task solved that, and then it was straightforward to do a 2-level match on the result of the future:

match task_future.await {
    Ok(result) => match result {
        Ok(()) => ...,
        Err(e) => Err(e)
    },
    Err(e) => /*handle the JoinError */,
}

... which all works well.

Thanks @alice as ever for the always useful advice :slight_smile:

1 Like

Of course, you don't have to use a two-level match!

match task_future.await {
    Ok(Ok(())) => ...,
    Ok(Err(e)) => Err(e)
    Err(e) => /*handle the JoinError */,
}
7 Likes

Ah, marvellous. Thank you so much for the ever-helpful hints!

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.