Throw error rust

Как и многие языки программирования, Rust призывает разработчика определенным способом обрабатывать ошибки. Вообще, существует два общих подхода обработки ошибок...

Как и многие языки программирования, Rust призывает разработчика определенным способом обрабатывать ошибки. Вообще, существует два общих подхода обработки ошибок: с помощью исключений и через возвращаемые значения. И Rust предпочитает возвращаемые значения.

В этой статье мы намерены подробно изложить работу с ошибками в Rust. Более того, мы попробуем раз за разом погружаться в обработку ошибок с различных сторон, так что под конец у вас будет уверенное практическое представление о том, как все это сходится воедино.

В наивной реализации обработка ошибок в Rust может выглядеть многословной и раздражающей. Мы рассмотрим основные камни преткновения, а также продемонстрируем, как сделать обработку ошибок лаконичной и удобной, пользуясь стандартной библиотекой.

Содержание

Эта статья очень длинная, в основном потому, что мы начнем с самого начала — рассмотрения типов-сумм (sum type) и комбинаторов, и далее попытаемся последовательно объяснить подход Rust к обработке ошибок. Так что разработчики, которые имеют опыт работы с другими выразительными системами типов, могут свободно перескакивать от раздела к разделу.

  • Основы
    • Объяснение unwrap
    • Тип Option
      • Совмещение значений Option<T>
    • Тип Result
      • Преобразование строки в число
      • Создание псевдонима типа Result
    • Короткое отступление: unwrap — не обязательно зло
  • Работа с несколькими типами ошибок
    • Совмещение Option и Result
    • Ограничения комбинаторов
    • Преждевременный return
    • Макрос try!
    • Объявление собственного типа ошибки
  • Типажи из стандартной библиотеки, используемые для обработки ошибок
    • Типаж Error
    • Типаж From
    • Настоящий макрос try!
    • Совмещение собственных типов ошибок
    • Рекомендации для авторов библиотек
  • Заключение

Основы

Обработку ошибок можно рассматривать как вариативный анализ того, было ли некоторое вычисление выполнено успешно или нет. Как будет показано далее, ключом к удобству обработки ошибок является сокращение количества явного вариативного анализа, который должен выполнять разработчик, сохраняя при этом код легко сочетаемым с другим кодом (composability).

(Примечание переводчика: Вариативный анализ – это один из наиболее общеприменимых методов аналитического мышления, который заключается в рассмотрении проблемы, вопроса или некоторой ситуации с точки зрения каждого возможного конкретного случая. При этом рассмотрение по отдельности каждого такого случая является достаточным для того, чтобы решить первоначальный вопрос.

Важным аспектом такого подхода к решению проблем является то, что такой анализ должен быть исчерпывающим (exhaustive). Другими словами, при использовании вариативного анализа должны быть рассмотрены все возможные случаи.

В Rust вариативный анализ реализуется с помощью синтаксической конструкции match. При этом компилятор гарантирует, что такой анализ будет исчерпывающим: если разработчик не рассмотрит все возможные варианты заданного значения, программа не будет скомпилирована.)

Сохранять сочетаемость кода важно, потому что без этого требования мы могли бы просто получать panic всякий раз, когда мы сталкивались бы с чем-то неожиданным. (panic вызывает прерывание текущего потока и, в большинстве случаев, приводит к завершению всей программы.) Вот пример:

// Попробуйте угадать число от 1 до 10.
// Если заданное число соответствует тому, что мы загадали, возвращается true.
// В противном случае возвращается false.
fn guess(n: i32) -> bool {
    if n < 1 || n > 10 {
        panic!("Неверное число: {}", n);
    }
    n == 5
}

fn main() {
    guess(11);
}

Если попробовать запустить этот код, то программа аварийно завершится с сообщением вроде этого:

thread '<main>' panicked at 'Неверное число: 11', src/bin/panic-simple.rs:6

Вот другой, менее надуманный пример. Программа, которая принимает число в качестве аргумента, удваивает его значение и печатает на экране.

use std::env;

fn main() {
    let mut argv = env::args();
    let arg: String = argv.nth(1).unwrap(); // ошибка 1
    let n: i32 = arg.parse().unwrap();      // ошибка 2
    println!("{}", 2 * n);
}

Если вы запустите эту программу без параметров (ошибка 1) или если первый параметр будет не целым числом (ошибка 2), программа завершится паникой, так же, как и в первом примере.

Обработка ошибок в подобном стиле подобна слону в посудной лавке. Слон будет нестись в направлении, в котором ему вздумается, и крушить все на своем пути.

Объяснение unwrap

В предыдущем примере мы утверждали, что программа будет просто паниковать, если будет выполнено одно из двух условий для возникновения ошибки, хотя, в отличии от первого примера, в коде программы нет явного вызова panic. Тем не менее, вызов panic встроен в вызов unwrap.

Вызывать unwrap в Rust подобно тому, что сказать: «Верни мне результат вычислений, а если произошла ошибка, просто паникуй и останавливай программу». Мы могли бы просто показать исходный код функции unwrap, ведь это довольно просто, но перед этим мы должны разобратся с типами Option и Result. Оба этих типа имеют определенный для них метод unwrap.

Тип Option

Тип Option объявлен в стандартной библиотеке:

enum Option<T> {
    None,
    Some(T),
}

Тип Option — это способ выразить возможность отсутствия чего бы то ни было, используя систему типов Rust. Выражение возможности отсутствия через систему типов является важной концепцией, поскольку такой подход позволяет компилятору требовать от разработчика обрабатывать такое отсутствие. Давайте взглянем на пример, который пытается найти символ в строке:

// Поиск Unicode-символа `needle` в `haystack`. Когда первый символ найден,
// возвращается побайтовое смещение для этого символа. Иначе возвращается `None`.
fn find(haystack: &str, needle: char) -> Option<usize> {
    for (offset, c) in haystack.char_indices() {
        if c == needle {
            return Some(offset);
        }
    }
    None
}

Обратите внимание, что когда эта функция находит соответствующий символ, она возвращает не просто offset. Вместо этого она возвращает Some(offset). Some — это вариант или конструктор значения для типа Option. Его можно интерпретировать как функцию типа fn<T>(value: T) -> Option<T>. Соответственно, None — это также конструктор значения, только у него нет параметров. Его можно интерпретировать как функцию типа fn<T>() -> Option<T>.

Может показаться, что мы подняли много шума из ничего, но это только половина истории. Вторая половина — это использование функции find, которую мы написали. Давайте попробуем использовать ее, чтобы найти расширение в имени файла.

fn main() {
    let file_name = "foobar.rs";
    match find(file_name, '.') {
        None => println!("Расширение файла не найдено."),
        Some(i) => println!("Расширение файла: {}", &file_name[i+1..]),
    }
}

Этот код использует сопоставление с образцом чтобы выполнить вариативный анализ для возвращаемого функцией find значения Option<usize>. На самом деле, вариативный анализ является единственным способом добраться до значения, сохраненного внутри Option<T>. Это означает, что вы, как разработчик, обязаны обработать случай, когда значение Option<T> равно None, а не Some(t).

Но подождите, как насчет unwrap, который мы до этого использовали? Там не было никакого вариативного анализа! Вместо этого, вариативный анализ был перемещен внутрь метода unwrap. Вы можете сделать это самостоятельно, если захотите:

enum Option<T> {
    None,
    Some(T),
}

