|
| 1 | +import { BlogPostLayout } from '@/components/BlogPostLayout' |
| 2 | +import { MotionCanvas } from '@/components/MotionCanvas' |
| 3 | + |
| 4 | +export const post = { |
| 5 | + draft: false, |
| 6 | + author: 'dig, b5, ramfox', |
| 7 | + date: '2025-08-22', |
| 8 | + title: 'Error handling in iroh', |
| 9 | + description: "Read about iroh's approach to error handling", |
| 10 | +} |
| 11 | + |
| 12 | +export const metadata = { |
| 13 | + title: post.title, |
| 14 | + description: post.description, |
| 15 | + openGraph: { |
| 16 | + title: post.title, |
| 17 | + description: post.description, |
| 18 | + images: [{ |
| 19 | + url: `/api/og?title=Blog&subtitle=${post.title}`, |
| 20 | + width: 1200, |
| 21 | + height: 630, |
| 22 | + alt: post.title, |
| 23 | + type: 'image/png', |
| 24 | + }], |
| 25 | + type: 'article' |
| 26 | + } |
| 27 | +} |
| 28 | + |
| 29 | +export default (props) => <BlogPostLayout article={post} {...props} /> |
| 30 | + |
| 31 | +Error handling in Rust is one of those topics that can spark passionate debates in the community. After wrestling with various approaches in the [iroh](https://iroh.computer/) codebase, the team has developed some insights about the current state of error handling, the tradeoffs involved, and how to get the best of both worlds. |
| 32 | + |
| 33 | +# The Great Error Handling Divide |
| 34 | + |
| 35 | +The Rust ecosystem has largely coalesced around two main approaches to error handling: |
| 36 | + |
| 37 | +**The `anyhow` approach**: One big generic error type that can wrap anything. It's fast to implement, gives you full backtraces, and lets you attach context easily. Perfect for applications where you mainly care about "something went wrong" and want good debugging information. |
| 38 | + |
| 39 | +**The `thiserror` approach**: Carefully crafted enum variants for every possible error case. This gives you precise error types that consumers can match on and handle differently. It's the approach that many library authors (rightfully) prefer because it provides a stable, matchable API. |
| 40 | + |
| 41 | +Both approaches have their merits, but there's an interesting third option that's rarely discussed: the standard library's IO error model. |
| 42 | + |
| 43 | +# Can we have both? |
| 44 | + |
| 45 | +The standard library's approach to IO errors is actually quite elegant. Instead of cramming everything into a single error type or creating hundreds of variants, it splits errors into two components: |
| 46 | + |
| 47 | +- **Error kind**: The broad category of what went wrong (permission denied, not found, etc.) |
| 48 | +- **Error source**: Additional context and the original error chain |
| 49 | + |
| 50 | +This lets you match on the high-level error patterns while still preserving detailed information. You can write code that handles "connection refused" generically while still having access to the underlying TCP error details when needed. |
| 51 | + |
| 52 | +Surprisingly, this pattern hasn't been adopted widely in other Rust libraries. It strikes a nice balance between the two extremes. |
| 53 | + |
| 54 | +# The Backtrace Problem |
| 55 | + |
| 56 | +Here's where things get frustrating: If you want proper error handling with backtraces, you're in for a world of pain due to fundamental limitations in Rust's error handling story. |
| 57 | + |
| 58 | +The core issue is that **Rust still hasn't stabilized backtrace propagation on errors**. For more context, take a look at [this comment](https://github.com/rust-lang/rust/issues/99301#issuecomment-2937061356), as well as the rest of the thread. |
| 59 | + |
| 60 | +This creates a cascade of problems: |
| 61 | + |
| 62 | +- `anyhow` can provide full backtraces because all errors are `anyhow` errors, and it has an extension trait that can propagate traces through the chain |
| 63 | +- `thiserror` cannot reliably provide backtraces when errors are nested, because each error type would need to know about the backtrace inside its wrapped errors |
| 64 | + |
| 65 | +The technical limitation comes down to Rust's trait system. When you implement `Into<YourError>` for the `?` operator to work nicely, you need a blanket implementation for all error types. But this conflicts with backtrace handling because you can only access backtraces on concrete types, not through the `Error` trait. |
| 66 | + |
| 67 | +This means you get to choose: either nice ergonomics with `?` or backtraces. You can't have both without significant workarounds. |
| 68 | + |
| 69 | +To be clear, we are not criticizing the rust maintainers; this is difficult work. But it does mean that crate authors have to make tough choices when it comes to error handling. |
| 70 | + |
| 71 | +# Enter Snafu: The Hybrid Approach |
| 72 | + |
| 73 | +After considerable experimentation and, admittedly, some screaming at the compiler, we found a solution that works for our needs: [snafu](https://github.com/shepmaster/snafu). |
| 74 | + |
| 75 | +Snafu is essentially `thiserror` on steroids. It provides: |
| 76 | + |
| 77 | +- Enum-based error types with derive macros (like `thiserror`) |
| 78 | +- Rich context attachment and error chaining |
| 79 | +- Automatic backtrace capture when constructing error variants |
| 80 | +- Extension traits that work around Rust's limitations |
| 81 | + |
| 82 | +The key breakthrough is figuring out how to wrap snafu errors *within* other snafu and non-snafu, while preserving the full backtrace chain. This required some careful incantations to work around the `Into` trait conflicts, but the result is that developers can now have an IO error nested three levels deep and still get a complete backtrace. |
| 83 | + |
| 84 | +When using `snafu` (in conjunction with our `n0-snafu` crate—more on this below), our test failures now look like this (with `RUST_BACKTRACE=1` ): |
| 85 | + |
| 86 | +```rust |
| 87 | +Error: |
| 88 | + 0: The relay denied our authentication (not authorized) |
| 89 | + |
| 90 | +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BACKTRACE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| 91 | + ⋮ 9 frames hidden ⋮ |
| 92 | +10: iroh_relay::server::tests::test_relay_access_control::{{closure}}::hd7e62eebdecb5f10 |
| 93 | + at /iroh/iroh-relay/src/server.rs:987 |
| 94 | + ⋮ 21 frames hidden ⋮ |
| 95 | +32: iroh_relay::server::tests::test_relay_access_control::hf276e536250e2f5f |
| 96 | + at /iroh/iroh-relay/src/server.rs:1016 |
| 97 | +33: iroh_relay::server::tests::test_relay_access_control::{{closure}}::h72fb6babf688bbfd |
| 98 | + at /iroh/iroh-relay/src/server.rs:948 |
| 99 | + ⋮ 23 frames hidden ⋮ |
| 100 | + |
| 101 | +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BACKTRACE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| 102 | + ⋮ 8 frames hidden ⋮ |
| 103 | + 9: <iroh_relay::client::ConnectError as core::convert::From<iroh_relay::protos::handshake::Error>>::from::h862bd832592732c4 |
| 104 | + at /iroh/iroh-relay/src/client.rs:56 |
| 105 | + ⋮ 1 frame hidden ⋮ |
| 106 | +11: iroh_relay::client::ClientBuilder::connect::{{closure}}::h5a1014df84d149d0 |
| 107 | + at /iroh/iroh-relay/src/client.rs:281 |
| 108 | +12: iroh_relay::server::tests::test_relay_access_control::{{closure}}::hd7e62eebdecb5f10 |
| 109 | + at /iroh/iroh-relay/src/server.rs:986 |
| 110 | + ⋮ 21 frames hidden ⋮ |
| 111 | +34: iroh_relay::server::tests::test_relay_access_control::hf276e536250e2f5f |
| 112 | + at /iroh/iroh-relay/src/server.rs:1016 |
| 113 | +35: iroh_relay::server::tests::test_relay_access_control::{{closure}}::h72fb6babf688bbfd |
| 114 | + at /iroh/iroh-relay/src/server.rs:948 |
| 115 | + ⋮ 23 frames hidden ⋮ |
| 116 | +``` |
| 117 | + |
| 118 | +A lot of credit deserves to go to [eyre](https://docs.rs/eyre/latest/eyre/), after which our error formatting is based! |
| 119 | + |
| 120 | +# Our push for concrete errors with backtraces |
| 121 | + |
| 122 | +As part of our [push to 1.0](https://iroh.computer/roadmap), we’re transitioning to structured errors using `snafu`. We have started this conversion in iroh `v0.90`. |
| 123 | + |
| 124 | +We’ve learned a lot so far, and there is further to go. We have some established patterns, but still need to ensure that all of our APIs follow those patterns, as well as ensure that any logging or error reporting formats the information in a way that’s easy to understand. Or, at least, as easy to understand as possible. |
| 125 | + |
| 126 | +We also very much missed how ergonomic `anyhow` is to work with, especially when writing tests. We now have a `n0-snafu` crate that provides utilities for working with `snafu`, that help claw back some of this ease-of-use especially when writing tests or examples. |
| 127 | + |
| 128 | +## Concrete-error writing guidelines |
| 129 | + |
| 130 | +Here are some guidelines we’ve used while writing concrete-errors. |
| 131 | + |
| 132 | +### Error enums are scoped to ***functions*** not ***modules*** |
| 133 | + |
| 134 | +During the initial refactor of our errors to use concrete types, we leaned toward the module-level error approach. It did make the conversion more simple at first and was a good stepping stone: we didn’t have to worry as much about enum hierarchy, for example, and instead shoved everything into one enum. |
| 135 | + |
| 136 | +For complex parts of our code, however, this soon became unwieldily. |
| 137 | + |
| 138 | +This was especially apparent in what eventually became the `iroh-relay::client::ConnectError` enum. SO many things can go wrong during a connection to relay server, even before you attempt to dial the relay server! |
| 139 | + |
| 140 | +We quickly realized that we needed some additional hierarchy: everything that can go wrong *before* dialing, and the errors that occur *while* dialing. Hence, we have the `DialError` enum nested inside the `ConnectError` enum. |
| 141 | + |
| 142 | +### Lean toward error enum names that are descriptive of the error, when logical |
| 143 | + |
| 144 | +One positive side effect of naming enums based around its function and purpose, rather than just having one giant enum for the whole module, was how the name of the enums allowed you to understand much more quickly the kinds of things that could go wrong in a function or method. |
| 145 | + |
| 146 | +A good example of this is our `ticket::ParseError` enum. Previously, this was a `ticket::Error`. We decided `ParseError` was a more descriptive and logical name: the only kind of errors you can get when working with the ticket are issues that can occur when parsing the ticket: maybe it’s the wrong “kind” of ticket, maybe there are issues when serializing or deserializing, or verifying the ticket. Calling it a `ParseError` means that any user who looks at the API can understand the scope of things that can go wrong when using a ticket, before reading any documentation. |
| 147 | + |
| 148 | +This came up mostly when looking at functions and methods that had simple or lower-level functionality. For example, the `connect` function mentioned above had so many possible categories of errors that calling the enum `ConnectError` was actually the most descriptive and accurate name we could give it. |
| 149 | + |
| 150 | +### Errors for public traits should contain a `Custom` variant, with helpful APIs for creating that variant |
| 151 | + |
| 152 | +It doesn’t necessarily need to be called `Custom`, but for traits that folks working with `iroh` can implement themselves, we needed to ensure that they could use the errors associated with that trait for their own purposes. |
| 153 | + |
| 154 | +A great example of this is our `Discovery` trait, that has a `DiscoveryError`: |
| 155 | + |
| 156 | +```rust |
| 157 | +/// Discovery errors |
| 158 | +#[common_fields({ |
| 159 | + backtrace: Option<snafu::Backtrace>, |
| 160 | + #[snafu(implicit)] |
| 161 | + span_trace: n0_snafu::SpanTrace, |
| 162 | +})] |
| 163 | +#[allow(missing_docs)] |
| 164 | +#[derive(Debug, Snafu)] |
| 165 | +#[non_exhaustive] |
| 166 | +pub enum DiscoveryError { |
| 167 | + #[snafu(display("No discovery service configured"))] |
| 168 | + NoServiceConfigured {}, |
| 169 | + #[snafu(display("Discovery produced no results for {}", node_id.fmt_short()))] |
| 170 | + NoResults { node_id: NodeId }, |
| 171 | + #[snafu(display("Service '{provenance}' error"))] |
| 172 | + User { |
| 173 | + provenance: &'static str, |
| 174 | + source: Box<dyn std::error::Error + Send + Sync + 'static>, |
| 175 | + }, |
| 176 | +} |
| 177 | + |
| 178 | +impl DiscoveryError { |
| 179 | + /// Creates a new user error from an arbitrary error type. |
| 180 | + pub fn from_err<T: std::error::Error + Send + Sync + 'static>( |
| 181 | + provenance: &'static str, |
| 182 | + source: T, |
| 183 | + ) -> Self { |
| 184 | + UserSnafu { provenance }.into_error(Box::new(source)) |
| 185 | + } |
| 186 | + |
| 187 | + /// Creates a new user error from an arbitrary boxed error type. |
| 188 | + pub fn from_err_box( |
| 189 | + provenance: &'static str, |
| 190 | + source: Box<dyn std::error::Error + Send + Sync + 'static>, |
| 191 | + ) -> Self { |
| 192 | + UserSnafu { provenance }.into_error(source) |
| 193 | + } |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +We have some specific errors, `NoServiceConfigured` and `NoResults` that we use in our own discovery implementations, but we also have a `User` error that allows someone who is implementing their own discovery trait to propagate whatever appropriate errors they need. |
| 198 | + |
| 199 | +We also provide `DiscoveryError::from_err` and `DiscoveryError::from_error_box` to easily allow users to create whatever `DiscoveryError`s they need. |
| 200 | + |
| 201 | +## The Tradeoffs Are Real |
| 202 | + |
| 203 | +Let's be honest about the costs: |
| 204 | + |
| 205 | +**Structured errors require more work upfront**. You need to think about error variants, write more boilerplate, and make decisions about error hierarchies. |
| 206 | + |
| 207 | +**Generic errors are faster to implement**. When you just need to get something working, `anyhow` is hard to beat for velocity. |
| 208 | + |
| 209 | +**Library vs. application needs differ**. Libraries benefit more from structured errors because they need stable APIs. Applications often care more about debugging information than precise error matching. |
| 210 | + |
| 211 | +**The tooling isn't perfect**. Rust's error handling story has fundamental limitations that require workarounds and compromise. |
| 212 | + |
| 213 | +### `n0-snafu` |
| 214 | + |
| 215 | +One of the biggest sources of frustration we faced during the conversion to concrete-errors with backtraces, was that we missed the ergonomics of `anyhow` when writing tests and examples. `snafu` does have their own version of `anyhow::anyhow!` called `snafu::whatever!`, but we ran into friction during tests and examples when we wanted to return any combination of `whatever` errors, `anyhow` errors, concrete errors we created in `iroh`, and concrete errors from other libraries we are using. |
| 216 | + |
| 217 | +For that, we wrote `n0-snafu` , a utility crate that allows for working with `snafu` (and other types of errors) with ease. It’s not *quite* as ergonomic as if you were just using `anyhow` throughout your entire application, but again, we’ve already established that part of the game here is trade-offs. |
| 218 | + |
| 219 | +The benefits of using `n0-snafu` in combination with `snafu` were the most apparent in tests, by using `n0-snafu::Result` and the `n0-snafu::ResultExt` , we could gain back some of the ease-of-use that we had when relying on `anyhow`. Here is a parsed-down example of an actual test in iroh: |
| 220 | + |
| 221 | +```rust |
| 222 | +#[cfg(test)] |
| 223 | +mod tests { |
| 224 | + // allows us to use the `.e()` and `.with_context()` methods: |
| 225 | + use n0_snafu::ResultExt; |
| 226 | + ... |
| 227 | + |
| 228 | + #[tokio::test] |
| 229 | + async fn endpoint_connect_close() -> n0_snafu::Result { |
| 230 | + ... |
| 231 | + let ep = Endpoint::builder() |
| 232 | + .secret_key(server_secret_key) |
| 233 | + .alpns(vec![TEST_ALPN.to_vec()]) |
| 234 | + .relay_mode(RelayMode::Custom(relay_map.clone())) |
| 235 | + .insecure_skip_relay_cert_verify(true) |
| 236 | + .bind() |
| 237 | + // returns an `iroh::BindError`, so it |
| 238 | + // can be implicitly returned without explicit conversion: |
| 239 | + .await?; |
| 240 | + |
| 241 | + let server = tokio::spawn( |
| 242 | + async move { |
| 243 | + info!("accepting connection"); |
| 244 | + // returns an `Option`, it needs to be converted to |
| 245 | + // a `Result` using `.e()`: |
| 246 | + let incoming = ep.accept().await.e()?; |
| 247 | + |
| 248 | + // returns a `quinn::ConnectionError` |
| 249 | + // needs to be converted into a `n0_snafu::Error` using the `.e()` method |
| 250 | + // in order to use the `?`: |
| 251 | + let conn = incoming.await.e()?; |
| 252 | + // same as above: |
| 253 | + let mut stream = conn.accept_uni().await.e()?; |
| 254 | + let mut buf = [0u8; 5]; |
| 255 | + // `.with_context` allows you to add context to the error when |
| 256 | + // converting to a `n0_snafu::Error`: |
| 257 | + stream.read_exact(&mut buf).await.with_context(|| format!("could not read from the stream")?; |
| 258 | + ... |
| 259 | + // check out `iroh/src/endpoint.rs for the full test |
| 260 | + } |
| 261 | +} |
| 262 | +``` |
| 263 | + |
| 264 | +## Looking Forward |
| 265 | + |
| 266 | +There is a lot of pressure from the Rust community to ensure that all libraries return concrete errors—this is not misguided—structured errors do provide real benefits for library APIs and error handling. **But the pragmatic reality is that different projects have different needs.** |
| 267 | + |
| 268 | +For the iroh project, the hybrid approach is working well: |
| 269 | + |
| 270 | +- Use structured errors for public APIs where consumers need to handle different cases |
| 271 | +- Preserve rich context and backtraces for debugging |
| 272 | + |
| 273 | +The error handling landscape in Rust is still evolving. Until backtrace propagation is stabilized and the ergonomics improve, teams are making tradeoffs. The key is being intentional about those tradeoffs rather than letting dogma drive technical decisions. |
| 274 | + |
| 275 | +- Accept that some boilerplate is the cost of precise error handling |
| 276 | + |
| 277 | +What matters most is choosing an approach that serves the project's needs—whether that's the simplicity of `anyhow`, the precision of `thiserror`, or something in between. The perfect error handling system doesn't exist, but good-enough error handling that ships is infinitely better than perfect error handling that never gets implemented. |
0 commit comments