Handling multiple errors in Rust iterator adapters

17 Dec 2023

Approaches for handling multiple errors within iterator adapters

build/rust.png

Refactoring Iterator Adapters in Rust

15 Jan 2023

A review of ways to refactor iterator adapters in Rust

build/rust.png

Better FastAPI Background Jobs

29 Aug 2022

A more featureful background task runner for Async apps like FastAPI or Discord bots

build/fastapi_logo.png

Useful Linux Examples

21 Dec 2021

A plethora of helpful tips for working in Linux

build/bash_logo.png
Continue to all blog posts

Latest Updated Post

Handling multiple errors in Rust iterator adapters

Recently I’ve spent a decent amount of time exploring different patterns for handling errors in iterator adapters based on some ideas I had while doing some advent of code questions where I was making extensive use of fallible operations.

In the end I’ve discovered two patterns that seem quite simple and helpful

Multiple Errors in a single adapter

The key idea here is to return Ok from the adapter, enabling the use of the ? operator.

Control on the error type returned is defined with the Result type specified in the specialisation of the generic iterator sink. The sink could be for instance collect, however in our example below we’re using sum::<Result<i32>>()? with anyhow::Result implicitly defining the error type as anyhow::Error. This gives us complete flexibility with the errors we can handle.

use anyhow::{Context, Result};
use itertools::Itertools;

let game_id_sum = lines
    .map(|line| {
        let (id, games) = line
            .strip_prefix("Game ")
            .context("has no 'Game ' prefix")?
            .split_once(':')
            .context("has no ':' separator")?;
        Ok((
            id.parse::<i32>().context("game id not valid int")?,
            parse_games_desc(&games)?,
        )) // Returning Ok(_) enables you to use the `?` operator in the closure
    })
    .map_ok(|(id, games)| { // Use itertools::Itertools::map_ok for subsiquent infalable operations 
        let result = (
            id,
            games.iter().fold(Game::default(), |max_seen, x| {
                Game {
                    red: max_seen.red.max(x.red),
                    green: max_seen.green.max(x.green),
                    blue: max_seen.blue.max(x.blue),
                }
            }),
        );
        result
    })
    .map_ok(|(_id, game)| game.red * game.green * game.blue)
    .sum::<Result<i32>>()?; // Error type is defined via the Result here

In a library context we would instead use sum::<Result<_,MyLibError>>()? with MyLibError defined using thiserror handling the conversion from each of the errors that can happen

Multiple errors across different adapters

Building on the handling of errors in one adapter, we might subsequently want to handle additional errors in later adapters.

We want to make sure that we aren’t nesting the Results each time, as this makes the type structures hard to work with. Ideally the “happy path” can continue with relatively simple access within the closures.

To this end, the pattern below making use of and_then within .map(|result| result.and_then(|ok_value| ok_value.fallible_method()) ) like operations enables you to avoid the nesting of results you would get with .map_ok(|ok_value| ok_value.fallible_method()) )

In order to enable this we need to ensure that the error type is the same throughout the chain of adapters. Again anyhow makes this trivial in the application logic setting

use anyhow::{Context, Result};
use itertools::Itertools;

fn application_logic(input: &str) -> Result<u64> {
    let sum = input
        .lines()
        .map(|l| l.parse::<u64>().context("failed top parse input data"))
        .map_ok(|n| n as f32 / 3.0)
        .map(|r| r.and_then(|f| 10u64.checked_div(f as u64).context("input numbers not suitable")))
        .sum::<Result<u64>>()?;
    Ok(sum)
}

Achieving the unified error type in a Library compatible fashion however is a little more complicated. thiserror helps us easily create Error variants for each of the error cases we need to handle. So we just need to find the right way to convert

use itertools::Itertools;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyLibError {
    #[error("failed to parse input data")]
    BadInputError(#[from] std::num::ParseIntError),
    #[error("input numbers not suitable")]
    InvalidDivisor,
}

type Result<T> = std::result::Result<T, MyLibError>;

fn library_logic(input: &str) -> Result<u64> {
    let sum = input
        .lines()
        .map(|l| l.parse::<u64>().map_err(|e| e.into())) // Alternative to implicit type conversion in `.context` which works for `thiserror`
        .map_ok(|n| n as f32 / 3.0)
        .map(|r| r.and_then(|f| 10u64.checked_div(f as u64).ok_or(MyLibError::InvalidDivisor)))
        .sum::<Result<u64>>()?;
    Ok(sum)
}

Future evolution

Some discussion on adding a shorthand for this into itertools have happened, but seem to have been dormant for a while:

It could look something along the lines of

        .map_and_then(|f| 10u64.checked_div(f as u64).ok_or(MyLibError::InvalidDivisor))

Which helps to reduce the visual clutter somewhat