impl<T> Option<T> {
    fn unwrap(self) -> T {
        match self {
            Option::Some(val) => val,
            Option::None =>
              panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

Метод unwrap абстрагирует вариативный анализ. Это именно то, что делает unwrap удобным в использовании. К сожалению, panic! означает, что unwrap неудобно сочетать с другим кодом: это слон в посудной лавке.

Совмещение значений Option<T>

В предыдущем примере мы рассмотрели, как можно воспользоватся find для того, чтобы получить расширение имени файла. Конечно, не во всех именах файлов можно найти ., так что существует вероятность, что имя некоторого файла не имеет расширения. Эта возможность отсутствия интерпретируется на уровне типов через использование Option<T>. Другими словами, компилятор заставит нас рассмотреть возможность того, что расширение не существует. В нашем случае мы просто печатаем сообщение об этом.

Получение расширения имени файла — довольно распространенная операция, так что имеет смысл вынести код в отдельную функцию:

// Возвращает расширение заданного имени файла, а именно все символы,
// идущие за первым вхождением `.` в имя файла.
// Если в `file_name` нет ни одного вхождения `.`, возвращается `None`.
fn extension_explicit(file_name: &str) -> Option<&str> {
    match find(file_name, '.') {
        None => None,
        Some(i) => Some(&file_name[i+1..]),
    }
}

(Подсказка: не используйте этот код. Вместо этого используйте метод extension из стандартной библиотеки.)

Код выглядит простым, но его важный аспект заключается в том, что функция find заставляет нас рассмотреть вероятность отсутствия значения. Это хорошо, поскольку это означает, что компилятор не позволит нам случайно забыть о том варианте, когда в имени файла отсутствует расширение. С другой стороны, каждый раз выполнять явный вариативный анализ, подобно тому, как мы делали это в extension_explicit, может стать немного утомительным.

На самом деле, вариативный анализ в extension_explicit является очень распространенным паттерном: если Option<T> владеет определенным значением T, то выполнить его преобразование с помощью функции, а если нет — то просто вернуть None.

Rust поддерживает параметрический полиморфизм, так что можно очень легко объявить комбинатор, который абстрагирует это поведение:

fn map<F, T, A>(option: Option<T>, f: F) -> Option<A> where F: FnOnce(T) -> A {
    match option {
        None => None,
        Some(value) => Some(f(value)),
    }
}

В действительности, map определен в стандартной библиотеке как метод Option<T>.

Вооружившись нашим новым комбинатором, мы можем переписать наш метод extension_explicit так, чтобы избавиться от вариативного анализа:

// Возвращает расширение заданного имени файла, а именно все символы,
// идущие за первым вхождением `.` в имя файла.
// Если в `file_name` нет ни одного вхождения `.`, возвращается `None`.
fn extension(file_name: &str) -> Option<&str> {
    find(file_name, '.').map(|i| &file_name[i+1..])
}

Есть еще одно поведение, которое можно часто встретить — это использование значения по-умолчанию в случае, когда значение Option равно None. К примеру, ваша программа может считать, что расширение файла равно rs в случае, если на самом деле оно отсутствует.

Легко представить, что этот случай вариативного анализа не специфичен только для расширений файлов — такой подход может работать с любым Option<T>:

fn unwrap_or<T>(option: Option<T>, default: T) -> T {
    match option {
        None => default,
        Some(value) => value,
    }
}

Хитрость только в том, что значение по-умолчанию должно иметь тот же тип, что и значение, которое может находится внутри Option<T>. Использование этого метода элементарно:

fn main() {
    assert_eq!(extension("foobar.csv").unwrap_or("rs"), "csv");
    assert_eq!(extension("foobar").unwrap_or("rs"), "rs");
}

(Обратите внимание, что unwrap_or объявлен как метод Option<T> в стандартной библиотеке, так что мы воспользовались им вместо функции, которую мы объявили ранее. Не забудьте также изучить более общий метод unwrap_or_else).

Существует еще один комбинатор, на который, как мы думаем, стоит обратить особое внимание: and_then. Он позволяет легко сочетать различные вычисления, которые допускают возможность отсутствия. Пример — большая часть кода в этом разделе, который связан с определением расширения заданного имени файла. Чтобы делать это, нам для начала необходимо узнать имя файла, которое как правило извлекается из файлового пути. Хотя большинство файловых путей содержат имя файла, подобное нельзя сказать обо всех файловых путях. Примером могут послужить пути ., .. или /.

Таким образом, мы определили задачу нахождения расширения заданного файлового пути. Начнем с явного вариативного анализа:

fn file_path_ext_explicit(file_path: &str) -> Option<&str> {
    match file_name(file_path) {
        None => None,
        Some(name) => match extension(name) {
            None => None,
            Some(ext) => Some(ext),
        }
    }
}

fn file_name(file_path: &str) -> Option<&str> {
  unimplemented!() // опустим реализацию
}

Можно подумать, мы могли бы просто использовать комбинатор map, чтобы уменьшить вариативный анализ, но его тип не совсем подходит. Дело в том, что map принимает функцию, которая делает что-то только с внутренним значением. Результат такой функции всегда оборачивается в Some. Вместо этого, нам нужен метод, похожий map, но который позволяет вызывающему передать еще один Option. Его общая реализация даже проще, чем map:

fn and_then<F, T, A>(option: Option<T>, f: F) -> Option<A>
        where F: FnOnce(T) -> Option<A> {
    match option {
        None => None,
        Some(value) => f(value),
    }
}

Теперь мы можем переписать нашу функцию file_path_ext без явного вариативного анализа:

fn file_path_ext(file_path: &str) -> Option<&str> {
    file_name(file_path).and_then(extension)
}

Тип Option имеет много других комбинаторов определенных в стандартной библиотеке. Очень полезно просмотреть этот список и ознакомиться с доступными методами — они не раз помогут вам сократить количество вариативного анализа. Ознакомление с этими комбинаторами окупится еще и потому, что многие из них определены с аналогичной семантикой и для типа Result, о котором мы поговорим далее.

Комбинаторы делают использование типов вроде Option более удобным, ведь они сокращают явный вариативный анализ. Они также соответствуют требованиям сочетаемости, поскольку они позволяют вызывающему обрабатывать возможность отсутствия результата собственным способом. Такие методы, как unwrap, лишают этой возможности, ведь они будут паниковать в случае, когда Option<T> равен None.

Тип Result

Тип Result также определен в стандартной библиотеке:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Тип Result — это продвинутая версия Option. Вместо того, чтобы выражать возможность отсутствия, как это делает Option, Result выражает возможность ошибки. Как правило, ошибки необходимы для объяснения того, почему результат определенного вычисления не был получен. Строго говоря, это более общая форма Option. Рассмотрим следующий псевдоним типа, который во всех смыслах семантически эквивалентен реальному Option<T>:

type Option<T> = Result<T, ()>;

Здесь второй параметр типа Result фиксируется и определяется через () (произносится как «unit» или «пустой кортеж»). Тип () имеет ровно одно значение — (). (Да, это тип и значение этого типа, которые выглядят одинаково!)

Тип Result — это способ выразить один из двух возможных исходов вычисления. По соглашению, один исход означает ожидаемый результат или «Ok«, в то время как другой исход означает исключительную ситуацию или «Err«.

Подобно Option, тип Result имеет метод unwrap, определенный в стандартной библиотеке. Давайте объявим его самостоятельно:

impl<T, E: ::std::fmt::Debug> Result<T, E> {
    fn unwrap(self) -> T {
        match self {
            Result::Ok(val) => val,
            Result::Err(err) =>
              panic!("called `Result::unwrap()` on an `Err` value: {:?}", err),
        }
    }
}

Это фактически то же самое, что и определение Option::unwrap, за исключением того, что мы добавили значение ошибки в сообщение panic!. Это делает отладку проще, но это вынуждает нас требовать от типа-параметра E (который представляет наш тип ошибки) реализации Debug. Поскольку подавляющее большинство типов должны реализовывать Debug, обычно на практике такое ограничение не мешает. (Реализация Debug для некоторого типа просто означает, что существует разумный способ печати удобочитаемого описания значения этого типа.)

Окей, давайте перейдем к примеру.

Преобразование строки в число

Стандартная библиотека Rust позволяет элементарно преобразовывать строки в целые числа. На самом деле это настолько просто, что возникает соблазн написать что-то вроде:

fn double_number(number_str: &str) -> i32 {
    2 * number_str.parse::<i32>().unwrap()
}

fn main() {
    let n: i32 = double_number("10");
    assert_eq!(n, 20);
}

Здесь вы должны быть скептически настроены по-поводу вызова unwrap. Если строку нельзя распарсить как число, вы получите панику:

thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', /home/rustbuild/src/rust-buildbot/slave/beta-dist-rustc-linux/build/src/libcore/result.rs:729

Это довольно неприятно, и если бы подобное произошло в используемой вами библиотеке, вы могли бы небезосновательно разгневаться. Так что нам стоит попытаться обработать ошибку в нашей функции, и пусть вызывающий сам решит что с этим делать. Это означает необходимость изменения типа, который возвращается double_number. Но на какой? Чтобы понять это, необходимо посмотреть на сигнатуру метода parse из стандартной библиотеки:

impl str {
    fn parse<F: FromStr>(&self) -> Result<F, F::Err>;
}

Хмм. По крайней мере мы знаем, что должны использовать Result. Вполне возможно, что метод мог возвращать Option. В конце концов, строка либо парсится как число, либо нет, не так ли? Это, конечно, разумный путь, но внутренняя реализация знает почему строка не распарсилась как целое число. (Это может быть пустая строка, или неправильные цифры, слишком большая или слишком маленькая длина и т.д.) Таким образом, использование Result имеет смысл, ведь мы хотим предоставить больше информации, чем просто «отсутствие». Мы хотим сказать, почему преобразование не удалось. Вам стоит рассуждать похожим образом, когда вы сталкиваетесь с выбором между Option и Result. Если вы можете предоставить подробную информацию об ошибке, то вам, вероятно, следует это сделать. (Позже мы поговорим об этом подробнее.)

Хорошо, но как мы запишем наш тип возвращаемого значения? Метод parse является обобщенным (generic) для всех различных типов чисел из стандартной библиотеки. Мы могли бы (и, вероятно, должны) также сделать нашу функцию обобщенной, но давайте пока остановимся на конкретной реализации. Нас интересует только тип i32, так что нам стоит найти его реализацию FromStr (выполните поиск в вашем браузере по строке «FromStr») и посмотреть на его ассоциированный тип Err. Мы делаем это чтобы определить конкретный тип ошибки. В данном случае, это std::num::ParseIntError. Наконец, мы можем переписать нашу функцию:

use std::num::ParseIntError;

fn double_number(number_str: &str) -> Result<i32, ParseIntError> {
    match number_str.parse::<i32>() {
        Ok(n) => Ok(2 * n),
        Err(err) => Err(err),
    }
}

fn main() {
    match double_number("10") {
        Ok(n) => assert_eq!(n, 20),
        Err(err) => println!("Error: {:?}", err),
    }
}

Неплохо, но нам пришлось написать гораздо больше кода! И нас опять раздражает вариативный анализ.

Комбинаторы спешат на помощь! Подобно Option, Result имеет много комбинаторов, определенных в качестве методов. Существует большой список комбинаторов, общих между Result и Option. И map входит в этот список:

use std::num::ParseIntError;

fn double_number(number_str: &str) -> Result<i32, ParseIntError> {
    number_str.parse::<i32>().map(|n| 2 * n)
}

fn main() {
    match double_number("10") {
        Ok(n) => assert_eq!(n, 20),
        Err(err) => println!("Error: {:?}", err),
    }
}

Все ожидаемые методы реализованы для Result, включая unwrap_or и and_then. Кроме того, поскольку Result имеет второй параметр типа, существуют комбинаторы, которые влияют только на значение ошибки, такие как map_err (аналог map) и or_else (аналог and_then).

Создание псевдонима типа Result

В стандартной библиотеке можно часто увидеть типы вроде Result<i32>. Но постойте, ведь мы определили Result с двумя параметрами типа. Как мы можем обойти это, указывая только один из них? Ответ заключается в определении псевдонима типа Result, который фиксирует один из параметров конкретным типом. Обычно фиксируется тип ошибки. Например, наш предыдущий пример с преобразованием строк в числа можно переписать так:

use std::num::ParseIntError;
use std::result;

type Result<T> = result::Result<T, ParseIntError>;

fn double_number(number_str: &str) -> Result<i32> {
    unimplemented!();
}

Зачем мы это делаем? Что ж, если у нас есть много функций, которые могут вернуть ParseIntError, то гораздо удобнее определить псевдоним, который всегда использует ParseIntError, так что мы не будем повторяться все время.

Самый заметный случай использования такого подхода в стандартной библиотеке — псевдоним io::Result. Как правило, достаточно писать io::Result<T>, чтобы было понятно, что вы используете псевдоним типа из модуля io, а не обычное определение из std::result. (Этот подход также используется для fmt::Result)

Короткое отступление: unwrap — не обязательно зло

Если вы были внимательны, то возможно заметили, что я занял довольно жесткую позицию по отношению к методам вроде unwrap, которые могут вызвать panic и прервать исполнение вашей программы. В основном, это хороший совет.

Тем не менее, unwrap все-таки можно использовать разумно. Факторы, которые оправдывают использование unwrap, являются несколько туманными, и разумные люди могут со мной не согласиться. Я кратко изложу свое мнение по этому вопросу:

  • Примеры и «грязный» код. Когда вы пишете просто пример или быстрый скрипт, обработка ошибок просто не требуется. Для подобных случаев трудно найти что-либо удобнее чем unwrap, так что здесь его использование очень привлекательно.
  • Паника указывает на ошибку в программе. Если логика вашего кода должна предотвращать определенное поведение (скажем, получение элемента из пустого стека), то использование panic также допустимо. Дело в том, что в этом случае паника будет сообщать о баге в вашей программе. Это может происходить явно, например от неудачного вызова assert!, или происходить потому, что индекс по массиву находится за пределами выделенной памяти.

Вероятно, это не исчерпывающий список. Кроме того, при использовании Option зачастую лучше использовать метод expect. Этот метод делает ровно то же, что и unwrap, за исключением того, что в случае паники напечатает ваше сообщение. Это позволит лучше понять причину ошибки, ведь будет показано конкретное сообщение, а не просто «called unwrap on a None value».

Мой совет сводится к следующему: используйте здравый смысл. Есть причины, по которым слова вроде «никогда не делать X» или «Y считается вредным» не появятся в этой статье. У любых решений существуют компромиссы, и это ваша задача, как разработчика, определить, что именно является приемлемым для вашего случая. Моя цель состоит только в том, чтобы помочь вам оценить компромиссы как можно точнее.

Теперь, когда мы рассмотрели основы обработки ошибок в Rust и разобрались с unwrap, давайте подробнее изучим стандартную библиотеку.

Работа с несколькими типами ошибок

До этого момента мы расматривали обработку ошибок только для случаев, когда все сводилось либо только к Option<T>, либо только к Result<T, SomeError>. Но что делать, когда у вас есть и Option, и Result? Или если у вас есть Result<T, Error1> и Result<T, Error2>? Наша следующуя задача — обработка композиции различных типов ошибок, и это будет главной темой на протяжении всей этой статьи.

Совмещение Option и Result

Пока что мы говорили о комбинаторах, определенных для Option, и комбинаторах, определенных для Result. Эти комбинаторы можно использовать для того, чтобы сочетать результаты различных вычислений, не делая подробного вариативного анализа.

Конечно, в реальном коде все происходит не так гладко. Иногда у вас есть сочетания типов Option и Result. Должны ли мы прибегать к явному вариативному анализу, или можно продолжить использовать комбинаторы?

Давайте на время вернемся к одному из первых примеров в этой статье:

use std::env;

fn main() {
    let mut argv = env::args();
    let arg: String = argv.nth(1).unwrap(); // ошибка 1
    let n: i32 = arg.parse().unwrap(); // ошибка 2
    println!("{}", 2 * n);
}

Учитывая наши знания о типах Option и Result, а также их различных комбинаторах, мы можем попытаться переписать этот код так, чтобы ошибки обрабатывались должным образом, и программа не паниковала в случае ошибки.

Ньюанс заключается в том, что argv.nth(1) возвращает Option, в то время как arg.parse() возвращает Result. Они не могут быть скомпонованы непосредственно. Когда вы сталкиваетесь одновременно с Option и Result, обычно наилучшее решение — преобразовать Option в Result. В нашем случае, отсутствие параметра командной строки (из env::args()) означает, что пользователь не правильно вызвал программу. Мы могли бы просто использовать String для описания ошибки. Давайте попробуем:

use std::env;

fn double_arg(mut argv: env::Args) -> Result<i32, String> {
    argv.nth(1)
        .ok_or("Please give at least one argument".to_owned())
        .and_then(|arg| arg.parse::<i32>().map_err(|err| err.to_string()))
}

fn main() {
    match double_arg(env::args()) {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Error: {}", err),
    }
}

Раcсмотрим пару новых моментов на этом примере. Во-первых, использование комбинатора Option::ok_or. Это один из способов преобразования Option в Result. Такое преобразование требует явного определения ошибки, которую необходимо вернуть в случае, когда значение Option равно None. Как и для всех комбинаторов, которые мы рассматривали, его объявление очень простое:

fn ok_or<T, E>(option: Option<T>, err: E) -> Result<T, E> {
    match option {
        Some(val) => Ok(val),
        None => Err(err),
    }
}

Второй новый комбинатор, который мы использовали — Result::map_err. Это то же самое, что и Result::map, за исключением того, функция применяется к ошибке внутри Result. Если значение Result равно Оk(...), то оно возвращается без изменений.

Мы используем map_err, потому что нам необходимо привести все ошибки к одинаковому типу (из-за нашего использования and_then). Поскольку мы решили преобразовывать Option<String> (из argv.nth(1)) в Result<String, String>, мы также обязаны преобразовывать ParseIntError из arg.parse() в String.

Ограничения комбинаторов

Работа с IO и анализ входных данных — очень типичные задачи, и это то, чем лично я много занимаюсь с Rust. Так что мы будем использовать IO и различные процедуры анализа как примеры обработки ошибок.

Давайте начнем с простого. Поставим задачу открыть файл, прочесть все его содержимое и преобразовать это содержимое в число. После этого нужно будет умножить значение на 2 и распечатать результат.

Хоть я и пытался убедить вас не использовать unwrap, иногда бывает полезным для начала написать код с unwrap. Это позволяет сосредоточиться на проблеме, а не на обработке ошибок, и это выявляет места, где надлежащая обработка ошибок необходима. Давайте начнем с того, что напишем просто работающий код, а затем отрефакторим его для лучшей обработки ошибок.

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> i32 {
    let mut file = File::open(file_path).unwrap(); // ошибка 1
    let mut contents = String::new();
    file.read_to_string(&mut contents).unwrap();   // ошибка 2
    let n: i32 = contents.trim().parse().unwrap(); // ошибка 3
    2 * n
}

fn main() {
    let doubled = file_double("foobar");
    println!("{}", doubled);
}

(Замечание: Мы используем AsRef по тем же причинам, почему он используется в std::fs::File::open. Это позволяет удобно использовать любой тип строки в качестве пути к файлу.)

У нас есть три потенциальные ошибки, которые могут возникнуть:

  1. Проблема при открытии файла.
  2. Проблема при чтении данных из файла.
  3. Проблема при преобразовании данных в число.

Первые две проблемы определяются типом std::io::Error. Мы знаем это из типа возвращаемого значения методов std::fs::File::open и std::io::Read::read_to_string. (Обратите внимание, что они оба используют концепцию с псевдонимом типа Result, описанную ранее. Если вы кликните на тип Result, вы увидите псевдоним типа, и следовательно, лежащий в основе тип io::Error.) Третья проблема определяется типом std::num::ParseIntError. Кстати, тип io::Error часто используется по всей стандартной библиотеке. Вы будете видеть его снова и снова.

Давайте начнем рефакторинг функции file_double. Для того, чтобы эту функцию можно было сочетать с остальным кодом, она не должна паниковать, если какие-либо из перечисленных выше ошибок действительно произойдут. Фактически, это означает, что функция должна возвращать ошибку, если любая из возможных операций завершилась неудачей. Проблема состоит в том, что тип возвращаемого значения сейчас i32, который не дает нам никакого разумного способа сообщить об ошибке. Таким образом, мы должны начать с изменения типа возвращаемого значения с i32 на что-то другое.

Первое, что мы должны решить: какой из типов использовать: Option или Result? Мы, конечно, могли бы с легкостью использовать Option. Если какая-либо из трех ошибок происходит, мы могли бы просто вернуть None. Это будет работать, и это лучше, чем просто паниковать, но мы можем сделать гораздо лучше. Вместо этого, мы будем сообщать некоторые детали о возникшей проблеме. Поскольку мы хотим выразить возможность ошибки, мы должны использовать Result<i32, E>. Но каким должен быть тип E? Поскольку может возникнуть два разных типа ошибок, мы должны преобразовать их к общему типу. Одним из таких типов является String. Давайте посмотрим, как это отразится на нашем коде:

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
    File::open(file_path)
         .map_err(|err| err.to_string())
         .and_then(|mut file| {
              let mut contents = String::new();
              file.read_to_string(&mut contents)
                  .map_err(|err| err.to_string())
                  .map(|_| contents)
         })
         .and_then(|contents| {
              contents.trim().parse::<i32>()
                      .map_err(|err| err.to_string())
         })
         .map(|n| 2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Ошибка: {}", err),
    }
}

Выглядит немного запутанно. Может потребоваться довольно много практики, прежде вы сможете писать такое. Написание кода в таком стиле называется следованием за типом. Когда мы изменили тип возвращаемого значения file_double на Result<i32, String>, нам пришлось начать подбирать правильные комбинатороы. В данном случае мы использовали только три различных комбинатора: and_then, map и map_err.

Комбинатор and_then используется для объединения по цепочке нескольких вычислений, где каждое вычисление может вернуть ошибку. После открытия файла есть еще два вычисления, которые могут завершиться неудачей: чтение из файла и преобразование содержимого в число. Соответственно, имеем два вызова and_then.

Комбинатор map используется, чтобы применить функцию к значению Ok(...) типа Result. Например, в самом последнем вызове, map умножает значение Ok(...) (типа i32) на 2. Если ошибка произошла до этого момента, эта операция была бы пропущена. Это следует из определения map.

Комбинатор map_err — это уловка, которая позволяют всему этому заработать. Этот комбинатор, такой же, как и map, за исключением того, что применяет функцию к Err(...) значению Result. В данном случае мы хотим привести все наши ошибки к одному типу — String. Поскольку как io::Error, так и num::ParseIntError реализуют ToString, мы можем вызвать метод to_string, чтобы выполнить преобразование.

Не смотря на все сказанное, код по-прежнему выглядит запутанным. Мастерство использования комбинаторов является важным, но у них есть свои недостатки. Давайте попробуем другой подход: преждевременный возврат.

Преждевременный return

Давайте возьмем код из предыдущего раздела и перепишем его с применением раннего возврата. Ранний return позволяет выйти из функции досрочно. Мы не можем выполнить return для file_double внутри замыкания, поэтому нам необходимо вернуться к явному вариативному анализу.

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
    let mut file = match File::open(file_path) {
        Ok(file) => file,
        Err(err) => return Err(err.to_string()),
    };
    let mut contents = String::new();
    if let Err(err) = file.read_to_string(&mut contents) {
        return Err(err.to_string());
    }
    let n: i32 = match contents.trim().parse() {
        Ok(n) => n,
        Err(err) => return Err(err.to_string()),
    };
    Ok(2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Ошибка: {}", err),
    }
}

Кто-то может обосновано не согласиться с тем, что этот код лучше, чем тот, который использует комбинаторы, но если вы не знакомы с комбинаторами, на мой взгляд, этот код будет выглядеть проще. Он выполняет явный вариативный анализ с помощью match и if let. Если происходит ошибка, мы просто прекращаем выполнение функции и возвращаем ошибку (после преобразования в строку).

Разве это не шаг назад? Ранее мы говорили, что ключ к удобной обработке ошибок — сокращение явного вариативного анализа, но здесь мы вернулись к тому, с чего начинали. Оказывается, существует несколько способов его уменьшения. И комбинаторы — не единственный путь.

Макрос try!

Краеугольный камень обработки ошибок в Rust — это макрос try!. Этот макрос абстрагирует анализ вариантов так же, как и комбинаторы, но в отличие от них, он также абстрагирует поток выполнения. А именно, он умеет абстрагировать идею досрочного возврата, которую мы только что реализовали.

Вот упрощенное определение макроса `try!:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

(Реальное определение выглядит немного сложнее. Мы обсудим это далее).

Использование макроса try! может очень легко упростить наш последний пример. Поскольку он выполняет анализ вариантов и досрочной возврат из функции, мы получаем более плотный код, который легче читать:

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
    let mut file = try!(File::open(file_path).map_err(|e| e.to_string()));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents).map_err(|e| e.to_string()));
    let n = try!(contents.trim().parse::<i32>().map_err(|e| e.to_string()));
    Ok(2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Ошибка: {}", err),
    }
}

Вызов map_err по-прежнему необходим, учитывая наше определение try!, поскольку ошибки все еще должны быть преобразованы в String. Хорошей новостью является то, что в ближайшее время мы узнаем, как убрать все эти вызовы map_err! Плохая новость состоит в том, что для этого нам придется кое-что узнать о паре важных типажей из стандартной библиотеки.

Объявление собственного типа ошибки

Прежде чем мы погрузимся в аспекты некоторых типажей из стандартной библиотеки, связанных с ошибками, я бы хотел завершить этот раздел отказом от использования String как типа ошибки в наших примерах.

Использование String в том стиле, в котором мы использовали его в предыдущих примерах удобно потому, что достаточно легко конвертировать любые ошибки в строки, или даже создавать свои собственные ошибки на ходу. Тем не менее, использование типа String для ошибок имеет некоторые недостатки.

Первый недостаток в том, что сообщения об ошибках, как правило, загромождают код. Можно определять сообщения об ошибках в другом месте, но это поможет только если вы необыкновенно дисциплинированны, поскольку очень заманчиво вставлять сообщения об ошибках прямо в код. На самом деле, мы именно этим и занимались в предыдущем примере.

Второй и более важный недостаток заключается в том, что использование String чревато потерей информации. Другими словами, если все ошибки будут преобразованы в строки, то когда мы будем возвращать их вызывающей стороне, они не будут иметь никакого смысла. Единственное разумное, что вызывающая сторона может сделать с ошибкой типа String — это показать ее пользователю. Безусловно, можно проверить строку по значению, чтобы определить тип ошибки, но такой подход не может похвастаться надежностью. (Правда, в гораздо большей степени это недостаток для библиотек, чем для конечных приложений).

Например, тип io::Error включает в себя тип io::ErrorKind, который является структурированными данными, представляющими то, что пошло не так во время выполнения операции ввода-вывода. Это важно, поскольку может возникнуть необходимость по-разному реагировать на различные причины ошибки. (Например, ошибка BrokenPipe может изящно завершать программу, в то время как ошибка NotFound будет завершать программу с кодом ошибки и показывать соответствующее сообщение пользователю.) Благодаря io::ErrorKind, вызывающая сторона может исследовать тип ошибки с помощью вариативного анализа, и это значительно лучше попытки вычленить детали об ошибке из String.

Вместо того, чтобы использовать String как тип ошибки в нашем предыдущем примере про чтение числа из файла, мы можем определить свой собственный тип, который представляет ошибку в виде структурированных данных. Мы постараемся не потерять никакую информацию от изначальных ошибок на тот случай, если вызывающая сторона захочет исследовать детали.

Идеальным способом представления одного варианта из многих является определение нашего собственного типа-суммы с помощью enum. В нашем случае, ошибка представляет собой либо io::Error, либо num::ParseIntError, из чего естественным образом вытекает определение:

use std::io;
use std::num;

// Мы реализуем `Debug` поскольку, по всей видимости, все типы должны реализовывать `Debug`.
// Это дает нам возможность получить адекватное и читаемое описание значения CliError
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

Осталось только немного подогнать наш код из примера. Вместо преобразования ошибок в строки, мы будем просто конвертировать их в наш тип CliError, используя соответствующий конструктор значения:

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = try!(File::open(file_path).map_err(CliError::Io));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents).map_err(CliError::Io));
    let n: i32 = try!(contents.trim().parse().map_err(CliError::Parse));
    Ok(2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Ошибка: {:?}", err),
    }
}

Единственное изменение здесь — замена вызова map_err(|e| e.to_string()) (который преобразовывал ошибки в строки) на map_err(CliError::Io) или map_err(CliError::Parse). Теперь вызывающая сторона определяет уровень детализации сообщения об ошибке для конечного пользователя. В действительности, использование String как типа ошибки лишает вызывающего возможности выбора, в то время использование собственного типа enum, на подобие CliError, дает вызывающему тот же уровень удобства, который был ранее, и кроме этого структурированные данные, описывающие ошибку.

Практическое правило заключается в том, что необходимо определять свой собственный тип ошибки, а тип String для ошибок использовать в крайнем случае, в основном когда вы пишете конечное приложение. Если вы пишете библиотеку, определение своего собственного типа ошибки наиболее предпочтительно. Таким образом, вы не лишите пользователя вашей библиотеки возможности выбирать наиболее предпочтительное для его конкретного случая поведение.

Типажи из стандартной библиотеки, используемые для обработки ошибок

Стандартная библиотека определяет два встроенных типажа, полезных для обработки ошибок std::error::Error и std::convert::From. И если Error разработан специально для создания общего описания ошибки, то типаж From играет широкую роль в преобразовании значений между различными типами.

Типаж Error

Типаж Error объявлен в стандартной библиотеке:

use std::fmt::{Debug, Display};

trait Error: Debug + Display {
  /// A short description of the error.
  fn description(&self) -> &str;

  /// The lower level cause of this error, if any.
  fn cause(&self) -> Option<&Error> { None }
}

Этот типаж очень обобщенный, поскольку предполагается, что он должен быть реализован для всех типов, которые представляют собой ошибки. Как мы увидим дальше, он нам очень пригодится для написания сочетаемого кода. Этот типаж, как минимум, позволяет выполнять следующие вещи:

  • Получать строковое представление ошибки для разработчика (Debug).
  • Получать понятное для пользователя представление ошибки (Display).
  • Получать краткое описание ошибки (метод description).
  • Изучать по цепочке первопричину ошибки, если она существует (метод cause).

Первые две возможности возникают в результате того, что типаж Error требует в свою очередь реализации типажей Debug и Display. Последние два факта исходят из двух методов, определенных в самом Error. Мощь Еrror заключается в том, что все существующие типы ошибок его реализуют, что в свою очередь означает что любые ошибки могут быть сохранены как типажи-объекты (trait object). Обычно это выглядит как Box<Error>, либо &Error. Например, метод cause возвращает &Error, который как раз является типажом-объектом. Позже мы вернемся к применению Error как типажа-объекта.

В настоящее время достаточно показать пример, реализующий типаж Error. Давайте воспользуемся для этого типом ошибки, который мы определили в предыдущем разделе:

use std::io;
use std::num;

// Мы реализуем `Debug` поскольку, по всей видимости, все типы должны реализовывать `Debug`.
// Это дает нам возможность получить адекватное и читаемое описание значения CliError
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

Данный тип ошибки отражает возможность возникновения двух других типов ошибок: ошибка работы с IО или ошибка преобразования строки в число. Определение ошибки может отражать столько других видов ошибок, сколько необходимо, за счет добавления новых вариантов в объявлении enum.

Реализация Error довольно прямолинейна и главным образом состоит из явного анализа вариантов:

use std::error;
use std::fmt;

impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            // Оба изначальных типа ошибок уже реализуют `Display`,
            // так что мы можем использовать их реализации
            CliError::Io(ref err) => write!(f, "IO error: {}", err),
            CliError::Parse(ref err) => write!(f, "Parse error: {}", err),
        }
    }
}

impl error::Error for CliError {
    fn description(&self) -> &str {
        // Оба изначальных типа ошибок уже реализуют `Error`,
        // так что мы можем использовать их реализацией
        match *self {
            CliError::Io(ref err) => err.description(),
            CliError::Parse(ref err) => err.description(),
        }
    }

    fn cause(&self) -> Option<&error::Error> {
        match *self {
            // В обоих случаях просходит неявное преобразование значения `err`
            // из конкретного типа (`&io::Error` или `&num::ParseIntError`)
            // в типаж-обьект `&Error`. Это работает потому что оба типа реализуют `Error`.
            CliError::Io(ref err) => Some(err),
            CliError::Parse(ref err) => Some(err),
        }
    }
}

Хочется отметить, что это очень типичная реализация Error: реализация методов description и cause в соответствии с каждым возможным видом ошибки.

Типаж From

Типаж std::convert::From объявлен в стандартной библиотеке:

trait From<T> {
    fn from(T) -> Self;
}

Очень просто, не правда ли? Типаж From чрезвычайно полезен, поскольку создает общий подход для преобразования из определенного типа Т в какой-то другой тип (в данном случае, «другим типом» является тип, реализующий данный типаж, или Self). Самое важное в типаже From — множество его реализаций, предоставляемых стандартной библиотекой.

Вот несколько простых примеров, демонстрирующих работу From:

let string: String = From::from("foo");
let bytes: Vec<u8> = From::from("foo");
let cow: ::std::borrow::Cow<str> = From::from("foo");

Итак, From полезен для выполнения преобразований между строками. Но как насчет ошибок? Оказывается, существует одна важная реализация:

impl<'a, E: Error + 'a> From<E> for Box<Error + 'a>

Эта реализация говорит, что любой тип, который реализует Error, можно конвертировать в типаж-объект Box<Error>. Выглядит не слишком впечатляюще, но это очень полезно в общем контексте.

Помните те две ошибки, с которыми мы имели дело ранее, а именно, io::Error and num::ParseIntError? Поскольку обе они реализуют Error, они также работают с From:

use std::error::Error;
use std::fs;
use std::io;
use std::num;

// Получаем значения ошибок
let io_err: io::Error = io::Error::last_os_error();
let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err();

// Собственно, конвертация
let err1: Box<Error> = From::from(io_err);
let err2: Box<Error> = From::from(parse_err);

Здесь нужно разобрать очень важный паттерн. Переменные err1 и err2 имеют одинаковый тип — типаж-объект. Это означает, что их реальные типы скрыты от компилятора, так что по факту он рассматривает err1 и err2 как одинаковые сущности. Кроме того, мы создали err1 и err2, используя один и тот же вызов функции — From::from. Мы можем так делать, поскольку функция From::from перегружена по ее аргументу и возвращаемому типу.

Эта возможность очень важна для нас, поскольку она решает нашу предыдущую проблему, позволяя эффективно конвертировать разные ошибки в один и тот же тип, пользуясь только одной функцией.

Настало время вернуться к нашему старому другу — макросу try!.

Настоящий макрос try!

До этого мы привели такое определение try!:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

Но это не настоящее определение. Реальное определение можно найти в стандартной библиотеке:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(::std::convert::From::from(err)),
    });
}

Здесь есть одно маленькое, но очень важное изменение: значение ошибки пропускается через вызов From::from. Это делает макрос try! очень мощным инструментом, поскольку он дает нам возможность бесплатно выполнять автоматическое преобразование типов.

Вооружившись более мощным макросом try!, давайте взглянем на код, написанный нами ранее, который читает файл и конвертирует его содержимое в число:

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
    let mut file = try!(File::open(file_path).map_err(|e| e.to_string()));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents).map_err(|e| e.to_string()));
    let n = try!(contents.trim().parse::<i32>().map_err(|e| e.to_string()));
    Ok(2 * n)
}

Ранее мы говорили, что мы можем избавиться от вызовов map_err. На самом деле, все что мы должны для этого сделать — это найти тип, который работает с From. Как мы увидели в предыдущем разделе, From имеет реализацию, которая позволяет преобразовать любой тип ошибки в Box<Error>:

use std::error::Error;
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, Box<Error>> {
    let mut file = try!(File::open(file_path));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents));
    let n = try!(contents.trim().parse::<i32>());
    Ok(2 * n)
}

Мы уже очень близки к идеальной обработке ошибок. Наш код имеет очень мало накладных расходов из-за обработки ошибок, ведь макрос try! инкапсулирует сразу три вещи:

  1. Вариативный анализ.
  2. Поток выполнения.
  3. Преобразование типов ошибок.

Когда все эти три вещи объединены вместе, мы получаем код, который не обременен комбинаторами, вызовами unwrap или постоянным анализом вариантов.

Но осталась одна маленькая деталь: тип Box<Error> не несет никакой информации. Если мы возвращаем Box<Error> вызывающей стороне, нет никакой возможности (легко) узнать базовый тип ошибки. Ситуация, конечно, лучше, чем со String, посольку появилась возможность вызывать методы, вроде description или cause, но ограничение остается: Box<Error> не предоставляет никакой информации о сути ошибки. (Замечание: Это не совсем верно, поскольку в Rust есть инструменты рефлексии во время выполнения, которые полезны при некоторых сценариях, но их рассмотрение выходит за рамки этой статьи).

Настало время вернуться к нашему собственному типу CliError и связать все в одно целое.

Совмещение собственных типов ошибок

В последнем разделе мы рассмотрели реальный макрос try! и то, как он выполняет автоматическое преобразование значений ошибок с помощью вызова From::from. В нашем случае мы конвертировали ошибки в Box<Error>, который работает, но его значение скрыто для вызывающей стороны.

Чтобы исправить это, мы используем средство, с которым мы уже знакомы: создание собственного типа ошибки. Давайте вспомним код, который считывает содержимое файла и преобразует его в целое число:

use std::fs::File;
use std::io::{self, Read};
use std::num;
use std::path::Path;

// Мы реализуем `Debug` поскольку, по всей видимости, все типы должны реализовывать `Debug`.
// Это дает нам возможность получить адекватное и читаемое описание значения CliError
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

fn file_double_verbose<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = try!(File::open(file_path).map_err(CliError::Io));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents).map_err(CliError::Io));
    let n: i32 = try!(contents.trim().parse().map_err(CliError::Parse));
    Ok(2 * n)
}

Обратите внимание, что здесь у нас еще остались вызовы map_err. Почему? Вспомните определения try! и From. Проблема в том, что не существует такой реализации From, которая позволяет конвертировать типы ошибок io::Error и num::ParseIntError в наш собственный тип CliError. Но мы можем легко это исправить! Поскольку мы определили тип CliError, мы можем также реализовать для него типаж From:

use std::io;
use std::num;

impl From<io::Error> for CliError {
    fn from(err: io::Error) -> CliError {
        CliError::Io(err)
    }
}

impl From<num::ParseIntError> for CliError {
    fn from(err: num::ParseIntError) -> CliError {
        CliError::Parse(err)
    }
}

Все эти реализации позволяют From создавать значения CliError из других типов ошибок. В нашем случае такое создание состоит из простого вызова конструктора значения. Как правило, это все что нужно.

Наконец, мы можем переписать file_double:


use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = try!(File::open(file_path));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents));
    let n: i32 = try!(contents.trim().parse());
    Ok(2 * n)
}

Единственное, что мы сделали — это удалили вызовы map_err. Они нам больше не нужны, поскольку макрос try! выполняет From::from над значениями ошибок. И это работает, поскольку мы предоставили реализации From для всех типов ошибок, которые могут возникнуть.

Если бы мы изменили нашу функцию file_double таким образом, чтобы она начала выполнять какие-то другие операции, например, преобразовать строку в число с плавающей точкой, то мы должны были бы добавить новый вариант к нашему типу ошибок:

use std::io;
use std::num;

enum CliError {
    Io(io::Error),
    ParseInt(num::ParseIntError),
    ParseFloat(num::ParseFloatError),
}

И добавить новую реализацию для From:


use std::num;

impl From<num::ParseFloatError> for CliError {
    fn from(err: num::ParseFloatError) -> CliError {
        CliError::ParseFloat(err)
    }
}

Вот и все!

Рекомендации для авторов библиотек

Если в вашей библиотеке могут возникать специфические ошибки, то вы наверняка должны определить для них свой собственный тип. На ваше усмотрение вы можете сделать его внутреннее представление публичным (как ErrorKind), или оставить его скрытым (подобно ParseIntError). Независимо от того, что вы предпримете, считается хорошим тоном обеспечить по крайней мере некоторую информацию об ошибке помимо ее строкового представления. Но, конечно, все зависит от конкретных случаев использования.

Как минимум, вы скорее всего должны реализовать типаж Error. Это даст пользователям вашей библиотеки некоторую минимальную гибкость при совмещении ошибок. Реализация типажа Error также означает, что пользователям гарантируется возможность получения строкового представления ошибки (это следует из необходимости реализации fmt::Debug и fmt::Display).

Кроме того, может быть полезным реализовать From для ваших типов ошибок. Это позволит вам (как автору библиотеки) и вашим пользователям совмещать более детальные ошибки. Например, csv::Error реализует From для io::Error и byteorder::Error.

Наконец, на свое усмотрение, вы также можете определить псевдоним типа Result, особенно, если в вашей библиотеке определен только один тип ошибки. Такой подход используется в стандартной библиотеке для io::Result и fmt::Result.

Заключение

Поскольку это довольно длинная статья, не будет лишним составить короткий конспект по обработке ошибок в Rust. Ниже будут приведены некоторые практические рекомендации. Это совсем не заповеди. Наверняка существуют веские причины для того, чтобы нарушить любое из этих правил.

  • Если вы пишете короткий пример кода, который может быть перегружен обработкой ошибок, это, вероятно, отличная возможность использовать unwrap (будь-то Result::unwrap, Option::unwrap или Option::expect). Те, для кого предназначен пример, должны осознавать, что необходимо реализовать надлежащую обработку ошибок. (Если нет, отправляйте их сюда!)
  • Если вы пишете одноразовую программу, также не зазорно использовать unwrap. Но будьте внимательны: если ваш код попадет в чужие руки, не удивляйтесь, если кто-то будет расстроен из-за скудных сообщений об ошибках!
  • Если вы пишете одноразовый код, но вам все-равно стыдно из-за использования unwrap, воспользуйтесь либо String в качестве типа ошибки, либо Box<Error + Send + Sync> (из-за доступных реализаций From.)
  • В остальных случаях, определяйте свои собственные типы ошибок с соответствующими реализациями From и Error, делая использование try! более удобным.
  • Если вы пишете библиотеку и ваш код может выдавать ошибки, определите ваш собственный тип ошибки и реализуйте типаж std::error::Error. Там, где это уместно, реализуйте From, чтобы вам и вашим пользователям было легче с ними работать. (Из-за правил когерентности в Rust, пользователи вашей библиотеки не смогут реализовать From для ваших ошибок, поэтому это должна сделать ваша библиотека.)
  • Изучите комбинаторы, определенные для Option и Result. Писать код, пользуясь только ними может быть немного утомительно, но я лично нашел для себя хороший баланс между использованием try! и комбинаторами (and_then, map и unwrap_or — мои любимые).

Эта статья была подготовлена в рамках перевода на русский язык официального руководства «The Rust Programming Language». Переводы остальных глав этой книги можно найти здесь. Так же, если у вас есть любые вопросы, связанные с Rust, вы можете задать их в чате русскоязычного сообщества Rust.

Throw is a new experimental rust error handling library, meant to assist and build on existing
error handling systems.

Throw exports two structs, throw::ErrorPoint and throw::Error. throw::Error stores a
single original_error variable which it is created from, and then a list of ErrorPoints
which starts out with the original point of creation with throw!(), and is added to every
time you propagate the error upwards with up!().

Throw does not replace existing error handling systems. The throw::Error type has a type
parameter E which represents an internal error type stored. throw::Error just wraps your
error type and stores ErrorPoints alongside it.

Throw helps you better keep track of your errors. Instead of seeing a generic «No such file or
directory» message, you get a stack trace of functions which propagated the error as well.

Instead of:

IO Error: failed to lookup address information: Name or service not known

Get:

Error: IO Error: failed to lookup address information: Name or service not known
    at 79:17 in zaldinar::startup (src/startup.rs)
    at 104:4 in zaldinar::startup (src/startup.rs)
    at 28:17 in zaldinar_irclib (/home/daboross/Projects/Rust/zaldinar/zaldinar-irclib/src/lib.rs)

The main way you use throw is through two macros, throw!() and up!(). throw!() is used
when you have a regular (non-throw) result coming from some library function that you want to
propagate upwards in case of an error. up!() is used when you have an error which was
created using throw!() in a sub-function which you want to add an error point to and
propagate upwards.

Here’s an example of throw in action:

#[macro_use]
extern crate throw;

use std::io::prelude::*;
use std::io;
use std::fs::File;

fn read_log() -> Result<String, throw::Error<io::Error>> {
    let mut file = throw!(File::open("some_file.log"));
    let mut buf = String::new();
    throw!(file.read_to_string(&mut buf));
    Ok((buf))
}

fn do_things() -> Result<(), throw::Error<io::Error>> {
    let log_contents = up!(read_log());
    println!("Log contents: {}", log_contents);

    Ok(())
}

fn main() {
    let result = do_things();
    if let Err(e) = result {
        panic!("{}", e);
    }
}

This simple program behaves exactly as if Result<_, io::Error> directly when it functions
correctly. When the program encounters is when throw really shines. This will result in an
error message:

Error: No such file or directory (os error 2)
   at 16:23 in main (src/main.rs)
   at 9:19 in main (src/main.rs)

These stack traces are stored inside throw::Error, and are recorded automatically when
throw!() or up!() returns an Err value.

In each at line, the 16:23 represents line_num:column_num, the main represents the
module path (for example my_program::sub_module), and src/main.rs represents the path of
the file in which throw!() was used in.


Throwing directly from a function is also supported, using throw_new!():

fn possibly_fails() -> Result<(), throw::Error<&'static str>> {
    if true {
        
        throw_new!("oops");
    }

    Ok(())
}

fn main() {
    possibly_fails().unwrap()
}
called `Result::unwrap()` on an `Err` value: Error: "oops"
   at 6:8 in main (src/main.rs)

throw_new!() differs from throw!() in that it takes a parameter directly to pass to a
throw::Error, rather than a Result<> to match on. throw_new!() will always return
directly from the function.


Throw offers support for no_std, with the caveat that a dependency on alloc is still
required for Vec support. (throw uses a Vec to store error points within an error.)

To use this feature, depend on throw with default-features = false:

[dependencies]
throw = { version = "0.1", default-features = "false" }

Throw supports adding key/value pairs to errors to provide additional context information.
In order to use this, simply add any number of "key_name" => value, arguments to any of
the macros throw exports. value can be any integer type, float type, an &'static str,
or an owned string.

fn possibly_fails(process_this: &str) -> Result<(), throw::Error<&'static str>> {
    if true {
        throw_new!("oops", "processing" => process_this.to_owned());
    }

    Ok(())
}

fn main() {
    possibly_fails("hello").unwrap()
}

Results in:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error: "oops"
    processing: hello
    at 6:9 in rust_out (src/lib.rs)', libcore/result.rs:945:5

To have serde::{Serialize, Deserialize} implemented on Throw types, depend on throw with
features = ["serde-1-std"] or features = ["serde-1"] for no-std environments.

Cover image for Error Handling in Rust

Enoch Chejieh

Introduction

In this article, we’re going to take a look at Error handling in Rust and how it improves the performance of Rust web applications.

Error handling is an important part of software development because it improves the way we think about software as we build and it allows us to intercept errors/failures.

When we build software, we tend to run into bugs or errors in our code and when we do, we are greeted with great error messages which help us identify problems efficiently. This, in turn, boosts our productivity and can lead to a better developer experience.

Major Difference between Error Handling in Rust and Other Programming Languages

Compared to other programming languages that handle errors by throwing exceptions, Rust handles errors by returning actual errors. While throwing exceptions is very useful for the identification of error types and error reporting, it’s not very explicit, which means it would be easy to miss areas of code that should have exception handling. Rust, on the other hand, returns errors explicitly.

How Rust Handles Errors

Result Type

The result type Result<T, E> is an enum that has two possible states:

enum Result<T, E> {
    Ok(T),
    Err(E)
}

Enter fullscreen mode

Exit fullscreen mode

T can be any type and E can be any error. The two states Ok(T) represent success and holds a value, and Err(E) represent error and holds a specific error value.

We use the result type when operations might go wrong. An operation is expected to succeed, but there might be cases where it fails.

For example:

use std::num::ParseIntError;

fn convert_string_to_integer(number_str: &str) -> Result<(), ParseIntError> {
    let number:i32 = match number_str.parse() {
        Ok(number) => number,
        Err(e) => return Err(e)
    };

    println!("{}", number); // 200

    Ok(())
}

Enter fullscreen mode

Exit fullscreen mode

Here’s what happens:

  1. When convert_string_to_integer is called, this function converts a string to an integer value based on the string parameter passed in the function.

  2. With the match number_str.parse() we are required to handle the two possible states. When we pass a string containing only numbers, the operation succeeds then we assign the converted value to the number variable, or we return from the function by returning the error when the operation fails.

Panic

In Rust, When you encounter an unrecoverable error they can be handled by panic!. panic! allows you to stop your program in execution when you encounter this kind of error and also provides useful feedback.

Here’s an example:

use std::num::ParseIntError;

fn convert_string_to_integer(number_str: &str) -> Result<(), ParseIntEror> {
    let number:i32 = match number_str.parse() {
        Ok(number) => number,
        Err(_) => panic!("Invalid digit found in string")
    };

    println!("{}", number); // 200

    Ok(())
}

Enter fullscreen mode

Exit fullscreen mode

When we encounter an error in this function panic!('Invalid digit found in string') will be invoked.

Unwrap

There might be some situations that you are very confident about the code you have written and you feel positive that your code won’t encounter errors and you want to opt-out of error handling.

In that case you can simply use the unwrap() method, for example:

fn convert_string_to_integer() -> i32 {
    let number_str:&str = "200";
    let number:i32 = number_str.parse().unwrap();

    return number; 
}

let result = convert_string_to_integer();
println!("{}", result); // 200

Enter fullscreen mode

Exit fullscreen mode

Expect

The expect() method is similar to the unwrap() but unlike unwrap(), The expect() method allows you to set an error message. This makes debugging a lot easier.

fn convert_string_to_integer(number_str: &str) {
    let number_str:&str = "o100" 
    let number:i32 = number_str.parse();

    number.expect("Invalid digit in string");
}

Enter fullscreen mode

Exit fullscreen mode

The output

thread 'main' panicked at 'Invalid digit in string: ParseIntError { kind: InvalidDigit }', src/main.rs:11:20
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Enter fullscreen mode

Exit fullscreen mode

The Question Mark Operator (?)

The (?) operator makes the propagation of errors much easier and it’s equivalent to the match expression. It eliminates a lot of verbosities when implementing functions and it can only be applied for Result<T, E> and Option<T> types, unlike the match expression, it unpacks the Result if Ok and returns the error if it’s not.

For example:

use std::num::ParseIntError;

fn convert_string_to_integer(number_str: &str) -> Result<i32, ParseIntError>  {
    let number:i32 = number_str.parse()?;

    Ok(number)
}

let result = convert_string_to_integer("200");
println!("{:?}", result); // Ok(200)

Enter fullscreen mode

Exit fullscreen mode

Notice how our code looks much more simpler and easier to read.

Conclusion

Error handling is about good communication. Rust has made a strong emphasis on making error handling a good experience for developers.

There is of course more methods used in Rust for error handling, but the ones covered in this article should get you started, this is also my first technical rust article and I look forward to writing more, please if you have any questions or comments please feel free to ask them in the comment section.

Rust generally solves errors in two ways:

  • Unrecoverable errors. Once you panic!, that’s it. Your program or thread aborts because it encounters something it can’t solve and its invariants have been violated. E.g. if you find invalid sequences in what should be a UTF-8 string.

  • Recoverable errors. Also called failures in some documentation. Instead of panicking, you emit a Option<T> or Result<T, E>. In these cases, you have a choice between a valid value Some(T)/Ok(T) respectively or an invalid value None/Error(E). Generally None serves as a null replacement, showing that the value is missing.


Now comes the hard part. Application.

Unwrap

Sometimes dealing with an Option is a pain in the neck, and you are almost guaranteed to get a value and not an error.

In those cases it’s perfectly fine to use unwrap. unwrap turns Some(e) and Ok(e) into e, otherwise it panics. Unwrap is a tool to turn your recoverable errors into unrecoverable.

if x.is_some() {
    y = x.unwrap(); // perfectly safe, you just checked x is Some
}

Inside the if-block it’s perfectly fine to unwrap since it should never panic because we’ve already checked that it is Some with x.is_some().

If you’re writing a library, using unwrap is discouraged because when it panics the user cannot handle the error. Additionally, a future update may change the invariant. Imagine if the example above had if x.is_some() || always_return_true(). The invariant would changed, and unwrap could panic.

? operator / try! macro

What’s the ? operator or the try! macro? A short explanation is that it either returns the value inside an Ok() or prematurely returns error.

Here is a simplified definition of what the operator or macro expand to:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

If you use it like this:

let x = File::create("my_file.txt")?;
let x = try!(File::create("my_file.txt"));

It will convert it into this:

let x = match File::create("my_file.txt") {
    Ok(val)  => val,
    Err(err) => return Err(err),
};

The downside is that your functions now return Result.

Combinators

Option and Result have some convenience methods that allow chaining and dealing with errors in an understandable manner. Methods like and, and_then, or, or_else, ok_or, map_err, etc.

For example, you could have a default value in case your value is botched.

let x: Option<i32> = None;
let guaranteed_value = x.or(Some(3)); //it's Some(3)

Or if you want to turn your Option into a Result.

let x = Some("foo");
assert_eq!(x.ok_or("No value found"), Ok("foo"));

let x: Option<&str> = None;
assert_eq!(x.ok_or("No value found"), Err("No value found"));

This is just a brief skim of things you can do. For more explanation, check out:

  • http://blog.burntsushi.net/rust-error-handling/
  • https://doc.rust-lang.org/book/ch09-00-error-handling.html
  • http://lucumr.pocoo.org/2014/10/16/on-error-handling/

Introduction

In this article, I will discuss error handling in Rust 🦀. I try to explain the differences between recoverable and unrecoverable errors, and how to handle them properly in your code.

At the end of this article, I will also take a quick lookinto two popular crates for error handling in Rust 🦀: anyhow and thiserror.

The Panic Macro and Unrecoverable Errors

A Panic is an exception that a Rust 🦀 program can throw. It stops all execution in the current thread. Panic, will return a short description of the error and the location of the panic in the source code.

Let’s look at an example:

fn main() {
    println!("Hello, world!");
    panic!("oh no!");
}

This will print Hello, world! and then panic with the message oh no! and the location of the panic in the source code.

If your running this code in a terminal, you will see the following output:

cargo run                                                
   Compiling rust-error v0.1.0 (/Users/dirien/Tools/repos/quick-bites/rust-error)
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rust-error`
Hello, world!
thread 'main' panicked at 'oh no!', src/main.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The message gives us also a hint on how to display a backtrace. If you run the code with the environment variable RUST_BACKTRACE=1 you will get a list of all the functions leading up to the panic.

RUST_BACKTRACE=1 cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/rust-error`
Hello, world!
thread 'main' panicked at 'oh no!', src/main.rs:3:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/panicking.rs:142:14
   2: rust_error::main
             at ./src/main.rs:3:5
   3: core::ops::function::FnOnce::call_once
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

In this case, the backtrace is not very useful, because the panic is in the main function.

Let’s look at a different example, which is extremely contrived, but for demonstration purposes, it will do.

fn a() {
    b();
}

fn b() {
    c("engin");
}

fn c(name: &str) {
    if name == "engin" {
        panic!("Dont pass engin");
    }
}

fn main() {
    a();
}

We have three functions a, b and c. The main function calls a. a calls b and b calls c. c takes a string as an argument and panics if the string is engin.

cargo run
   Compiling rust-error v0.1.0 (/Users/dirien/Tools/repos/quick-bites/rust-error)
    Finished dev [unoptimized + debuginfo] target(s) in 0.14s
     Running `target/debug/rust-error`
thread 'main' panicked at 'Dont pass engin', src/main.rs:11:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

This error is not very useful. We can see that the panic happened in c, but we don’t know which function called c.

If we run the code with the environment variable RUST_BACKTRACE=1 we get the following output:

RUST_BACKTRACE=1 cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/rust-error`
thread 'main' panicked at 'Dont pass engin', src/main.rs:11:9
stack backtrace:
   0: rust_begin_unwind
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/panicking.rs:142:14
   2: rust_error::c
             at ./src/main.rs:11:9
   3: rust_error::b
             at ./src/main.rs:6:5
   4: rust_error::a
             at ./src/main.rs:2:5
   5: rust_error::main
             at ./src/main.rs:16:5
   6: core::ops::function::FnOnce::call_once
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

This is much better. We can see that the panic happened in c, and we can see the call stack leading up to the panic. We see that c was called by b, which was called by a, which was called by main. So let’s change the code in b to call c with a different name.

fn b() {
    c("dirien");
}

Now the code compiles and runs without any problems.

Recoverable Errors

A recoverable error is an error that can be handled by the code. For example, if we try to open a file that does not exist, we can handle the error and print a message to the user or create the file instead of crashing the program.

For this case, we can use the Result type. The Result type is an enum with two variants: Ok and Err. The Ok variant indicates that the operation was successful and stores a generic value. The Err variant indicates that the operation failed and stores an error value.

Like the Option type, the Result type is defined in the standard library, and we need to bring it into scope.

Let’s look at an example. We will try to open a file and read the contents of the file.

fn main() {
    let f = File::open("hello.txt");
}

Here we need to check the result of the open function. If the file is opened successfully, we can read the contents of the file. If the file is not opened successfully, we can print an error message to the user.

To check the result of the open function, we can use the match expression. The match expression is similar to the if expression, but it can handle more than two cases. We’re also shadowing the f variable and setting it to the match expression.

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => panic!("There was a problem opening the file: {:?}", error),
    };
}

