Как и многие языки программирования, 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
. Это позволяет удобно использовать любой тип строки в качестве пути к файлу.)
У нас есть три потенциальные ошибки, которые могут возникнуть:
- Проблема при открытии файла.
- Проблема при чтении данных из файла.
- Проблема при преобразовании данных в число.
Первые две проблемы определяются типом 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!
инкапсулирует сразу три вещи:
- Вариативный анализ.
- Поток выполнения.
- Преобразование типов ошибок.
Когда все эти три вещи объединены вместе, мы получаем код, который не обременен комбинаторами, вызовами 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 ErrorPoint
s
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.
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:
-
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. -
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 thenumber
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>
orResult<T, E>
. In these cases, you have a choice between a valid valueSome(T)
/Ok(T)
respectively or an invalid valueNone
/Error(E)
. GenerallyNone
serves as anull
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 inOk
.
Rust can work out from the return type thatparse
should convert toi32
.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 forResult<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 theFrom
trait.Box<Error>
works as it does because it implementsFrom
for all types implementingError
.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 intoBox<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
implementsError
sodescription()
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 withFrom
) and the
second?
will convert theParseFloatError
toMyError
.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
writeFrom
conversions for all the other error types that need to play nice
withMyError
— or you simply lean onBox<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
, notOption
, and this is
a feature, not a limitation.Option
has aok_or_else
which converts itself into aResult
.
For example, say we had aHashMap
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 withBox<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 toreturn SimpleError::new(s).into();
— return early with a conversion into
the receiving type.You need to use
BoxResult
for mixing theSimpleError
type with other errors, since
we can’t implementFrom
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. EditCargo.toml
and adderror-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
andError
.
It also by default implementsFrom
so strings can be converted into errors.Our first
src/main.rs
file looks like this. All the main program does is callrun
, print out any
errors, and end the program with a non-zero exit code. The macroerror_chain
generates all the
definitions needed, within anerror
module — in a larger program you would put this in its own file.
We need to bring everything inerror
back into global scope because our code will need to see
the generated traits. By default, there will be anError
struct and aResult
defined with that
error.Here we also ask for
From
to be implemented so thatstd::io::Error
will convert into
our error type usingforeign_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
convertstd::io::Error
into ourerror::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 anOption<String>
into aResult<String>
. There are twoOption
methods for doing this conversion, and I’ve picked the simplest one. OurError
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 theok_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 variantMsg
(when you sayError::from(str)
) and theforeign_links
has declaredIo
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 theerror_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, feedingErrorKind::NoArgument
aString
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 acatch
orexcept
block.In summary, error-chain creates a type
Error
for you, and definesResult<T>
to bestd::result::Result<T,Error>
.
Error
contains an enumErrorKind
and by default there is one variantMsg
for errors created from
strings. You define external errors withforeign_links
which does two things. First, it creates a new
ErrorKind
variant. Second, it definesFrom
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 toio::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
- 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
- Internal Errors
- Error Reporting For Operators
- Keeping Track Of The Error Root Cause
- The
Error
Trait- Trait Objects
Error::source
- Errors For Control Flow
- Layering
- Modelling Errors as Enums
- The Error Type Is Not Enough
- Removing The Boilerplate With
thiserror
- Avoid «Ball Of Mud» Error Enums
- Using
anyhow
As Opaque Error Type anyhow
Orthiserror
?
- Using
- Who Should Log Errors?
- 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
andactix-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 fromstd::error
throughactix_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 withinto()
.
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
andDisplay
), 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 enum
s 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 theDisplay
representation of the enum variant it is applied to. E.g.Display
will returnFailed to send a confirmation email.
when invoked on an instance ofSubscribeError::SendEmailError
. You can interpolate values in the final representation — e.g. the{0}
in#[error("{0}")]
on top ofValidationError
is referring to the wrappedString
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 inError::source
; -
#[from]
automatically derives an implementation ofFrom
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 thiserror
9 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 likeBox<dyn std::error::Error>
, but with these differences:
anyhow::Error
requires that the error isSend
,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 Result
10, 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 convertingSubscribeError
into anactix_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
- Getting Started
- Installing The Rust Toolchain
- Project Setup
- IDEs
- Continuous Integration
- Our Driving Example
- What Should Our Newsletter Do?
- Working In Iterations
- 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
- Telemetry
- Unknown Unknowns
- Observability
- Logging
- Instrumenting /POST subscriptions
- Structured Logging
- Go Live
- We Must Talk About Deployments
- Choosing Our Tools
- A Dockerfile For Our Application
- Deploy To DigitalOcean Apps Platform
- Rejecting Invalid Subscribers #1
- Requirements
- First Implementation
- Validation Is A Leaky Cauldron
- Type-Driven Development
- Ownership Meets Invariants
- Panics
- Error As Values —
Result
- 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
- 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?
- 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
- Securing Our API
- Authentication
- Password-based Authentication
- Is it safe?
- What Should We Do Next
- Fault-tolerant Newsletter Delivery