Simulate exception in async Rust

WARNING: This may be a bad practice. The point of this article is just to share my idea.

Exception is a popular feature in many programming languages. Unlike Rust where errors are considered as a return value of the Err case of Result, in these languages errors are thrown like panic. The caller may catch the errors and process them similar to catch_unwind in Rust. Different people may have different preferences on the error handling style, but panic-unwind is always considered a bad practice and should be avoided if possible. So using exception-like error handling in Rust is impossible.

Well, it is possible with async Rust, but we need a little trick. The main idea here is to stop polling the future after an exception is thrown.

First, we define an exception context, which stores the thrown exception if any.

1
2
3
4
5
6
7
8
9
10
#[derive(Default)]
pub struct ExceptionContext {
exception: std::cell::Cell<Option<anyhow::Error>>, // You can change `anyhow::Error` to your exception type.
}

impl ExceptionContext {
pub fn new() -> ExceptionContext {
std::default::Default::default()
}
}

For the function that may throw the exception, it needs to accept an argument with type &ExceptionContext. If there is already a context-like type, you can consider embedding this struct into it.

Now, we try to implement the throw function. When an exception is thrown, the exception field will be set, and the code after the throw should not be executed. We can do this by core::future::pending().await.

1
2
3
4
5
6
impl ExceptionContext {
pub async fn throw(&self, exception: anyhow::Error) -> ! {
self.exception.set(Some(exception));
core::future::pending().await
}
}

In an async function with ExceptionContext, if you want to throw an exception, just use the throw method then await the return value.

1
2
3
4
5
6
7
8
9
async fn send(ctx: &ExceptionContext, msg: &str, connected: bool) -> usize {
if connected {
msg.len()
} else {
ctx
.throw(anyhow::anyhow!("send error: not connected"))
.await
}
}

Now, how should we execute the function with exception? We need to prepare a future that wraps the async function result. It polls the inner future until either getting Ready or an exception is set in the context.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pin_project_lite::pin_project! { // pin-project-lite = "0.2"
pub struct Catching<'a, F> {
ctx: &'a ExceptionContext,
#[pin]
future: F,
}
}

impl<'a, F: std::future::Future> std::future::Future for Catching<'a, F> {
type Output = anyhow::Result<F::Output>;

fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
let this = self.project();
let p = this.future.poll(cx);
if let Some(exception) = this.ctx.exception.take() {
std::task::Poll::Ready(Err(exception))
} else {
p.map(Ok)
}
}
}

Then we can implement a catching method that accepts an async function that requires an exception context and returns a Catching future.

1
2
3
4
5
6
7
8
9
10
11
impl ExceptionContext {
pub fn catching<'a, Fu: std::future::Future, F: Fn(&'a Self) -> Fu>(
&'a self,
f: F,
) -> Catching<'a, Fu> {
Catching {
ctx: self,
future: f(self),
}
}
}

Now we can execute a block with exceptions like this.

1
2
3
4
5
6
7
8
9
10
11
12
#[tokio::main(flavor = "current_thread")]
async fn main() {
let result = ExceptionContext::new()
.catching(|ctx| async move {
assert_eq!(5, send(ctx, "abcde", true).await);
assert_eq!(3, send(ctx, "abc", true).await);
send(ctx, "", false).await;
panic!("Shouldn't reach here cause an exception is thrown");
})
.await;
assert_eq!(result.unwrap_err().to_string(), "send error: not connected");
}

You can find the complete code here. I also made a crate to provide a general implementation of ExceptionContext and a Sync version.

There are pros and cons of this approach. The biggest pro is that it has no panic-unwind. You can use panic = abort while doing exception-style programming. However, there are the following cons.

  • Only supports async function.
  • The future will be canceled if an exception occurs, which can be problematic.
  • Exception-style is widely considered bad, especially in the Rust community, so even we can do it without panic-unwind it may still be a bad practice.

This is just my idea, whether to use it is up to you.