If the open function returns Ok, we store the file handle in the f variable. If the open function returns Err, we panic and print the error message.

Let us run the code and see what happens.

cargo run
warning: `rust-error` (bin "rust-error") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/rust-error`
thread 'main' panicked at 'There was a problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:7:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

We get a panic, but the error message is much more useful. We can see that the error is Os { code: 2, kind: NotFound, message: "No such file or directory" }. This error makes sense because we are trying to open a file that does not exist.

Now let’s enhance the code instead of panicking, we will create the file if it does not exist. First, we will bring the ErrorKind enum into scope.

use std::fs::File;
use std::io::ErrorKind;
...

Then we will use the match expression to check the error kind. If the error kind is NotFound, we will create the file. But the creation of the file can also fail, so we will use the match expression again to check the result of the create function. If the create function returns Ok, we will return the file handle. If the create function returns Err, we will panic.

The last part is to use other_error to handle all other errors that are not ErrorKind::NotFound.

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => panic!("There was a problem opening the file: {:?}", other_error),
        },
    };
}

Now when we run the code, we can see that no panic happens. And if we check the directory, we can see that the file was created.

cargo run
   Compiling rust-error v0.1.0 (/Users/dirien/Tools/repos/quick-bites/rust-error)
    Finished dev [unoptimized + debuginfo] target(s) in 0.62s
     Running `target/debug/rust-error`

But this code is not very readable. We have a lot of match expressions. A better way to handle this is to use closures. We will use closures to handle the Ok and Err variants of the Result type.

When we attempt to open a file, we will use the unwrap_or_else method which gives us back the file or calls the anonymous function or closure that we pass the error to. Inside the closure, we will check the error kind. If the error is NotFound then we attempt to create the file called the unwrap_or_else method again. This gives us back the file if the calls succeed. Note that we don’t have a semicolon at the end which means this is an expression and not a statement. In the failure case, we have another closure that will just panic.

fn main() {
    let f = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {:?}", error);
            })
        } else {
            panic!("There was a problem opening the file: {:?}", error);
        }
    });
}

Now we going to rewrite the code again to use the unwrap and expect methods. The unwrap method is a shortcut method that is implemented on Result types. If the Result is Ok, the unwrap method will return the value inside the Ok. If the Result is Err, the unwrap method will call the panic! macro for us.

fn main() {
    let f = File::open("hello.txt").unwrap();
}

When we run the code, we get the same error as before.

cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.10s
     Running `target/debug/rust-error`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The expect method is similar to the unwrap method, but we can pass a custom error message to the expect method. This error message will be printed when the Result is Err.

fn main() {
    let f = File::open("hello.txt").expect("OMG! I cant open the file!");
}

When we run the code, we can see our custom error message.

cargo run
   Compiling rust-error v0.1.0 (/Users/dirien/Tools/repos/quick-bites/rust-error)
    Finished dev [unoptimized + debuginfo] target(s) in 0.10s
     Running `target/debug/rust-error`
thread 'main' panicked at 'OMG! I cant open the file!: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

How to propagate errors

In the previous section, we saw how to handle errors. But what if we want to propagate the error to the caller of our function? This gives the caller the ability to handle the error.

Let’s say we want to read the contents of a file. We will create a function that reads username from a file. The function will return a Result type. The Result type will contain a String on success and the io::Error on error.

If the file does not exist, we will return the error. If the file exists, we will try to read the contents of the file. If this is not successful, we will return the error. If the read is successful, we will return the username.

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}

We can shorten the code by using the ? operator. The ? operator can only be used in functions that return a Result type. The ? operator is similar to our unwrap and expect methods. If the Result is Ok, the ? operator will return the value inside the Ok. If the Result is Err, instead of calling the panic! macro, the ? operator will return the error and early exit the function.

If everything is successful, the ? operator will return safely the value inside the Ok.

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;

    let mut username = String::new();

    username_file.read_to_string(&mut username)?;

    Ok(username)
}

We can shorten the code even more by chaining method calls. The ? operator can be used with method calls that return a Result type.

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}

But we can make the code even shorter by using the system module function fs::read_to_string. The fs::read_to_string function will open the file, create a new String, read the contents of the file into the String, and return it. If any of these steps fail, the fs::read_to_string function will return the error.

use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}

As mentioned before, the ? operator can only be used in functions that return a Result type. If we want to use the ? operator in the main function, we have to change the return type of the main function to Result. The main function can also return a Result type.

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;
    Ok(())
}

The main function returns a Result type. The Result type contains a () on success and a Box<dyn Error> on error.

Error helper crates

There are a lot of crates that can help you with error handling. In this section, we will look at the anyhow crate and the thiserror crate. This is not an exhaustive list of error-handling crates, but it will give you an idea of what is out there.

Of course, we can not go to deep into these crates. If you want to learn more about these crates, you can check out the links at the end of this section.

The thiserror crate

thiserror provides a derived implementation which adds the error trait for us. This makes it easier to implement the error trait for our custom error types.

To use the thiserror crate, we have to add the crate to our Cargo.toml file. The cargo add command will add the thiserror crate to our Cargo.toml file.

cargo add thiserror

We can now use the thiserror crate in our code. We will create a custom error type for our read_username_from_file function called CustomError.

use std::error::Error;
use std::fs::File;
use std::io::Read;

#[derive(Debug, thiserror::Error)]
enum CustomError {
    #[error("OMG! There is an error {0}")]
    BadError(#[from] std::io::Error),

}

fn read_username_from_file() -> Result<String, CustomError> {
    let mut username = String::new();
    File::open("hello.txt")?.read_to_string(&mut username)?;
    Ok(username)
}

The anyhow crate

anyhow provides an idiomatic alternative to explicitly handling errors. It is similar to the previously mentioned error trait but has additional features such as adding context to thrown errors.

To add the anyhow crate to our project, we can use the cargo add command.

cargo add anyhow

We can now use the anyhow crate in our code. We will create a custom error type for our read_username_from_file function called CustomError.

use std::fs::File;
use std::io::Read;
use anyhow::Context;


fn read_username_from_file() -> Result<String, anyhow::Error> {
    let mut username = String::new();

    File::open("hello.txt").context("Failed to open file")?.read_to_string(&mut username).context("Failed to read file")?;

    Ok(username)
}

When to use thiserror and anyhow

The thiserror crate is useful when you want to implement the Error trait for your custom error types. The anyhow crate is useful when you don’t care about the error type and just want to add context to the error.

Summary

In this article, we looked at error handling in Rust 🦀. We talked about non-recoverable errors and recoverable errors. The error handling in Rust 🦀 is designed to help you in writing code that is more robust and less error-prone. The panic! macro is used for non-recoverable errors when your program is in a state where it can not continue and should stop instead of trying to proceed with invalid or incorrect data. The Result type is used for recoverable errors. The Result enums indicate that the operation can fail and that our code can recover from the error and the caller of the piece of code has to handle the success or failure of the operation.

Resources

  • Error Handling

  • The anyhow crate

  • The thiserror crate

Basic Error Handling

Error handling in Rust can be clumsy if you can’t use the question-mark operator.
To achieve happiness, we need to return a Result which can accept any error.
All errors implement the trait std::error::Error, and
so any error can convert into a Box<Error>.

Say we needed to handle both i/o errors and errors from converting
strings into numbers:

# #![allow(unused_variables)]
# 
#fn main() {
// box-error.rs
use std::fs::File;
use std::io::prelude::*;
use std::error::Error;

fn run(file: &str) -> Result<i32,Box<Error>> {
    let mut file = File::open(file)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents.trim().parse()?)
}

#}

So that’s two question-marks for the i/o errors (can’t open file, or can’t read as string)
and one question-mark for the conversion error. Finally, we wrap the result in Ok.
Rust can work out from the return type that parse should convert to i32.

It’s easy to create a shortcut for this Result type:

# #![allow(unused_variables)]
# 
#fn main() {
type BoxResult<T> = Result<T,Box<Error>>;

#}

However, our programs will have application-specific error conditions, and so
we need to create our own error type. The basic requirements
are straightforward:

  • May implement Debug
  • Must implement Display
  • Must implement Error

Otherwise, your error can do pretty much what it likes.

# #![allow(unused_variables)]
# 
#fn main() {
// error1.rs
use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct MyError {
    details: String
}

impl MyError {
    fn new(msg: &str) -> MyError {
        MyError{details: msg.to_string()}
    }
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f,"{}",self.details)
    }
}

impl Error for MyError {
    fn description(&self) -> &str {
        &self.details
    }
}

// a test function that returns our error result
fn raises_my_error(yes: bool) -> Result<(),MyError> {
    if yes {
        Err(MyError::new("borked"))
    } else {
        Ok(())
    }
}

#}

Typing Result<T,MyError> gets tedious and many Rust modules define their own
Result — e.g. io::Result<T> is short for Result<T,io::Error>.

In this next example we need to handle the specific error when a string can’t be parsed
as a floating-point number.

Now the way that ? works
is to look for a conversion from the error of the expression to the error that must
be returned. And this conversion is expressed by the From trait. Box<Error>
works as it does because it implements From for all types implementing Error.

At this point you can continue to use the convenient alias BoxResult and catch everything
as before; there will be a conversion from our error into Box<Error>.
This is a good option for smaller applications. But I want to show other errors can
be explicitly made to cooperate with our error type.

ParseFloatError implements Error so description() is defined.

# #![allow(unused_variables)]
# 
#fn main() {
use std::num::ParseFloatError;

impl From<ParseFloatError> for MyError {
    fn from(err: ParseFloatError) -> Self {
        MyError::new(err.description())
    }
}

// and test!
fn parse_f64(s: &str, yes: bool) -> Result<f64,MyError> {
    raises_my_error(yes)?;
    let x: f64 = s.parse()?;
    Ok(x)
}

#}

The first ? is fine (a type always converts to itself with From) and the
second ? will convert the ParseFloatError to MyError.

And the results:

fn main() {
    println!(" {:?}", parse_f64("42",false));
    println!(" {:?}", parse_f64("42",true));
    println!(" {:?}", parse_f64("?42",false));
}
//  Ok(42)
//  Err(MyError { details: "borked" })
//  Err(MyError { details: "invalid float literal" })

Not too complicated, although a little long-winded. The tedious bit is having to
write From conversions for all the other error types that need to play nice
with MyError — or you simply lean on Box<Error>. Newcomers get confused
by the multitude of ways to do the same thing in Rust; there is always another
way to peel the avocado (or skin the cat, if you’re feeling bloodthirsty). The price
of flexibility is having many options. Error-handling for a 200 line program can afford
to be simpler than for a large application. And if you ever want to package your precious
droppings as a Cargo crate, then error handling becomes crucial.

Currently, the question-mark operator only works for Result, not Option, and this is
a feature, not a limitation. Option has a ok_or_else which converts itself into a Result.
For example, say we had a HashMap and must fail if a key isn’t defined:

# #![allow(unused_variables)]
# 
#fn main() {
    let val = map.get("my_key").ok_or_else(|| MyError::new("my_key not defined"))?;

#}

Now here the error returned is completely clear! (This form uses a closure, so the error value
is only created if the lookup fails.)

simple-error for Simple Errors

The simple-error crate provides you with
a basic error type based on a string, as we have defined it here, and a few convenient macros.
Like any error, it works fine with Box<Error>:

#[macro_use]
extern crate simple_error;

use std::error::Error;

type BoxResult<T> = Result<T,Box<Error>>;

fn run(s: &str) -> BoxResult<i32> {
    if s.len() == 0 {
        bail!("empty string");
    }
    Ok(s.trim().parse()?)
}

fn main() {
    println!("{:?}", run("23"));
    println!("{:?}", run("2x"));
    println!("{:?}", run(""));
}
// Ok(23)
// Err(ParseIntError { kind: InvalidDigit })
// Err(StringError("empty string"))

bail!(s) expands to return SimpleError::new(s).into(); — return early with a conversion into
the receiving type.

You need to use BoxResult for mixing the SimpleError type with other errors, since
we can’t implement From for it, since both the trait and the type come from other crates.

error-chain for Serious Errors

For non-trivial applications have a look
at the error_chain crate.
A little macro magic can go a long way in Rust…

Create a binary crate with cargo new --bin test-error-chain and
change to this directory. Edit Cargo.toml and add error-chain="0.8.1" to the end.

What error-chain does for you is create all the definitions we needed for manually implementing
an error type; creating a struct, and implementing the necessary traits: Display, Debug and Error.
It also by default implements From so strings can be converted into errors.

Our first src/main.rs file looks like this. All the main program does is call run, print out any
errors, and end the program with a non-zero exit code. The macro error_chain generates all the
definitions needed, within an error module — in a larger program you would put this in its own file.
We need to bring everything in error back into global scope because our code will need to see
the generated traits. By default, there will be an Error struct and a Result defined with that
error.

Here we also ask for From to be implemented so that std::io::Error will convert into
our error type using foreign_links:

#[macro_use]
extern crate error_chain;

mod errors {
    error_chain!{
        foreign_links {
            Io(::std::io::Error);
        }
    }
}
use errors::*;

fn run() -> Result<()> {
    use std::fs::File;

    File::open("file")?;

    Ok(())
}


fn main() {
    if let Err(e) = run() {
        println!("error: {}", e);

        std::process::exit(1);
    }
}
// error: No such file or directory (os error 2)

The ‘foreign_links’ has made our life easier, since the question mark operator now knows how to
convert std::io::Error into our error::Error. (Under the hood, the macro is creating a
From<std::io::Error> conversion, exactly as spelt out earlier.)

All the action happens in run; let’s make it print out the first 10 lines of a file given as the
first program argument. There may or may not be such an argument, which isn’t necessarily an
error. Here we want to convert an Option<String> into a Result<String>. There are two Option
methods for doing this conversion, and I’ve picked the simplest one. Our Error type implements
From for &str, so it’s straightforward to make an error with a simple text message.

# #![allow(unused_variables)]
# 
#fn main() {
fn run() -> Result<()> {
    use std::env::args;
    use std::fs::File;
    use std::io::BufReader;
    use std::io::prelude::*;

    let file = args().skip(1).next()
        .ok_or(Error::from("provide a file"))?;

    let f = File::open(&file)?;
    let mut l = 0;
    for line in BufReader::new(f).lines() {
        let line = line?;
        println!("{}", line);
        l += 1;
        if l == 10 {
            break;
        }
    }

    Ok(())
}

#}

There is (again) a useful little macro bail! for ‘throwing’ errors.
An alternative to the ok_or method here could be:

# #![allow(unused_variables)]
# 
#fn main() {
    let file = match args().skip(1).next() {
        Some(s) => s,
        None => bail!("provide a file")
    };

#}

Like ? it does an early return.

The returned error contains an enum ErrorKind, which allows us to distinguish between various
kinds of errors. There’s always a variant Msg (when you say Error::from(str)) and the foreign_links
has declared Io which wraps I/O errors:

fn main() {
    if let Err(e) = run() {
        match e.kind() {
            &ErrorKind::Msg(ref s) => println!("msg {}",s),
            &ErrorKind::Io(ref s) => println!("io {}",s),
        }
        std::process::exit(1);
    }
}
// $ cargo run
// msg provide a file
// $ cargo run foo
// io No such file or directory (os error 2)

It’s straightforward to add new kinds of errors. Add an errors section to the error_chain! macro:

# #![allow(unused_variables)]
# 
#fn main() {
    error_chain!{
        foreign_links {
            Io(::std::io::Error);
        }

        errors {
            NoArgument(t: String) {
                display("no argument provided: '{}'", t)
            }
        }

    }

#}

This defines how Display works for this new kind of error. And now we can handle
‘no argument’ errors more specifically, feeding ErrorKind::NoArgument a String value:

# #![allow(unused_variables)]
# 
#fn main() {
    let file = args().skip(1).next()
        .ok_or(ErrorKind::NoArgument("filename needed".to_string()))?;


#}

There’s now an extra ErrorKind variant that you must match:

fn main() {
    if let Err(e) = run() {
        println!("error {}",e);
        match e.kind() {
            &ErrorKind::Msg(ref s) => println!("msg {}", s),
            &ErrorKind::Io(ref s) => println!("io {}", s),
            &ErrorKind::NoArgument(ref s) => println!("no argument {:?}", s),
        }
        std::process::exit(1);
    }
}
// cargo run
// error no argument provided: 'filename needed'
// no argument "filename needed"

Generally, it’s useful to make errors as specific as possible, particularly if this is a library
function! This match-on-kind technique is pretty much the equivalent of traditional exception handling,
where you match on exception types in a catch or except block.

In summary, error-chain creates a type Error for you, and defines Result<T> to be std::result::Result<T,Error>.
Error contains an enum ErrorKind and by default there is one variant Msg for errors created from
strings. You define external errors with foreign_links which does two things. First, it creates a new
ErrorKind variant. Second, it defines From on these external errors so they can be converted to our
error. New error variants can be easily added. A lot of irritating boilerplate code is eliminated.

Chaining Errors

But the really cool thing that this crate provides is error chaining.

As a library user, it’s irritating when a method simply just ‘throws’ a generic I/O error. OK, it
could not open a file, fine, but what file? Basically, what use is this information to me?

error_chain does error chaining which helps solve this problem of over-generic errors. When we
try to open the file, we can lazily lean on the conversion to io::Error using ?, or chain the error.

# #![allow(unused_variables)]
# 
#fn main() {
// non-specific error
let f = File::open(&file)?;

// a specific chained error
let f = File::open(&file).chain_err(|| "unable to read the damn file")?;

#}

Here’s a new version of the program, with no imported ‘foreign’ errors, just the defaults:

#[macro_use]
extern crate error_chain;

mod errors {
    error_chain!{
    }

}
use errors::*;

fn run() -> Result<()> {
    use std::env::args;
    use std::fs::File;
    use std::io::BufReader;
    use std::io::prelude::*;

    let file = args().skip(1).next()
        .ok_or(Error::from("filename needed"))?;

    ///////// chain explicitly! ///////////
    let f = File::open(&file).chain_err(|| "unable to read the damn file")?;

    let mut l = 0;
    for line in BufReader::new(f).lines() {
        let line = line.chain_err(|| "cannot read a line")?;
        println!("{}", line);
        l += 1;
        if l == 10 {
            break;
        }
    }

    Ok(())
}


fn main() {
    if let Err(e) = run() {
        println!("error {}", e);

        /////// look at the chain of errors... ///////
        for e in e.iter().skip(1) {
            println!("caused by: {}", e);
        }

        std::process::exit(1);
    }
}
// $ cargo run foo
// error unable to read the damn file
// caused by: No such file or directory (os error 2)

So the chain_err method takes the original error, and creates a new error which contains the
original error — this can be continued indefinitely. The closure is expected to return any
value which can be converted into an error.

Rust macros can clearly save you a lot of typing. error-chain even provides a shortcut that
replaces the whole main program:

# #![allow(unused_variables)]
# 
#fn main() {
quick_main!(run);

#}

(run is where all the action takes place, anyway.)

  • May 13, 2021
  • 8550 words
  • 43 min

This article is a sample from Zero To Production In Rust, a book on backend development in Rust.
You can get a copy of the book on zero2prod.com.
Subscribe to the newsletter to be notified when a new episode is published.

TL;DR

To send a confirmation email you have to stitch together multiple operations: validation of user input, email dispatch, various database queries.
They all have one thing in common: they may fail.

In Chapter 6 we discussed the building blocks of error handling in Rust — Result and the ? operator.
We left many questions unanswered: how do errors fit within the broader architecture of our application? What does a good error look like? Who are errors for? Should we use a library? Which one?

An in-depth analysis of error handling patterns in Rust will be the sole focus of this chapter.

Chapter 8

  1. What Is The Purpose Of Errors?
    • Internal Errors
      • Enable The Caller To React
      • Help An Operator To Troubleshoot
    • Errors At The Edge
      • Help A User To Troubleshoot
    • Summary
  2. Error Reporting For Operators
    • Keeping Track Of The Error Root Cause
    • The Error Trait
      • Trait Objects
      • Error::source
  3. Errors For Control Flow
    • Layering
    • Modelling Errors as Enums
    • The Error Type Is Not Enough
    • Removing The Boilerplate With thiserror
  4. Avoid «Ball Of Mud» Error Enums
    • Using anyhow As Opaque Error Type
    • anyhow Or thiserror?
  5. Who Should Log Errors?
  6. Summary

What Is The Purpose Of Errors?

Let’s start with an example:

//! src/routes/subscriptions.rs
// [...]

pub async fn store_token(
    transaction: &mut Transaction<'_, Postgres>,
    subscriber_id: Uuid,
    subscription_token: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query!(
        r#"
    INSERT INTO subscription_tokens (subscription_token, subscriber_id)
    VALUES ($1, $2)
        "#,
        subscription_token,
        subscriber_id
    )
    .execute(transaction)
    .await
    .map_err(|e| {
        tracing::error!("Failed to execute query: {:?}", e);
        e
    })?;
    Ok(())
}

We are trying to insert a row into the subscription_tokens table in order to store a newly-generated token against a subscriber_id.
execute is a fallible operation: we might have a network issue while talking to the database, the row we are trying to insert might violate some table constraints (e.g. uniqueness of the primary key), etc.

Internal Errors

Enable The Caller To React

The caller of execute most likely wants to be informed if a failure occurs — they need to react accordingly, e.g. retry the query or propagate the failure upstream using ?, as in our example.

Rust leverages the type system to communicate that an operation may not succeed: the return type of execute is Result, an enum.

pub enum Result<Success, Error> {
    Ok(Success),
    Err(Error)
}

The caller is then forced by the compiler to express how they plan to handle both scenarios — success and failure.

If our only goal was to communicate to the caller that an error happened, we could use a simpler definition for Result:

pub enum ResultSignal<Success> {
    Ok(Success),
    Err
}

There would be no need for a generic Error type — we could just check that execute returned the Err variant, e.g.

let outcome = sqlx::query!(/* ... */)
    .execute(transaction)
    .await;
if outcome == ResultSignal::Err { 
    // Do something if it failed
}

This works if there is only one failure mode.
Truth is, operations can fail in multiple ways and we might want to react differently depending on what happened.
Let’s look at the skeleton of sqlx::Error, the error type for execute:

//! sqlx-core/src/error.rs
 
pub enum Error {
    Configuration(/* */),
    Database(/* */),
    Io(/* */),
    Tls(/* */),
    Protocol(/* */),
    RowNotFound,
    TypeNotFound {/* */},
    ColumnIndexOutOfBounds {/* */},
    ColumnNotFound(/* */),
    ColumnDecode {/* */},
    Decode(/* */),
    PoolTimedOut,
    PoolClosed,
    WorkerCrashed,
    Migrate(/* */),
}

Quite a list, ain’t it?
sqlx::Error is implemented as an enum to allow users to match on the returned error and behave differently depending on the underlying failure mode. For example, you might want to retry a PoolTimedOut while you will probably give up on a ColumnNotFound.

Help An Operator To Troubleshoot

What if an operation has a single failure mode — should we just use () as error type?

Err(()) might be enough for the caller to determine what to do — e.g. return a 500 Internal Server Error to the user.

But control flow is not the only purpose of errors in an application.
We expect errors to carry enough context about the failure to produce a report for an operator (e.g. the developer) that contains enough details to go and troubleshoot the issue.

What do we mean by report?
In a backend API like ours it will usually be a log event.
In a CLI it could be an error message shown in the terminal when a --verbose flag is used.

The implementation details may vary, the purpose stays the same: help a human understand what is going wrong.
That’s exactly what we are doing in the initial code snippet:

//! src/routes/subscriptions.rs
// [...]

pub async fn store_token(/* */) -> Result<(), sqlx::Error> {
    sqlx::query!(/* */)
        .execute(transaction)
        .await
        .map_err(|e| {
            tracing::error!("Failed to execute query: {:?}", e);
            e
        })?;
    // [...]
}

If the query fails, we grab the error and emit a log event. We can then go and inspect the error logs when investigating the database issue.

Errors At The Edge

Help A User To Troubleshoot

So far we focused on the internals of our API — functions calling other functions and operators trying to make sense of the mess after it happened.
What about users?

Just like operators, users expect the API to signal when a failure mode is encountered.

What does a user of our API see when store_token fails?
We can find out by looking at the request handler:

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(/* */) -> HttpResponse {
    // [...]
    if store_token(&mut transaction, subscriber_id, &subscription_token)
        .await
        .is_err() 
    {
        return HttpResponse::InternalServerError().finish();
    }
    // [...]
}

They receive an HTTP response with no body and a 500 Internal Server Error status code.

The status code fulfills the same purpose of the error type in store_token: it is a machine-parsable piece of information that the caller (e.g. the browser) can use to determine what to do next (e.g. retry the request assuming it’s a transient failure).

What about the human behind the browser? What are we telling them?
Not much, the response body is empty.
That is actually a good implementation: the user should not have to care about the internals of the API they are calling — they have no mental model of it and no way to determine why it is failing. That’s the realm of the operator.
We are omitting those details by design.

In other circumstances, instead, we need to convey additional information to the human user. Let’s look at our input validation for the same endpoint:

//! src/routes/subscriptions.rs

#[derive(serde::Deserialize)]
pub struct FormData {
    email: String,
    name: String,
}

impl TryFrom<FormData> for NewSubscriber {
    type Error = String;

    fn try_from(value: FormData) -> Result<Self, Self::Error> {
        let name = SubscriberName::parse(value.name)?;
        let email = SubscriberEmail::parse(value.email)?;
        Ok(Self { email, name })
    }
} 

We received an email address and a name as data attached to the form submitted by the user. Both fields are going through an additional round of validation — SubscriberName::parse and SubscriberEmail::parse. Those two methods are fallible — they return a String as error type to explain what has gone wrong:

//! src/domain/subscriber_email.rs
// [...]

impl SubscriberEmail {
    pub fn parse(s: String) -> Result<SubscriberEmail, String> {
        if validate_email(&s) {
            Ok(Self(s))
        } else {
            Err(format!("{} is not a valid subscriber email.", s))
        }
    }
}

It is, I must admit, not the most useful error message: we are telling the user that the email address they entered is wrong, but we are not helping them to determine why.
In the end, it doesn’t matter: we are not sending any of that information to the user as part of the response of the API — they are getting a 400 Bad Request with no body.

//! src/routes/subscription.rs
// [...]

pub async fn subscribe(/* */) -> HttpResponse {
    let new_subscriber = match form.0.try_into() {
        Ok(form) => form,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    // [...]

This is a poor error: the user is left in the dark and cannot adapt their behaviour as required.

Summary

Let’s summarise what we uncovered so far.
Errors serve two1 main purposes:

  • Control flow (i.e. determine what do next);
  • Reporting (e.g. investigate, after the fact, what went wrong on).

We can also distinguish errors based on their location:

  • Internal (i.e. a function calling another function within our application);
  • At the edge (i.e. an API request that we failed to fulfill).

Control flow is scripted: all information required to take a decision on what to do next must be accessible to a machine.
We use types (e.g. enum variants), methods and fields for internal errors.
We rely on status codes for errors at the edge.

Error reports, instead, are primarily consumed by humans.
The content has to be tuned depending on the audience.
An operator has access to the internals of the system — they should be provided with as much context as possible on the failure mode.
A user sits outside the boundary of the application2: they should only be given the amount of information required to adjust their behaviour if necessary (e.g. fix malformed inputs).

We can visualise this mental model using a 2×2 table with Location as columns and Purpose as rows:

Internal At the edge
Control Flow Types, methods, fields Status codes
Reporting Logs/traces Response body

We will spend the rest of the chapter improving our error handling strategy for each of the cells in the table.

Error Reporting For Operators

Let’s start with error reporting for operators.
Are we doing a good job with logging right now when it comes to errors?

Let’s write a quick test to find out:

//! tests/api/subscriptions.rs
// [...]

#[tokio::test]
async fn subscribe_fails_if_there_is_a_fatal_database_error() {
    // Arrange
    let app = spawn_app().await;
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    // Sabotage the database
    sqlx::query!("ALTER TABLE subscription_tokens DROP COLUMN subscription_token;",)
        .execute(&app.db_pool)
        .await
        .unwrap();

    // Act
    let response =  app.post_subscriptions(body.into()).await;

    // Assert
    assert_eq!(response.status().as_u16(), 500);
}

The test passes straight away — let’s look at the log emitted by the application3.

Make sure you are running on tracing-actix-web 0.4.0-beta.8, tracing-bunyan-formatter 0.2.4 and actix-web 4.0.0-beta.8!

# sqlx logs are a bit spammy, cutting them out to reduce noise
export RUST_LOG="sqlx=error,info"
export TEST_LOG=enabled
cargo t subscribe_fails_if_there_is_a_fatal_database_error | bunyan

The output, once you focus on what matters, is the following:

 INFO: [HTTP REQUEST - START] 
 INFO: [ADDING A NEW SUBSCRIBER - START]
 INFO: [SAVING NEW SUBSCRIBER DETAILS IN THE DATABASE - START]
 INFO: [SAVING NEW SUBSCRIBER DETAILS IN THE DATABASE - END]
 INFO: [STORE SUBSCRIPTION TOKEN IN THE DATABASE - START]
ERROR: [STORE SUBSCRIPTION TOKEN IN THE DATABASE - EVENT] Failed to execute query: 
        Database(PgDatabaseError { 
          severity: Error, 
          code: "42703", 
          message: 
              "column 'subscription_token' of relation
               'subscription_tokens' does not exist", 
          ...
        })
    target=zero2prod::routes::subscriptions
 INFO: [STORE SUBSCRIPTION TOKEN IN THE DATABASE - END]
 INFO: [ADDING A NEW SUBSCRIBER - END]
ERROR: [HTTP REQUEST - EVENT] Error encountered while 
        processing the incoming HTTP request: "" 
    exception.details="",
    exception.message="",
    target=tracing_actix_web::middleware
 INFO: [HTTP REQUEST - END] 
    exception.details="",
    exception.message="",
    target=tracing_actix_web::root_span_builder, 
    http.status_code=500

How do you read something like this?
Ideally, you start from the outcome: the log record emitted at the end of request processing. In our case, that is:

 INFO: [HTTP REQUEST - END] 
    exception.details="",
    exception.message="",
    target=tracing_actix_web::root_span_builder, 
    http.status_code=500

What does that tell us?
The request returned a 500 status code — it failed.
We don’t learn a lot more than that: both exception.details and exception.message are empty.

The situation does not get much better if we look at the next log, emitted by tracing_actix_web:

ERROR: [HTTP REQUEST - EVENT] Error encountered while 
        processing the incoming HTTP request: "" 
    exception.details="",
    exception.message="",
    target=tracing_actix_web::middleware

No actionable information whatsoever. Logging «Oops! Something went wrong!» would have been just as useful.

We need to keep looking, all the way to the last remaining error log:

ERROR: [STORE SUBSCRIPTION TOKEN IN THE DATABASE - EVENT] Failed to execute query: 
        Database(PgDatabaseError { 
          severity: Error, 
          code: "42703", 
          message: 
              "column 'subscription_token' of relation
               'subscription_tokens' does not exist", 
          ...
        })
    target=zero2prod::routes::subscriptions

Something went wrong when we tried talking to the database — we were expecting to see a subscription_token column in the subscription_tokens table but, for some reason, it was not there.
This is actually useful!

Is it the cause of the 500 though?
Difficult to say just by looking at the logs — a developer will have to clone the codebase, check where that log line is coming from and make sure that it’s indeed the cause of the issue.
It can be done, but it takes time: it would be much easier if the [HTTP REQUEST - END] log record reported something useful about the underlying root cause in exception.details and exception.message.

Keeping Track Of The Error Root Cause

To understand why the log records coming out tracing_actix_web are so poor we need to inspect (again) our request handler and store_token:

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(/* */) -> HttpResponse {
    // [...]
    if store_token(&mut transaction, subscriber_id, &subscription_token)
        .await
        .is_err()
    {
        return HttpResponse::InternalServerError().finish();
    }
    // [...]
}

pub async fn store_token(/* */) -> Result<(), sqlx::Error> {
    sqlx::query!(/* */)
        .execute(transaction)
        .await
        .map_err(|e| {
            tracing::error!("Failed to execute query: {:?}", e);
            e
        })?;
    // [...]
}

The useful error log we found is indeed the one emitted by that tracing::error call — the error message includes the sqlx::Error returned by execute.
We propagate the error upwards using the ? operator, but the chain breaks in subscribe — we discard the error we received from store_token and build a bare 500 response.

HttpResponse::InternalServerError().finish() is the only thing that actix_web and tracing_actix_web::TracingLogger get to access when they are about to emit their respective log records. The error does not contain any context about the underlying root cause, therefore the log records are equally useless.

How do we fix it?

We need to start leveraging the error handling machinery exposed by actix_web — in particular, actix_web::Error.
According to the documentation:

actix_web::Error is used to carry errors from std::error through actix_web in a convenient way.

It sounds exactly like what we are looking for.
How do we build an instance of actix_web::Error?
The documentation states that

actix_web::Error can be created by converting errors with into().

A bit indirect, but we can figure it out4.
The only From/Into implementation that we can use, browsing the ones listed in the documentation, seems to be this one:

/// Build an `actix_web::Error` from any error that implements `ResponseError`
impl<T: ResponseError + 'static> From<T> for Error {
    fn from(err: T) -> Error {
        Error {
            cause: Box::new(err),
        }
    }
}

ResponseError is a trait exposed by actix_web:

/// Errors that can be converted to `Response`.
pub trait ResponseError: fmt::Debug + fmt::Display {
    /// Response's status code.
    ///
    /// The default implementation returns an internal server error.
    fn status_code(&self) -> StatusCode;

    /// Create a response from the error.
    ///
    /// The default implementation returns an internal server error.
    fn error_response(&self) -> Response;
}

We just need to implement it for our errors!
actix_web provides a default implementation for both methods that returns a 500 Internal Server Error — exactly what we need. Therefore it’s enough to write:

//! src/routes/subscriptions.rs
use actix_web::ResponseError;
// [...]

impl ResponseError for sqlx::Error {}

The compiler is not happy:

error[E0117]: only traits defined in the current crate 
              can be implemented for arbitrary types
   --> src/routes/subscriptions.rs:162:1
    |
162 | impl ResponseError for sqlx::Error {}
    | ^^^^^^^^^^^^^^^^^^^^^^^-----------
    | |                      |
    | |                      `sqlx::Error` is not defined in the current crate
    | impl doesn't use only types from inside the current crate
    |
    = note: define and implement a trait or new type instead

We just bumped into Rust’s orphan rule: it is forbidden to implement a foreign trait for a foreign type, where foreign stands for «from another crate».
This restriction is meant to preserve coherence: imagine if you added a dependency that defined its own implementation of ResponseError for sqlx::Error — which one should the compiler use when the trait methods are invoked?

Orphan rule aside, it would still be a mistake for us to implement ResponseError for sqlx::Error.
We want to return a 500 Internal Server Error when we run into a sqlx::Error while trying to persist a subscriber token.
In another circumstance we might wish to handle a sqlx::Error differently.

We should follow the compiler’s suggestion: define a new type to wrap sqlx::Error.

//! src/routes/subscriptions.rs
// [...]

//                                    Using the new error type!
pub async fn store_token(/* */) -> Result<(), StoreTokenError> {
    sqlx::query!(/* */)
    .execute(transaction)
    .await
    .map_err(|e| {
        // [...]
        // Wrapping the underlying error
        StoreTokenError(e)
    })?;
    // [...]
}

// A new error type, wrapping a sqlx::Error
pub struct StoreTokenError(sqlx::Error);

impl ResponseError for StoreTokenError {}

It doesn’t work, but for a different reason:

error[E0277]: `StoreTokenError` doesn't implement `std::fmt::Display`
   --> src/routes/subscriptions.rs:164:6
    |
164 | impl ResponseError for StoreTokenError {}
    |      ^^^^^^^^^^^^^ 
    `StoreTokenError` cannot be formatted with the default formatter
    |
    |
59  | pub trait ResponseError: fmt::Debug + fmt::Display {
    |                                       ------------ 
    |			required by this bound in `ResponseError`
    |
    = help: the trait `std::fmt::Display` is not implemented for `StoreTokenError`

error[E0277]: `StoreTokenError` doesn't implement `std::fmt::Debug`
   --> src/routes/subscriptions.rs:164:6
    |
164 | impl ResponseError for StoreTokenError {}
    |      ^^^^^^^^^^^^^ 
    `StoreTokenError` cannot be formatted using `{:?}`
    |
    |
59  | pub trait ResponseError: fmt::Debug + fmt::Display {
    |                          ---------- 
                required by this bound in `ResponseError`
    |
    = help: the trait `std::fmt::Debug` is not implemented for `StoreTokenError`
    = note: add `#[derive(Debug)]` or manually implement `std::fmt::Debug`

