Как и многие языки программирования, 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.
Перевод | Автор оригинала: Stefan Baumgartner
Я начал читать университетские лекции по Rust, а также проводить семинары и тренинги. Одной из частей, которая превратилась из пары слайдов в полноценный сеанс, было все, что касалось обработки ошибок в Rust, поскольку это невероятно хорошо!
Это не только помогает сделать невозможные состояния невозможными, но и содержит так много деталей, что обработка ошибок — как и все в Rust — становится очень эргономичной и простой для чтения и использования.
Делаем невозможные состояния невозможными
В Rust нет таких вещей, как undefined или null, и у вас нет исключений, как вы знаете из языков программирования, таких как Java или C#. Вместо этого вы используете встроенные перечисления для моделирования состояния:
- Option для привязок, которые могут не иметь значения (например, Some(x) или None)
- Result<T, E> для результатов операций, которые могут привести к ошибке (например, Ok(val) vs Err(error))
Разница между ними очень тонкая и во многом зависит от семантики вашего кода. Однако способ работы обоих перечислений очень похож. На мой взгляд, наиболее важным является то, что оба типа просят вас разобраться с ними. Либо явно обрабатывая все состояния, либо явно игнорируя их.
В этой статье я хочу сосредоточиться на Result<T, E>, поскольку он действительно содержит ошибки.
Result<T, E> — это перечисление с двумя вариантами:
enum Result<T, E> {
Ok(T),
Err(E),
}
T, E являются дженериками. T может быть любым значением, E может быть любой ошибкой. Два варианта Ok и Err доступны во всем мире.
Используйте Result<T, E>
, когда у вас есть что-то, что может пойти не так. Ожидается, что операция будет успешной, но могут быть случаи, когда это не удается. Когда у вас есть значение Result, вы можете сделать следующее:
- Разберитесь с государствами!
- Игнорируй это
- Паника!
- Используйте запасные варианты
- Распространять ошибки
Давайте подробно рассмотрим, что я имею в виду.
Обработка состояния ошибки
Напишем небольшой фрагмент, в котором мы хотим прочитать строку из файла. Это требует от нас
- Прочтите файл
- Прочтите строку из этого файла.
Обе операции могут вызвать ошибку std::io::Error, потому что может произойти что-то непредвиденное (файл не существует, его нельзя прочитать и т.д.). Таким образом, функция, которую мы пишем, может возвращать либо String, либо io::Error.
use std::io;
use std::fs::File;
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let f = File::open(path);
/* 1 */
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut s = String::new();
/* 2 */
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(err) => Err(err),
}
}
Вот что происходит:
- Когда мы открываем файл по пути, он либо может вернуть дескриптор файла для работы с Ok(файл), либо вызывает ошибку Err(e). При использовании match f мы вынуждены иметь дело с двумя возможными состояниями. Либо мы назначаем дескриптор файла f (обратите внимание на затенение f), либо возвращаемся из функции, возвращая ошибку. Оператор return здесь важен, поскольку мы хотим выйти из функции.
- Затем мы хотим прочитать содержимое только что созданной строки s. Он снова может либо завершиться успешно, либо выдать ошибку. Функция f.read_to_string возвращает длину прочитанных байтов, поэтому мы можем спокойно игнорировать значение и вернуть Ok(s) с прочитанной строкой. В противном случае мы просто возвращаем ту же ошибку. Обратите внимание, что я не ставил точку с запятой в конце выражения соответствия. Поскольку это выражение, это то, что мы возвращаем из функции в этот момент.
Это может показаться очень многословным (это…), но вы видите два очень важных аспекта обработки ошибок:
- В обоих случаях ожидается, что вы будете иметь дело с двумя возможными состояниями. Вы не можете продолжить, если ничего не сделаете
- Такие функции, как затенение (привязка значения к существующему имени) и выражения, позволяют легко читать и использовать даже подробный код.
Операцию, которую мы только что сделали, часто называют разворачиванием. Потому что вы разворачиваете значение, заключенное внутри перечисления.
Кстати о разворачивании…
Игнорировать ошибки
Если вы абсолютно уверены, что ваша программа не потерпит неудачу, вы можете просто .unwrap() свои значения, используя встроенные функции:
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path).unwrap(); /* 1 */
let mut s = String::new();
f.read_to_string(&mut s).unwrap(); /* 1 */
Ok(s) /* 2 */
}
Вот что происходит:
- Во всех случаях, которые могут вызвать ошибку, мы вызываем unwrap(), чтобы получить значение
- Оборачиваем результат в вариант Ok, который возвращаем. Мы могли бы просто вернуть s и оставить Result<T, E> в сигнатуре нашей функции. Мы сохраняем его, потому что снова используем его в других примерах.
Сама функция unwrap() очень похожа на то, что мы делали на первом шаге, когда мы работали со всеми состояниями:
// result.rs
impl<T, E: fmt::Debug> Result<T, E> {
// ...
pub fn unwrap(&self) -> T {
match self {
Ok(t) => t,
Err(e) => unwrap_failed("called `Result::unwrap()` on an `Err` value", &e),
}
}
// ...
}
unwrap_failed — это ярлык к панике! макрос. Это означает, что если вы используете .unwrap() и не получите успешного результата, ваше программное обеспечение выйдет из строя.
Вы можете спросить себя: чем это отличается от ошибок, которые просто приводят к сбою программного обеспечения на других языках программирования? Ответ прост: вы должны четко заявить об этом. Rust требует, чтобы вы что-то делали, даже если он явно позволяет паниковать.
Существует множество различных функций .unwrap_, которые можно использовать в различных ситуациях. Мы рассмотрим один или два из них дальше.
Паника!
Говоря о панике, вы также можете паниковать своим собственным паническим сообщением:
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path).expect("Error opening file");
let mut s = String::new();
f.read_to_string(&mut s).unwrap("Error reading file to string");
Ok(s)
}
То, что делает .expect (…), очень похоже на unwrap()
impl<T, E: fmt::Debug> Result<T, E> {
// ...
pub fn expect(self, msg: &str) -> T {
match self {
Ok(t) => t,
Err(e) => unwrap_failed(msg, &e),
}
}
}
Но у вас в руках свои панические сообщения, которые могут вам понравиться!
Но даже если мы всегда будем откровенны, мы можем захотеть, чтобы наше программное обеспечение не паниковало и не падало всякий раз, когда мы сталкиваемся с ошибкой. Возможно, мы захотим сделать что-нибудь полезное, например, предоставить запасные варианты или… ну… собственноручно обработать ошибки.
Резервные значения
Rust имеет возможность использовать значения по умолчанию в своих перечислениях Result (и Option).
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path).expect("Error opening file");
let mut s = String::new();
f.read_to_string(&mut s).unwrap_or("admin"); /* 1 */
Ok(s)
}
- «admin» может быть не лучшим вариантом для имени пользователя, но идею вы поняли. Вместо сбоя мы возвращаем значение по умолчанию в случае результата ошибки. Метод .unwrap_or_else принимает закрытие для более сложных значений по умолчанию.
Так-то лучше! Тем не менее, то, что мы до сих пор узнали, — это компромисс между слишком подробным описанием или допуском явных сбоев или, возможно, наличием резервных значений. Но можем ли мы получить и то, и другое? Краткий код и безопасность от ошибок? Мы можем!
Распространение ошибки
Одна из функций, которые мне больше всего нравятся в типах результатов Rust, — это возможность распространения ошибки. Обе функции, которые могут вызвать ошибку, имеют один и тот же тип ошибки: io::Error. Мы можем использовать оператор вопросительного знака после каждой операции, чтобы писать код для счастливого пути (только успешные результаты) и возвращать результаты ошибок, если что-то пойдет не так:
fn read_username_from_file(path: &str) -> Result<String, io::Error> {
let mut f = File::open(path)?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
В этом фрагменте f — обработчик файла, f.read_to_string сохраняет в s. Если что-то пойдет не так, мы вернемся из функции с Err(io::Error). Краткий код, но мы имеем дело с ошибкой на один уровень выше:
fn main() {
match read_username_from_file("user.txt") {
Ok(username) => println!("Welcome {}", username),
Err(err) => eprintln!("Whoopsie! {}", err)
};
}
Что в этом хорошего?
- Мы по-прежнему недвусмысленны, мы должны что-то делать! Вы все еще можете найти все места, где могут произойти ошибки!
- Мы можем писать краткий код, как если бы ошибок не было. Ошибки еще предстоит исправить! Либо от нас, либо от пользователей нашей функции.
Оператор вопросительного знака также работает с Option, это также позволяет создать действительно красивый и элегантный код!
Распространение различных ошибок
Проблема в том, что подобные методы работают только при одинаковом типе ошибок. Если у нас есть два разных типа ошибок, мы должны проявить творческий подход. Посмотрите на эту слегка измененную функцию, в которой мы открываем и читаем файлы, а затем анализируем прочитанное содержимое в u64
fn read_number_from_file(filename: &str) -> Result<u64, ???> {
let mut file = File::open(filename)?; /* 1 */
let mut buffer = String::new();
file.read_to_string(&mut buffer)?; /* 1 */
let parsed: u64 = buffer.trim().parse()?; /* 2 */
Ok(parsed)
}
- Эти две точки могут вызвать io::Error, как мы знаем из предыдущих примеров.
- Однако эта операция может вызвать ошибку ParseIntError.
Проблема в том, что мы не знаем, какую ошибку получаем во время компиляции. Это полностью зависит от запуска нашего кода. Мы можем обрабатывать каждую ошибку с помощью выражений сопоставления и возвращать собственный тип ошибки. Что верно, но снова делает наш код многословным. Или мы готовимся к «вещам, которые происходят во время выполнения»!
Ознакомьтесь с нашей слегка измененной функцией
use std::error;
fn read_number_from_file(filename: &str) -> Result<u64, Box<dyn error::Error>> {
let mut file = File::open(filename)?; /* 1 */
let mut buffer = String::new();
file.read_to_string(&mut buffer)?; /* 1 */
let parsed: u64 = buffer.trim().parse()?; /* 2 */
Ok(parsed)
}
Вот что происходит:
- Вместо того, чтобы возвращать реализацию ошибки, мы сообщаем Rust, что идет что-то, реализующее трэйту ошибки Error.
- Поскольку мы не знаем, что это может быть во время компиляции, мы должны сделать его типажным объектом: dyn std::error::Error.
- А поскольку мы не знаем, насколько это будет большим, мы упаковываем его в Box. Умный указатель, указывающий на данные, которые в конечном итоге будут в куче
Box включает динамическую отправку в Rust: возможность динамически вызывать функцию, которая неизвестна во время компиляции. Для этого Rust представляет виртуальную таблицу, в которой хранятся указатели на фактические реализации. Во время выполнения мы используем эти указатели для вызова соответствующих реализаций функций.
И теперь наш код снова лаконичен, и нашим пользователям приходится иметь дело с возможной ошибкой.
Первый вопрос, который я задаю, когда показываю это людям на моих курсах: но можем ли мы в конечном итоге проверить, какой тип ошибки произошел? Мы можем! Метод downcast_ref() позволяет нам вернуться к исходному типу.
fn main() {
match read_number_from_file("number.txt") {
Ok(v) => println!("Your number is {}", v),
Err(err) => {
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
eprintln!("Error during IO! {}", io_err)
} else if let Some(pars_err) = err.downcast_ref::<ParseIntError>() {
eprintln!("Error during parsing {}", pars_err)
}
}
};
}
Отлично!
Пользовательские ошибки
Он становится еще лучше и гибче, если вы хотите создавать собственные ошибки для своих операций. Чтобы использовать настраиваемые ошибки, ваши структуры ошибок должны реализовывать трэйту std::error::Error. Это может быть классическая структура, кортежная структура или даже единичная структура.
Вам не нужно реализовывать какие-либо функции std::error::Error, но вам нужно реализовать как трейт Debug, так и свойство Display. Причина в том, что ошибки хотят где-то печатать. Вот как выглядит пример:
#[derive(Debug)] /* 1 */
pub struct ParseArgumentsError(String); /* 2 */
impl std::error::Error for ParseArgumentsError {} /* 3 */
/* 4 */
impl Display for ParseArgumentsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
- Мы выводим трэйту Debug.
- Наша ParseArgumentsError — это структура кортежа с одним элементом: настраиваемое сообщение.
- Реализуем std::error::Error для ParseArgumentsError. Больше ничего реализовывать не нужно
- Мы реализуем Display, где выводим единственный элемент нашего кортежа.
И это все!
Anyhow…
Поскольку многие вещи, которые вы только что выучили, очень распространены, конечно, существуют крэйти, которые абстрагируют большую часть из них. Фантастический крэйт Anyhow Crate — один из них, который дает вам возможность обрабатывать ошибки на основе объектов с помощью удобных макросов и типов.
Нижняя линия
Это очень быстрое руководство по обработке ошибок в Rust. Конечно, это еще не все, но это должно помочь вам начать! Это также моя первая техническая статья по Rust, и я надеюсь, что ее будет еще много. Дайте мне знать, если вам это понравилось, и если вы обнаружите какие-либо… ха-ха… ошибки (ба-дум-ц 🥁), я просто напишу твит.
Search code, repositories, users, issues, pull requests…
Provide feedback
Saved searches
Use saved searches to filter your results more quickly
Sign up
- 8550 words
- 43 min
This article is a sample from Zero To Production In Rust, a hands-on introduction to backend development in Rust.
You can get a copy of the book at zero2prod.com.
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:\n\t{}", 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:\n\t{}", 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!
This article is a sample from Zero To Production In Rust, a hands-on introduction to backend development in Rust.
You can get a copy of the book at zero2prod.com.
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
Nathan
Posted on
• Updated on
Rust community constantly discusses about error handling.. In this article I will try to explain what is it then why, and how we should use it.
Purpose of Error Handling
Error handling is a process that helps to identify, debug, and resolve errors that occur during the execution of a program.
It helps to ensure the smooth functioning of the program by preventing errors from occurring and allows the program to continue running in an optimal state.
Error handling also allows users to be informed of any problems that may arise and take corrective action to prevent the errors from happening again in the future.
What is a Result?
Result is a built-in enum in the Rust standard library.
It has two variants Ok(T) and Err(E).
Result should be used as a return type for a function that can encounter error situations.
Ok value is return in case of success or an Err value in case of an error.
Implementation of Result in a function.
What is Error Handling
Sometimes we are using functions that can fail, for example calling an endpoint from an API or searching a file. These type of function can encounter errors (in our case the API is not reachable or the file is not existing).
There are similar scenarios where we are using Error Handling.
Explained Step by Step
- A Result is the result of the read username from file function.
It follows that the function’s returned value will either be an Ok that contains a String or an Err that contains an instance of io::Error.
There is a call to «File::open» inside of read username from file, which returns a Result type.
- It can return an Ok
- It can return an Err
Then the code calls a match to check the result of the function and return the value inside the ok in the case the function was successful or return the Error value.
In the second function read_to_string, the same principle is applied, but in this case we did not use the keyword return as you can see, and we finally return either an OK or an Err.
So you may ask: On every result type I have to write all these Match block?
So hopefully there is a shortcut
What is the Question Mark- Propagation Error?
According to the rust lang book:
The question mark operator (?) unwraps valid values or returns erroneous values, propagating them to the calling function. It is a unary postfix operator that can only be applied to the types Result and Option.
Let’s me explain it.
Question mark (?) in Rust is used to indicate a Result type. It is used to return an error value if the operation cannot be completed.
For example, in our function that reads a file, it can return a Result type, where the question mark indicates that an error might be returned if the file cannot be read, or in the other hand the final result.
In other words, used to short-circuit a chain of computations and return early if a condition is not met.
fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("username.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
Enter fullscreen mode
Exit fullscreen mode
Every time you see a ?, that’s a possible early return from the function in case of Error, else , f will hold the file handle the Ok contained and execution of the function continues (similary to unwrap function).
Why use crates for Handle errors?
Standard library does not provide all solutions for Error Handling..
In fact, different errors may be returned by the same function, making it increasingly difficult to handle them precisely.
Personal anecdote, in our company we developed Cherrybomb an API security tool written in Rust, and we need to re-write a good part of it to have a better errors handling.
For example:
Or the same message error can be displayed multiples times.
This is why we need to define our own custom Error enum.
Then our function will look like:
Customize Errors
Thiserror focuses on creating structured errorsand has only one trait that can be used to define new errors:
Thiserror is an error-handling library for Rust that provides a powerful yet concise syntax to create custom error types.
In the cargo toml:
[dependencies]
thiserror = "1.0"
It allows developers to create custom error types and handlers without having to write a lot of boilerplate code.
Thank to thiserror crate, we can customize our error messages.
It also provides features to automatically convert between custom error types and the standard error type. We will see it in the next Chapter with Dynamic Error.
- Create new errors through #[derive(Error)].
- Enums, structs with named fields, tuple structs, and unit structs are all possible.
- A Display impl is generated for your error if you provide #[error(«…»)] messages on the struct or each variant of your enum and support string interpolation.
Example taken from docs.rs:
Dealing Dynamic Errors handling
If you want to be able to use?, your Error type must implement the From trait for the error types of your dependencies. Your program or library may use many dependencies, each of which has its own error you have two different structs of custom error, and we call a function that return one specific type.
For example:
So when we call our main function that return a ErrorA
type, we encounter the following error:
So one of the solution is to implement the trait From<ErrorB>
for the struct ErrorA
.
Our code looks like this now:
Another solution to this problem is to return dynamic errors.
To handle dynamic errors in Rust, in the case of an Err value, you can use the box operator to return the error as a Box (a trait object of the Error trait). This allows the error type to be determined at runtime, rather than at compile time, making it easier to work with errors of different types.
The Box can then be used to store any type of Error, including those from external libraries or custom errors. The Box can then be used to propagate the Error up the call stack, allowing for appropriate handling of the error at each stage.
Thiserror crate
In order to have a code clearer and soft let’s use thiserror crate.
The thiserror
crate can help handle dynamic errors in Rust by allowing the user to define custom error types. It does this through the #[derive(thiserror::Error)]
macro. This macro allows the user to define a custom error type with a specific set of parameters, such as an error code, a message, and the source of the error. The user can then use this error type to return an appropriate error value in the event of a dynamic error. Additionally, the thiserror
crate also provides several helpful methods, such as display_chain
, which can be used to chain together multiple errors into a single error chain.
In the following we have created our error type ErrorB
, then we used the From trait to convert from ErrorB
errors into our custom ErrorA
error type. If a dynamic error occurs, you can create a new instance of your error type and return it to the caller. See function returns_error_a()
in line 13.
Anyhow crate
anyhow was written by the same author, dtolnay, and released in the same week as thiserror.
The anyhow can be used to return errors of any type that implement the std::error::Error
trait and will display a nicely formatted error message if the program crashes.
The most common way to use the crate is to wrap your code in a Result type. This type is an alias for the std::result::Result<T, E>
type, and it allows you to handle success or failure cases separately.
When an error occurs,for example you can use the context()
method to provide more information about the error, or use the with_chain() method to chain multiple errors together.
The anyhow crate provides several convenient macros to simplify the process of constructing and handling errors. These macros include the bail!()
and try_with_context!()
macros.
The former can be used to quickly construct an error value, while the latter can be used to wrap a function call and automatically handle any errors that occur.
Comparison
The main difference between anyhow and the Thiserror crate in Rust is the way in which errors are handled. Anyhow allows for error handling using any type that implements the Error trait, whereas Thiserror requires you to explicitly define the error types using macros.
Anyhow is an error-handling library for Rust that provides an easy way to convert errors into a uniform type. It allows to write concise and powerful error-handling code by automatically converting many different types of errors into a single, common type.
In conclusion,in Cherrybomb we choose to combining the two, in order to create a custom error type with thiserror and managed it by the anyhow crate.