We are missing two trait implementations on StoreTokenError: Debug and Display.
Both traits are concerned with formatting, but they serve a different purpose.
Debug should return a programmer-facing representation, as faithful as possible to the underlying type structure, to help with debugging (as the name implies). Almost all public types should implement Debug.
Display, instead, should return a user-facing representation of the underlying type. Most types do not implement Display and it cannot be automatically implemented with a #[derive(Display)] attribute.

When working with errors, we can reason about the two traits as follows: Debug returns as much information as possible while Display gives us a brief description of the failure we encountered, with the essential amount of context.

Let’s give it a go for StoreTokenError:

//! src/routes/subscriptions.rs
// [...]

// We derive `Debug`, easy and painless.
#[derive(Debug)]
pub struct StoreTokenError(sqlx::Error);

impl std::fmt::Display for StoreTokenError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "A database error was encountered while 
            trying to store a subscription token."
        )
    }
}

It compiles!
We can now leverage it in our request handler:

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(/* */) -> Result<HttpResponse, actix_web::Error> {
    // You will have to wrap (early) returns in `Ok(...)` as well!
    // [...]
    // The `?` operator transparently invokes the `Into` trait
    // on our behalf - we don't need an explicit `map_err` anymore.
    store_token(/* */).await?;
    // [...]
}

Let’s look at our logs again:

# sqlx logs are a bit spammy, cutting them out to reduce noise
export RUST_LOG="sqlx=error,info"
export TEST_LOG=enabled
cargo t subscribe_fails_if_there_is_a_fatal_database_error | bunyan
...
 INFO: [HTTP REQUEST - END] 
    exception.details= StoreTokenError(
        Database(
            PgDatabaseError { 
                severity: Error, 
                code: "42703", 
                message: 
                    "column 'subscription_token' of relation 
                     'subscription_tokens' does not exist",
                ...
            }
        )
    )
    exception.message=
        "A database failure was encountered while 
         trying to store a subscription token.",
    target=tracing_actix_web::root_span_builder, 
    http.status_code=500

Much better!
The log record emitted at the end of request processing now contains both an in-depth and brief description of the error that caused the application to return a 500 Internal Server Error to the user.
It is enough to look at this log record to get a pretty accurate picture of everything that matters for this request.

The Error Trait

So far we moved forward by following the compiler suggestions, trying to satisfy the constraints imposed on us by actix-web when it comes to error handling.
Let’s step back to look at the bigger picture: what should an error look like in Rust (not considering the specifics of actix-web)?

Rust’s standard library has a dedicated trait, Error.

pub trait Error: Debug + Display {
    /// The lower-level source of this error, if any.
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        None
    }
}

It requires an implementation of Debug and Display, just like ResponseError.
It also gives us the option to implement a source method that returns the underlying cause of the error, if any.

What is the point of implementing the Error trait at all for our error type?
It is not required by Result — any type can be used as error variant there.

pub enum Result<T, E> {
    /// Contains the success value
    Ok(T),

    /// Contains the error value
    Err(E),
}

The Error trait is, first and foremost, a way to semantically mark our type as being an error. It helps a reader of our codebase to immediately spot its purpose.
It is also a way for the Rust community to standardise on the minimum requirements for a good error:

  • it should provide different representations (Debug and Display), tuned to different audiences;
  • it should be possible to look at the underlying cause of the error, if any (source).

This list is still evolving — e.g. there is an unstable backtrace method.
Error handling is an active area of research in the Rust community — if you are interested in staying on top of what is coming next I strongly suggest you to keep an eye on the Rust Error Handling Working Group.

By providing a good implementation of all the optional methods we can fully leverage the error handling ecosystem — functions that have been designed to work with errors, generically. We will be writing one in a couple of sections!

Trait Objects

Before we work on implementing source, let’s take a closer look at its return — Option<&(dyn Error + 'static)>.
dyn Error is a trait object5 — a type that we know nothing about apart from the fact that it implements the Error trait.
Trait objects, just like generic type parameters, are a way to achieve polymorphism in Rust: invoke different implementations of the same interface. Generic types are resolved at compile-time (static dispatch), trait objects incur a runtime cost (dynamic dispatch).

Why does the standard library return a trait object?
It gives developers a way to access the underlying root cause of current error while keeping it opaque.
It does not leak any information about the type of the underlying root cause — you only get access to the methods exposed by the Error trait6: different representations (Debug, Display), the chance to go one level deeper in the error chain using source.

Error::source

Let’s implement Error for StoreTokenError:

//! src/routes/subscriptions.rs
// [..]

impl std::error::Error for StoreTokenError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        // The compiler transparently casts `&sqlx::Error` into a `&dyn Error`
        Some(&self.0)
    }
}

source is useful when writing code that needs to handle a variety of errors: it provides a structured way to navigate the error chain without having to know anything about the specific error type you are working with.

If we look at our log record, the causal relationship between StoreTokenError and sqlx::Error is somewhat implicit — we infer one is the cause of the other because it is a part of it.

...
 INFO: [HTTP REQUEST - END] 
    exception.details= StoreTokenError(
        Database(
            PgDatabaseError { 
                severity: Error, 
                code: "42703", 
                message: 
                    "column 'subscription_token' of relation 
                     'subscription_tokens' does not exist",
                ...
            }
        )
    )
    exception.message=
        "A database failure was encountered while 
         trying to store a subscription token.",
    target=tracing_actix_web::root_span_builder, 
    http.status_code=500

Let’s go for something more explicit:

//! src/routes/subscriptions.rs

// Notice that we have removed `#[derive(Debug)]`
pub struct StoreTokenError(sqlx::Error);

impl std::fmt::Debug for StoreTokenError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}nCaused by:nt{}", self, self.0)
    }
}

The log record leaves nothing to the imagination now:

...
 INFO: [HTTP REQUEST - END] 
    exception.details=
        "A database failure was encountered 
        while trying to store a subscription token.
    
        Caused by:
            error returned from database: column 'subscription_token'
            of relation 'subscription_tokens' does not exist"
    exception.message=
        "A database failure was encountered while 
         trying to store a subscription token.",
    target=tracing_actix_web::root_span_builder, 
    http.status_code=500

exception.details is easier to read and still conveys all the relevant information we had there before.

Using source we can write a function that provides a similar representation for any type that implements Error:

//! src/routes/subscriptions.rs
// [...]

fn error_chain_fmt(
    e: &impl std::error::Error,
    f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
    writeln!(f, "{}n", e)?;
    let mut current = e.source();
    while let Some(cause) = current {
        writeln!(f, "Caused by:nt{}", cause)?;
        current = cause.source();
    }
    Ok(())
}

It iterates over the whole chain of errors7 that led to the failure we are trying to print.
We can then change our implementation of Debug for StoreTokenError to use it:

//! src/routes/subscriptions.rs
// [...]

impl std::fmt::Debug for StoreTokenError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        error_chain_fmt(self, f)
    }
}

The result is identical — and we can reuse it when working with other errors if we want a similar Debug representation.

Errors For Control Flow

Layering

We achieved the outcome we wanted (useful logs), but I am not too fond of the solution: we implemented a trait from our web framework (ResponseError) for an error type returned by an operation that is blissfully unaware of REST or the HTTP protocol, store_token. We could be calling store_token from a different entrypoint (e.g. a CLI) — nothing should have to change in its implementation.
Even assuming we are only ever going to be invoking store_token in the context of a REST API, we might add other endpoints that rely on that routine — they might not want to return a 500 when it fails.

Choosing the appropriate HTTP status code when an error occurs is a concern of the request handler, it should not leak elsewhere.
Let’s delete

//! src/routes/subscriptions.rs
// [...]

// Nuke it!
impl ResponseError for StoreTokenError {}

To enforce a proper separation of concerns we need to introduce another error type, SubscribeError. We will use it as failure variant for subscribe and it will own the HTTP-related logic (ResponseError‘s implementation).

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    // [...]	
}

#[derive(Debug)]
struct SubscribeError {}

impl std::fmt::Display for SubscribeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "Failed to create a new subscriber."
        )
    }
}

impl std::error::Error for SubscribeError {}

impl ResponseError for SubscribeError {}

If you run cargo check you will see an avalanche of '?' couldn't convert the error to 'SubscribeError' — we need to implement conversions from the error types returned by our functions and SubscribeError.

Modelling Errors as Enums

An enum is the most common approach to work around this issue: a variant for each error type we need to deal with.

//! src/routes/subscriptions.rs
// [...]

#[derive(Debug)]
pub enum SubscribeError {
    ValidationError(String),
    DatabaseError(sqlx::Error),
    StoreTokenError(StoreTokenError),
    SendEmailError(reqwest::Error),
}

We can then leverage the ? operator in our handler by providing a From implementation for each of wrapped error types:

//! src/routes/subscriptions.rs
// [...]

impl From<reqwest::Error> for SubscribeError {
    fn from(e: reqwest::Error) -> Self {
        Self::SendEmailError(e)
    }
}

impl From<sqlx::Error> for SubscribeError {
    fn from(e: sqlx::Error) -> Self {
        Self::DatabaseError(e)
    }
}

impl From<StoreTokenError> for SubscribeError {
    fn from(e: StoreTokenError) -> Self {
        Self::StoreTokenError(e)
    }
}

impl From<String> for SubscribeError {
    fn from(e: String) -> Self {
        Self::ValidationError(e)
    }
}

We can now clean up our request handler by removing all those match / if fallible_function().is_err() lines:

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    let new_subscriber = form.0.try_into()?;
    let mut transaction = pool.begin().await?;
    let subscriber_id = insert_subscriber(/* */).await?;
    let subscription_token = generate_subscription_token();
    store_token(/* */).await?;
    transaction.commit().await?;
    send_confirmation_email(/* */).await?;
    Ok(HttpResponse::Ok().finish())
}

The code compiles, but one of our tests is failing:

thread 'subscriptions::subscribe_returns_a_400_when_fields_are_present_but_invalid' 
panicked at 'assertion failed: `(left == right)`
  left: `400`,
 right: `500`: The API did not return a 400 Bad Request when the payload was empty name.'

We are still using the default implementation of ResponseError — it always returns 500.
This is where enums shine: we can use a match statement for control flow — we behave differently depending on the failure scenario we are dealing with.

//! src/routes/subscriptions.rs
use actix_web::http::StatusCode; 
// [...]

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST,
            SubscribeError::DatabaseError(_)
            | SubscribeError::StoreTokenError(_)
            | SubscribeError::SendEmailError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

The test suite should pass again.

The Error Type Is Not Enough

What about our logs?
Let’s look again:

export RUST_LOG="sqlx=error,info"
export TEST_LOG=enabled
cargo t subscribe_fails_if_there_is_a_fatal_database_error | bunyan
...
 INFO: [HTTP REQUEST - END] 
    exception.details="StoreTokenError(
            A database failure was encountered while trying to 
            store a subscription token.
            
        Caused by:
            error returned from database: column 'subscription_token' 
            of relation 'subscription_tokens' does not exist)"
    exception.message="Failed to create a new subscriber.",
    target=tracing_actix_web::root_span_builder, 
    http.status_code=500

We are still getting a great representation for the underlying StoreTokenError in exception.details, but it shows that we are now using the derived Debug implementation for SubscribeError. No loss of information though.
The same cannot be said for exception.message — no matter the failure mode, we always get Failed to create a new subscriber. Not very useful.

Let’s refine our Debug and Display implementations:

//! src/routes/subscriptions.rs
// [...]

// Remember to delete `#[derive(Debug)]`!
impl std::fmt::Debug for SubscribeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        error_chain_fmt(self, f)
    }
}

impl std::error::Error for SubscribeError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            // &str does not implement `Error` - we consider it the root cause
            SubscribeError::ValidationError(_) => None,
            SubscribeError::DatabaseError(e) => Some(e),
            SubscribeError::StoreTokenError(e) => Some(e),
            SubscribeError::SendEmailError(e) => Some(e),
        }
    }
}

impl std::fmt::Display for SubscribeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SubscribeError::ValidationError(e) => write!(f, "{}", e),
            // What should we do here?
            SubscribeError::DatabaseError(_) => write!(f, "???"),
            SubscribeError::StoreTokenError(_) => write!(
                f,
                "Failed to store the confirmation token for a new subscriber."
            ),
            SubscribeError::SendEmailError(_) => {
                write!(f, "Failed to send a confirmation email.")
            },
        }
    }
}

Debug is easily sorted: we implemented the Error trait for SubscribeError, including source, and we can use again the helper function we wrote earlier for StoreTokenError.

We have a problem when it comes to Display — the same DatabaseError variant is used for errors encountered when:

  • acquiring a new Postgres connection from the pool;
  • inserting a subscriber in the subscribers table;
  • committing the SQL transaction.

When implementing Display for SubscribeError we have no way to distinguish which of those three cases we are dealing with — the underlying error type is not enough.
Let’s disambiguate by using a different enum variant for each operation:

//! src/routes/subscriptions.rs
// [...]

pub enum SubscribeError {
    // [...]
    // No more `DatabaseError`
    PoolError(sqlx::Error),
    InsertSubscriberError(sqlx::Error),
    TransactionCommitError(sqlx::Error),
}

impl std::error::Error for SubscribeError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            //  [...]
            // No more DatabaseError
            SubscribeError::PoolError(e) => Some(e),
            SubscribeError::InsertSubscriberError(e) => Some(e),
            SubscribeError::TransactionCommitError(e) => Some(e),
            // [...]
        }
    }
}

impl std::fmt::Display for SubscribeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            // [...]
            SubscribeError::PoolError(_) => {
                write!(f, "Failed to acquire a Postgres connection from the pool")
            }
            SubscribeError::InsertSubscriberError(_) => {
                write!(f, "Failed to insert new subscriber in the database.")
            }
            SubscribeError::TransactionCommitError(_) => {
                write!(
                    f,
                    "Failed to commit SQL transaction to store a new subscriber."
                )
            }
        }
    }
}

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST,
            SubscribeError::PoolError(_)
            | SubscribeError::TransactionCommitError(_)
            | SubscribeError::InsertSubscriberError(_)
            | SubscribeError::StoreTokenError(_)
            | SubscribeError::SendEmailError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

DatabaseError is used in one more place:

//! src/routes/subscriptions.rs
// [..]

impl From<sqlx::Error> for SubscribeError {
    fn from(e: sqlx::Error) -> Self {
        Self::DatabaseError(e)
    }
}

The type alone is not enough to distinguish which of the new variants should be used; we cannot implement From for sqlx::Error.
We have to use map_err to perform the right conversion in each case.

//! src/routes/subscriptions.rs
// [..]

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    // [...]
    let mut transaction = pool.begin().await.map_err(SubscribeError::PoolError)?;
    let subscriber_id = insert_subscriber(&mut transaction, &new_subscriber)
        .await
        .map_err(SubscribeError::InsertSubscriberError)?;
    // [...]
    transaction
        .commit()
        .await
        .map_err(SubscribeError::TransactionCommitError)?;
    // [...]
}

The code compiles and exception.message is useful again:

...
 INFO: [HTTP REQUEST - END] 
    exception.details="Failed to store the confirmation token 
        for a new subscriber.

        Caused by:
            A database failure was encountered while trying to store 
            a subscription token.
        Caused by:
            error returned from database: column 'subscription_token'
            of relation 'subscription_tokens' does not exist"
    exception.message="Failed to store the confirmation token for a new subscriber.",
    target=tracing_actix_web::root_span_builder, 
    http.status_code=500

Removing The Boilerplate With thiserror

It took us roughly 90 lines of code to implement SubscribeError and all the machinery that surrounds it in order to achieve the desired behaviour and get useful diagnostic in our logs.
That is a lot of code, with a ton of boilerplate (e.g. source‘s or From implementations).
Can we do better?

Well, I am not sure we can write less code, but we can find a different way out: we can generate all that boilerplate using a macro!

As it happens, there is already a great crate in the ecosystem for this purpose: thiserror.
Let’s add it to our dependencies:

#! Cargo.toml

[dependencies]
# [...]
thiserror = "1"

It provides a derive macro to generate most of the code we just wrote by hand.
Let’s see it in action:

//! src/routes/subscriptions.rs
// [...]

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("{0}")]
    ValidationError(String),
    #[error("Failed to acquire a Postgres connection from the pool")]
    PoolError(#[source] sqlx::Error),
    #[error("Failed to insert new subscriber in the database.")]
    InsertSubscriberError(#[source] sqlx::Error),
    #[error("Failed to store the confirmation token for a new subscriber.")]
    StoreTokenError(#[from] StoreTokenError),
    #[error("Failed to commit SQL transaction to store a new subscriber.")]
    TransactionCommitError(#[source] sqlx::Error),
    #[error("Failed to send a confirmation email.")]
    SendEmailError(#[from] reqwest::Error),
}

// We are still using a bespoke implementation of `Debug`
// to get a nice report using the error source chain
impl std::fmt::Debug for SubscribeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        error_chain_fmt(self, f)
    }
}

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    // We no longer have `#[from]` for `ValidationError`, so we need to 
    // map the error explicitly
    let new_subscriber = form.0.try_into().map_err(SubscribeError::ValidationError)?;
    // [...]
}

We cut it down to 21 lines — not bad!
Let’s break down what is happening now.

thiserror::Error is a procedural macro used via a #[derive(/* */)] attribute.
We have seen and used these before — e.g. #[derive(Debug)] or #[derive(serde::Serialize)].
The macro receives, at compile-time, the definition of SubscribeError as input and returns another stream of tokens as output — it generates new Rust code, which is then compiled into the final binary.

Within the context of #[derive(thiserror::Error)] we get access to other attributes to achieve the behaviour we are looking for:

  • #[error(/* */)] defines the Display representation of the enum variant it is applied to. E.g. Display will return Failed to send a confirmation email. when invoked on an instance of SubscribeError::SendEmailError. You can interpolate values in the final representation — e.g. the {0} in #[error("{0}")] on top of ValidationError is referring to the wrapped String field, mimicking the syntax to access fields on tuple structs (i.e. self.0).

  • #[source] is used to denote what should be returned as root cause in Error::source;

  • #[from] automatically derives an implementation of From for the type it has been applied to into the top-level error type (e.g. impl From<StoreTokenError> for SubscribeError {/* */}). The field annotated with #[from] is also used as error source, saving us from having to use two annotations on the same field (e.g. #[source] #[from] reqwest::Error).

I want to call your attention on a small detail: we are not using either #[from] or #[source] for the ValidationError variant. That is because String does not implement the Error trait, therefore it cannot be returned in Error::source — the same limitation we encountered before when implementing Error::source manually, which led us to return None in the ValidationError case.

Avoid «Ball Of Mud» Error Enums

In SubscribeError we are using enum variants for two purposes:

  • Determine the response that should be returned to the caller of our API (ResponseError);
  • Provide relevant diagnostic (Error::source, Debug, Display).

SubscribeError, as currently defined, exposes a lot of the implementation details of subscribe: we have a variant for every fallible function call we make in the request handler!
It is not a strategy that scales very well.

We need to think in terms of abstraction layers: what does a caller of subscribe need to know?

They should be able to determine what response to return to a user (via ResponseError). That’s it.
The caller of subscribe does not understand the intricacies of the subscription flow: they don’t know enough about the domain to behave differently for a SendEmailError compared to a TransactionCommitError (by design!). subscribe should return an error type that speaks at the right level of abstraction.

The ideal error type would look like this:

//! src/routes/subscriptions.rs 

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("{0}")]
    ValidationError(String),
    #[error(/* */)]
    UnexpectedError(/* */),
}

ValidationError maps to a 400 Bad Request, UnexpectedError maps to an opaque 500 Internal Server Error.

What should we store in the UnexpectedError variant?
We need to map multiple error types into it — sqlx::Error, StoreTokenError, reqwest::Error.
We do not want to expose the implementation details of the fallible routines that get mapped to UnexpectedError by subscribe — it must be opaque.

We bumped into a type that fulfills those requirements when looking at the Error trait from Rust’s standard library: Box<dyn std::error::Error>8

Let’s give it a go:

//! src/routes/subscriptions.rs 

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("{0}")]
    ValidationError(String),
    // Transparent delegates both `Display`'s and `source`'s implementation
    // to the type wrapped by `UnexpectedError`.
    #[error(transparent)]
    UnexpectedError(#[from] Box<dyn std::error::Error>),
}

We can still generate an accurate response for the caller:

//! src/routes/subscriptions.rs 
// [...]

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST,
            SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

We just need to adapt subscribe to properly convert our errors before using the ? operator:

//! src/routes/subscriptions.rs 
// [...]

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    // [...]
    let mut transaction = pool
        .begin()
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    let subscriber_id = insert_subscriber(/* */)
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    // [...]
    store_token(/* */)
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    transaction
        .commit()
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    send_confirmation_email(/* */)
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    // [...]
}

There is some code repetition, but let it be for now.
The code compiles and our tests pass as expected.

Let’s change the test we have used so far to check the quality of our log messages: let’s trigger a failure in insert_subscriber instead of store_token.

//! tests/api/subscriptions.rs
// [...] 

#[tokio::test]
async fn subscribe_fails_if_there_is_a_fatal_database_error() {
    // [...]
    // Break `subscriptions` instead of `subscription_tokens` 
    sqlx::query!("ALTER TABLE subscriptions DROP COLUMN email;",)
        .execute(&app.db_pool)
        .await
        .unwrap();
    
    // [..]
}

The test passes, but we can see that our logs have regressed:

 INFO: [HTTP REQUEST - END] 
    exception.details: 
        "error returned from database: column 'email' of 
         relation 'subscriptions' does not exist"
    exception.message: 
        "error returned from database: column 'email' of 
         relation 'subscriptions' does not exist"

We do not see a cause chain anymore.
We lost the operator-friendly error message that was previously attached to the InsertSubscriberError via thiserror:

//! src/routes/subscriptions.rs
// [...]

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("Failed to insert new subscriber in the database.")]
    InsertSubscriberError(#[source] sqlx::Error),
    // [...]
}

That is to be expected: we are forwarding the raw error now to Display (via #[error(transparent)]), we are not attaching any additional context to it in subscribe.
We can fix it — let’s add a new String field to UnexpectedError to attach contextual information to the opaque error we are storing:

//! src/routes/subscriptions.rs
// [...]

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("{0}")]
    ValidationError(String),
    #[error("{1}")]
    UnexpectedError(#[source] Box<dyn std::error::Error>, String),
}

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            // [...]
            // The variant now has two fields, we need an extra `_`
            SubscribeError::UnexpectedError(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

We need to adjust our mapping code in subscribe accordingly — we will reuse the error descriptions we had before refactoring SubscribeError:

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    // [..]
    let mut transaction = pool.begin().await.map_err(|e| {
        SubscribeError::UnexpectedError(
            Box::new(e),
            "Failed to acquire a Postgres connection from the pool".into(),
        )
    })?;
    let subscriber_id = insert_subscriber(/* */)
        .await
        .map_err(|e| {
            SubscribeError::UnexpectedError(
                Box::new(e),
                "Failed to insert new subscriber in the database.".into(),
            )
        })?;
    // [..]
    store_token(/* */)
        .await
        .map_err(|e| {
            SubscribeError::UnexpectedError(
                Box::new(e),
                "Failed to store the confirmation token for a new subscriber.".into(),
            )
        })?;
    transaction.commit().await.map_err(|e| {
        SubscribeError::UnexpectedError(
            Box::new(e),
            "Failed to commit SQL transaction to store a new subscriber.".into(),
        )
    })?;
    send_confirmation_email(/* */)
        .await
        .map_err(|e| {
            SubscribeError::UnexpectedError(
                Box::new(e), 
                "Failed to send a confirmation email.".into()
            )
        })?;
    // [..]
}

It is somewhat ugly, but it works:

 INFO: [HTTP REQUEST - END] 
    exception.details=
        "Failed to insert new subscriber in the database.
        
        Caused by:
            error returned from database: column 'email' of 
             relation 'subscriptions' does not exist"
    exception.message="Failed to insert new subscriber in the database."

Using anyhow As Opaque Error Type

We could spend more time polishing the machinery we just built, but it turns out it is not necessary: we can lean on the ecosystem, again.
The author of thiserror9 has another crate for us — anyhow.

#! Cargo.toml

[dependencies]
# [...]
anyhow = "1"

The type we are looking for is anyhow::Error. Quoting the documentation:

anyhow::Error is a wrapper around a dynamic error type.
anyhow::Error works a lot like Box<dyn std::error::Error>, but with these differences:

  • anyhow::Error requires that the error is Send, Sync, and 'static.
  • anyhow::Error guarantees that a backtrace is available, even if the underlying error type does not provide one.
  • anyhow::Error is represented as a narrow pointer — exactly one word in size instead of two.

The additional constraints (Send, Sync and 'static) are not an issue for us.
We appreciate the more compact representation and the option to access a backtrace, if we were to be interested in it.

Let’s replace Box<dyn std::error::Error> with anyhow::Error in SubscribeError:

//! src/routes/subscriptions.rs
// [...]

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("{0}")]
    ValidationError(String),
    #[error(transparent)]
    UnexpectedError(#[from] anyhow::Error),
}

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            // [...]
            // Back to a single field
            SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

We got rid of the second String field as well in SubscribeError::UnexpectedError — it is no longer necessary.
anyhow::Error provides the capability to enrich an error with additional context out of the box.

//! src/routes/subscriptions.rs
use anyhow::Context;
// [...]

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    // [...]
    let mut transaction = pool
        .begin()
        .await
        .context("Failed to acquire a Postgres connection from the pool")?;
    let subscriber_id = insert_subscriber(/* */)
        .await
        .context("Failed to insert new subscriber in the database.")?;
    // [..]
    store_token(/* */)
        .await
        .context("Failed to store the confirmation token for a new subscriber.")?;
    transaction
        .commit()
        .await
        .context("Failed to commit SQL transaction to store a new subscriber.")?;
    send_confirmation_email(/* */)
        .await
        .context("Failed to send a confirmation email.")?;
    // [...]
}

The context method is performing double duties here:

  • it converts the error returned by our methods into an anyhow::Error;
  • it enriches it with additional context around the intentions of the caller.

context is provided by the Context trait — anyhow implements it for Result10, giving us access to a fluent API to easily work with fallible functions of all kinds.

anyhow Or thiserror?

We have covered a lot of ground — time to address a common Rust myth:

anyhow is for applications, thiserror is for libraries.

It is not the right framing to discuss error handling.
You need to reason about intent.

Do you expect the caller to behave differently based on the failure mode they encountered?
Use an error enumeration, empower them to match on the different variants. Bring in thiserror to write less boilerplate.

Do you expect the caller to just give up when a failure occurs? Is their main concern reporting the error to an operator or a user?
Use an opaque error, do not give the caller programmatic access to the error inner details. Use anyhow or eyre if you find their API convenient.

The misunderstanding arises from the observation that most Rust libraries return an error enum instead of Box<dyn std::error::Error> (e.g. sqlx::Error).
Library authors cannot (or do not want to) make assumptions on the intent of their users. They steer away from being opinionated (to an extent) — enums give users more control, if they need it.
Freedom comes at a price — the interface is more complex, users need to sift through 10+ variants trying to figure out which (if any) deserve special handling.
Reason carefully about your usecase and the assumptions you can afford to make in order to design the most appropriate error type — sometimes Box<dyn std::error::Error> or anyhow::Error are the most appropriate choice, even for libraries.

Who Should Log Errors?

Let’s look again at the logs emitted when a request fails.

# sqlx logs are a bit spammy, cutting them out to reduce noise
export RUST_LOG="sqlx=error,info"
export TEST_LOG=enabled
cargo t subscribe_fails_if_there_is_a_fatal_database_error | bunyan

There are three error-level log records:

  • one emitted by our code in insert_subscriber
//! src/routes/subscriptions.rs 
// [...]

pub async fn insert_subscriber(/* */) -> Result<Uuid, sqlx::Error> {
    // [...]
    sqlx::query!(/* */)
        .execute(transaction)
        .await
        .map_err(|e| {
            tracing::error!("Failed to execute query: {:?}", e);
            e
        })?;
    // [...]
}
  • one emitted by actix_web when converting SubscribeError into an actix_web::Error;
  • one emitted by tracing_actix_web::TracingLogger, our telemetry middleware.

We do not need to see the same information three times — we are emitting unnecessary log records which, instead of helping, make it more confusing for operators to understand what is happening (are those logs reporting the same error? Am I dealing with three different errors?).

As a rule of thumb,

errors should be logged when they are handled.

If your function is propagating the error upstream (e.g. using the ? operator), it should not log the error. It can, if it makes sense, add more context to it.
If the error is propagated all the way up to the request handler, delegate logging to a dedicated middleware — tracing_actix_web::TracingLogger in our case.

The log record emitted by actix_web is going to be removed in the next release. Let’s ignore it for now.

Let’s review the tracing::error statements in our own code:

//! src/routes/subscriptions.rs
// [...]

pub async fn insert_subscriber(/* */) -> Result<Uuid, sqlx::Error> {
    // [...]
    sqlx::query!(/* */)
        .execute(transaction)
        .await
        .map_err(|e| {
            // This needs to go, we are propagating the error via `?`
            tracing::error!("Failed to execute query: {:?}", e);
            e
        })?;
    // [..]
}

pub async fn store_token(/* */) -> Result<(), StoreTokenError> {
    sqlx::query!(/* */)
        .execute(transaction)
        .await
        .map_err(|e| {
            // This needs to go, we are propagating the error via `?`
            tracing::error!("Failed to execute query: {:?}", e);
            StoreTokenError(e)
        })?;
    Ok(())
}

Check the logs again to confirm they look pristine.

Summary

We used this chapter to learn error handling patterns «the hard way» — building an ugly but working prototype first, refining it later using popular crates from the ecosystem.
You should now have:

  • a solid grasp on the different purposes fulfilled by errors in an application;
  • the most appropriate tools to fulfill them.

Internalise the mental model we discussed (Location as columns, Purpose as rows):

Internal At the edge
Control Flow Types, methods, fields Status codes
Reporting Logs/traces Response body

Practice what you learned: we worked on the subscribe request handler, tackle confirm as an exercise to verify your understanding of the concepts we covered. Improve the response returned to the user when validation of form data fails.
You can look at the code in the GitHub repository as a reference implementation.

Some of the themes we discussed in this chapter (e.g. layering and abstraction boundaries) will make another appearance when talking about the overall layout and structure of our application. Something to look forward to!


Zero To Production In Rust is a hands-on introduction to backend development in Rust.
Subscribe to the newsletter to be notified when a new episode is published.

Click to expand!

Book — Table Of Contents

Click to expand!

The Table of Contents is provisional and might change over time. The draft below is the most accurate picture at this point in time.

  • Who Is This Book For
  • What Is This Book About
  1. Getting Started
    • Installing The Rust Toolchain
    • Project Setup
    • IDEs
    • Continuous Integration
  2. Our Driving Example
    • What Should Our Newsletter Do?
    • Working In Iterations
  3. Sign Up A New Subscriber
    • Choosing A Web Framework
    • Our First Endpoint: A Basic Health Check
    • Our First Integration Test
    • Reading Request Data
    • Adding A Database
    • Persisting A New Subscriber
  4. Telemetry
    • Unknown Unknowns
    • Observability
    • Logging
    • Instrumenting /POST subscriptions
    • Structured Logging
  5. Go Live
    • We Must Talk About Deployments
    • Choosing Our Tools
    • A Dockerfile For Our Application
    • Deploy To DigitalOcean Apps Platform
  6. Rejecting Invalid Subscribers #1
    • Requirements
    • First Implementation
    • Validation Is A Leaky Cauldron
    • Type-Driven Development
    • Ownership Meets Invariants
    • Panics
    • Error As Values — Result
  7. Reject Invalid Subscribers #2
    • Confirmation Emails
    • EmailClient, Our Email Delivery Component
    • Skeletons And Principles For A Maintainable Test Suite
    • Zero Downtime Deployments
    • Multi-step Database Migrations
    • Sending A Confirmation Email
    • Database Transactions
  8. Error Handling
    • What Is The Purpose Of Errors?
    • Error Reporting For Operators
    • Errors For Control Flow
    • Avoid «Ball Of Mud» Error Enums
    • Who Should Log Errors?
  9. Naive Newsletter Delivery
    • User Stories Are Not Set In Stone
    • Do Not Spam Unconfirmed Subscribers
    • All Confirmed Subscribers Receive New Issues
    • Implementation Strategy
    • Body Schema
    • Fetch Confirmed Subscribers List
    • Send Newsletter Emails
    • Validation Of Stored Data
    • Limitations Of The Naive Approach
  10. Securing Our API
    • Authentication
    • Password-based Authentication
    • Is it safe?
    • What Should We Do Next
  11. Fault-tolerant Newsletter Delivery

Понравилась статья? Поделить с друзьями:
  • Throw error laravel
  • Throw error js что это
  • Throw error javascript new error
  • Throw error haskell
  • Throw er unhandled error event node js