Я часто сталкиваюсь с разработчиками, очень хорошо знающими механику обработки ошибок в Python, однако когда я начинаю выполнять ревью их кода, он оказывается далеко неидеальным. Исключения в Python — это одна из тех областей, поверхностный уровень которого знает большинство, но многие разработчики даже не догадываются о существовании более глубокого, почти тайного уровня. Если вы хотите протестировать себя по этой теме, то проверьте, сможете ли вы ответить на следующие вопросы:
- Когда следует перехватывать исключения, генерируемые вызываемыми вами функциями, а когда этого делать не нужно?
- Как узнать, какие классы исключений нужно перехватывать?
- Что нужно делать при перехвате исключений для их «обработки»?
- Почему перехватывание всех исключений считается порочной практикой, и когда делать это приемлемо?
Вы готовы узнать секреты обработки ошибок в Python? Тогда поехали!
Основы: два пути обработки ошибок в Python
Я начну с того, что, как мне кажется, знают многие мои читатели и о чём написано на различных ресурсах. В Python существует два основных стиля написания кода обработки ошибок, часто обозначаемых непроизносимыми аббревиатурами LBYL и EAFP. Знакомы ли они вам? Если нет, то прочитайте ниже краткое введение в них.
Look Before You Leap (LBYL)
Паттерн обработки ошибок «look before you leap» гласит, что перед выполнением самого действия необходимо проверять те условия его выполнения, которые могут быть ошибочными.
if can_i_do_x():
do_x()
else:
handle_error()
В качестве примера рассмотрим задачу удаления файла с диска. При использовании LBYL код будет выглядеть так:
if os.path.exists(file_path):
os.remove(file_path)
else:
print(f"Error: file {file_path} does not exist!")
Хотя на первый взгляд этот код выглядит довольно надёжным, на практике это не так.
Основная проблема здесь заключается в том, что нам нужно знать всё, что теоретически может пойти не так при удалении файла, чтобы мы могли проверить все эти потенциальные проблемы, прежде чем вызывать
remove()
. Очевидно, что файл должен существовать, но отсутствующий файл — это не единственная причина неудачного удаления файла:- Путь может указывать на папку, а не на файл
- Владельцем файла может быть не тот пользователь, который пытается его удалить
- У файла может быть разрешение только на чтение
- Диск, на котором хранится файл, может быть примонтирован как том только для чтения
- Файл может быть блокирован другим процессом, что часто случается в Microsoft Windows
Как бы выглядел показанный выше пример с удалением файла, если бы нам пришлось добавлять все эти проверки?
Как видите, используя паттерн LBYL, достаточно сложно писать надёжный код, потому что нужно знать все возможные причины неудачного выполнения вызываемой функции, и иногда их просто слишком много.
Ещё одна проблема применения паттерна LBYL — существование состояний гонки. Если вы проверяете условия неудачного выполнения, а затем выполняете действия, всегда есть вероятность изменения условий в краткий промежуток времени между проведением проверок и действием.
Easier to Ask Forgiveness than Permission (EAFP)
Наверно, вы уже поняли, что я не очень хорошего мнения о паттерне LBYL (но, как мы увидим ниже, в некоторых ситуациях он полезен). Паттерн EAFP заявляет, что «проще попросить прощения, чем разрешения». Что это значит? Это значит, что нужно выполнить действие, а уже потом обрабатывать все возникшие ошибки.
В Python паттерн EAFP лучше всего реализуется при помощи исключений:
try:
do_x()
except SomeError:
handle_error()
Удаление файла при помощи EAFP выполняется так:
try:
os.remove(file_path)
except OSError as error:
print(f"Error deleting file: {error}")
Надеюсь, вы согласитесь, что в большинстве случаев EAFP предпочтительнее, чем LBYL.
Существенное улучшение заключается в том, что при таком паттерне на целевую функцию возлагается задача проверки ошибок и генерации сообщений о них, поэтому мы, как вызывающая сторона, можем выполнить вызов и быть уверенными, что функция сообщит нам, если действие завершится неудачно.
С другой стороны, нам нужно знать, что исключения записывают в оператор
except
, потому что все упущенные нами классы исключений когда-то всплывают и потенциально приведут к аварийному завершению приложения на Python. В случае удаления файла можно безопасно предположить, что все возникающие ошибки будут относиться к OSError
или к одному из его подклассов, но в других случаях для того, чтобы узнать, какие исключения может создать функция, необходимо обратиться к документации или исходному коду.Вы можете задаться вопросом: почему бы не перехватывать все возможные исключения, чтобы точно не упустить ни одного. Это плохой паттерн, создающий больше проблем, чем решений, поэтому я не рекомендую использовать его за исключением нишевых случаев, о которых расскажу ниже. Проблема в том, что обычно баги в нашем собственном коде проявляются в виде неожиданных исключений. Если вы перехватываете и заглушаете все исключения при каждом вызове функции, то с большой долей вероятности упустите исключения, которые не должны возникать; они вызваны багами, которые нужно устранить.
Чтобы избежать риска потери багов приложения, проявляющихся как неожиданные исключения, всегда следует перехватывать максимально краткий список классов исключений, а если это возможно, то и вообще не перехватывать исключения. Всегда учитывайте полное отсутствие перехвата исключений как стратегию обработки ошибок. Это может показаться противоречием, но на самом деле это не так. К этому мы ещё вернёмся.
Обработка ошибок Python в реальном мире
К сожалению, традиционное знание об обработке ошибок не слишком глубокое. Вы можете полностью понимать LBYL и EAFP, знать, как работают
try
и except
, но часто не знаете или не понимаете, как лучше писать код обработки ошибок.Поэтому сейчас мы рассмотрим ошибки под совершенно другим углом, с основным упором на сами ошибки, а не на способы их обработки. Надеюсь, благодаря этому вам гораздо проще будет понимать, что же нужно сделать.
Новые ошибки и всплывающие ошибки
Для начала нам нужно классифицировать ошибки на основании их источника. Существует два типа:
- Ваш код нашёл проблему и ему нужно сгенерировать ошибку. Я буду называть этот тип новой ошибкой.
- Ваш код получил ошибку от вызванной им функции. Я буду называть её всплывающей ошибкой.
Если разобраться, то на самом деле, ошибки могут возникать только в двух ситуациях. Вы должны внести новую ошибку самостоятельно и поместить её в систему, чтобы её могла обработать какая-то другая часть приложения, или получить ошибку из какого-то другого места, и решать, что с ней делать.
Выражение «всплывающая» (bubbled-up) — это атрибут исключений. Когда блок кода вызывает исключение, то сторона, вызывавшая функцию с ошибкой, имеет шанс перехватить исключение в блоке
try
/except
. Если вызывающая сторона не перехватила её, то исключение предлагается следующей вызывающей стороне в стеке вызовов, и так продолжается, пока какой-нибудь код не решит перехватить исключение и обработать его. Когда исключение перемещается к вершине стека вызовов, это называется «всплыванием». Если исключение не перехвачено и всплыло на самую вершину, то Python прервёт работу приложения и тогда вы увидите трассировку стека со всеми уровнями, через которые всплывала ошибка — очень полезная штука при отладке.Ошибки, после которых можно и нельзя восстановиться
Наряду с определением того, стала ли ошибка новой или всплывающей, нужно принять решение, можно ли после неё восстановиться. Ошибка, после которой можно восстановиться — это ошибка, которую имеющий с ней дело код может устранить перед тем, как продолжить выполнение. Например, если блок кода пытается удалить файл и обнаруживает, что файл не существует, то это не станет большой проблемой, он может просто игнорировать ошибку и продолжить выполнение.
Ошибка, после которой нельзя восстановиться — это ошибка, которую код не может устранить или, иными словами, ошибка, из-за которой код на этом уровне не может продолжать выполнение. В качестве примера приведём функцию, которой нужно считать какие-то данные из базы данных, изменить их и сохранить обратно. Если произошёл сбой чтения, то функция должна преждевременно завершиться, потому что не может выполнить остальную часть работы.
Теперь у нас есть простой способ категоризации ошибок на основании их источников и возможности восстановления. Таким образом, мы получаем всего четыре возможные конфигурации ошибок, которые нам нужно как-то обрабатывать. В последующих разделах я расскажу, что конкретно нужно делать с каждым из четырёх типов.
Тип 1: обработка новых восстановимых ошибок
Это простой случай: у нас есть блок кода в нашем собственном приложении, обнаруживший состояние ошибки. К счастью, этот код может самостоятельно восстановиться от этой ошибки и продолжать выполнение.
Как, по вашему мнению, лучше всего обрабатывать этот случай? Мы просто восстанавливаемся после ошибки и продолжаем, не мешая ничему остальному!
Рассмотрим пример:
def add_song_to_database(song):
# ...
if song.year is None:
song.year = 'Unknown'
# ...
Здесь у нас есть функция, записывающая песню в базу данных. Допустим, в схеме базы данных год выпуска песни не может быть равен null.
Воспользовавшись идеями из паттерна LBYL, мы можем проверить, задан ли атрибут year песни, чтобы запись в базу данных не завершилась неудачно. Как нам восстановиться от этой ошибки? В такой ситуации мы присваиваем году значение unknown и продолжаем работу, зная, что запись в базу данных не завершится неудачно (по крайней мере, по этой причине).
Разумеется, способ восстановления после ошибки сильно зависит от приложения и ошибки. В примере выше я предположил, что год выпуска песни хранится в базе данных в виде строки. Если он хранится как число, то, вероятно, приемлемым способом обработки песен с неизвестным годом будет присваивание значения
0
. В другом приложении год может быть обязательным, тогда для этого приложения такая ошибка не будет восстанавливаемой.Логично? Если вы найдёте погрешность или несоответствие в текущем состоянии приложения и можете исправить состояние, не вызывая ошибку, то вызывать её и не требуется, достаточно просто исправить состояние и продолжить выполнение.
Тип 2: обработка всплывающих восстанавливаемых ошибок
Второй случай — вариация первого. У нас есть ошибка, которая не стала новой, это ошибка, всплывшая из вызванной функции. Как и в предыдущем случае, природа ошибки такова, что получающий ошибку код знает, как восстановиться после неё и продолжить работу.
Как обработать такой случай? Мы используем EAFP для перехвата ошибки, затем выполняем всё необходимое для восстановления после неё и продолжаем.
Вот другая часть функции
add_song_to_database()
, демонстрирующая этот случай:def add_song_to_database(song):
# ...
try:
artist = get_artist_from_database(song.artist)
except NotFound:
artist = add_artist_to_database(song.artist)
# ...
Функции нужно извлечь из базы данных исполнителя по песне, но время от времени она может выполняться неудачно, например, при добавлении первой песни этого исполнителя. Функция использует EAFP для перехвата ошибки
NotFound
базы данных, а затем исправляет ошибку, добавляя в базу данных неизвестного исполнителя, и продолжает работу.Как и в первом случае, здесь код, который должен обрабатывать ошибку, знает, как изменить состояние приложения, чтобы продолжить его выполнение, поэтому он может получить ошибку и продолжить. Ни одному из слоёв в стеке вызовов выше этого кода не нужно знать, что возникла ошибка, поэтому всплывание этой ошибки завершается в данной точке.
Тип 3: обработка новых невосстанавливаемых ошибок
С третьим случаем ситуация более интересная. Теперь у нас есть новая ошибка такого уровня опасности, что код не знает, что делать и не может продолжить выполнение. Единственным разумным действием здесь будет прекратить текущую функцию и отправить уведомление об ошибке на один уровень выше стека вызовов в надежде, что вызывающая сторона знает, что делать. Как говорилось выше, в Python предпочтительный способ уведомления вызывающей стороны об ошибке — это вызов исключения, что мы и сделаем.
Эта стратегия хорошо работает благодаря интересному свойству невосстанавливаемых ошибок. В большинстве случаев невосстанавливаемая ошибка рано или поздно становится восстанавливаемой, достигнув достаточно высокой позиции в стеке вызовов. Так что если ошибка сможет всплывать по стеку вызовов до тех пор, пока не станет восстанавливаемой, то она станет ошибкой типа 2, обрабатывать который мы уже умеем.
Давайте вернёмся к функции
add_song_to_database()
. Мы решили, что если год песни не указан, то можно восстановиться от этой ошибки и предотвратить ошибку в базе данных, присвоив году значение 'Unknown'
. Однако если у песни нет названия, то гораздо сложнее будет понять, правильно ли делать это на данном уровне, поэтому мы можем сказать, что для этой функции отсутствующее название — это невосстанавливаемая ошибка. Вот, как мы обрабатываем эту ошибку:def add_song_to_database(song):
# ...
if song.name is None:
raise ValueError('The song must have a name')
# ...
Выбор класс исключений зависит от приложения и вашего личного мнения. Для многих ошибок можно использовать исключения самого Python, но если ни одно из встроенных исключений не подходит, то всегда можно создать собственные подклассы исключений. Вот реализация того же примера с собственным исключением:
class ValidationError(Exception):
pass
# ...
def add_song_to_database(song):
# ...
if song.name is None:
raise ValidationError('The song must have a name')
# ...
Важно отметить, что ключевое слово
raise
прерывает функцию. Это необходимо, потому что, как мы говорили, от этой ошибки невозможно восстановиться, поэтому остальная часть функции после ошибки не сможет выполнять нужные ей действия и должна быть прекращена. Вызов исключения прерывает работу текущей функции и выполняет всплывание ошибки, начиная с ближайшей вызывающей стороны и дальше по стеку вызовов, пока какой-нибудь код не решит перехватить исключение.Тип 4: обработка всплывающих невосстанавливаемых ошибок
У нас остался последний тип ошибок, самый интересный и любимый мной.
Допустим, у нас есть блок кода, вызывавший некую функцию, функция вызвала ошибку и мы в нашей функции понятия не имеем, как нам исправить ситуацию, чтобы можно было продолжить выполнение, поэтому мы считаем эту ошибку невосстанавливаемой. Что нам теперь делать?
Ответ вас удивит. В этом случае мы абсолютно ничего не делаем!
Как говорилось выше, отсутствие обработки ошибок может быть замечательной стратегией обработки ошибок; именно такую ситуацию я и имел в виду. Давайте рассмотрим пример того, как можно обрабатывать ошибку, не делая ничего:
def new_song():
song = get_song_from_user()
add_song_to_database(song)
Допустим, обе функции, вызванные в
new_song()
, могут завершиться неудачно и вызвать исключения. Вот пара примеров того, что может пойти не так с этими функциями:- Пользователь может нажать Ctrl-C, пока приложение ждёт ввода внутри
get_song_from_user()
, или в случае приложения с GUI пользователь может нажать кнопку «Закрыть» или «Отмена». - При нахождении внутри одной из функций база данных может отключиться из-за проблем с облаком, из-за чего все запросы и внесения изменений будут какое-то время завершаться неудачно.
Если мы никак не можем восстановиться после таких ошибок, то и нет смысла их перехватывать. Ничего не делать — это, на самом деле, самое полезное, что мы можем сделать, потому что это позволяет исключениям всплывать вверх. Рано или поздно исключения достигнут уровня, на котором код знает, как выполнить восстановление, и в этой точке они будут считаться ошибками типа 2, которые легко отлавливаются и обрабатываются.
Вы можете подумать, что это крайне редкая ситуация, но я считаю, что это не так. На самом деле, следует проектировать свои приложения так, чтобы максимальный объём кода находился в функциях, которым не нужно заниматься обработкой ошибок. Перенос кода обработки ошибок в функции более высокого уровня — это очень хорошая стратегия, помогающая писать чистый и удобный в поддержке код.
Наверно, кто-то из вас с этим не согласится. Возможно, вы считаете, что функция
add_song()
должна хотя бы вывести сообщение об ошибке, чтобы сообщить пользователю о сбое. Я не буду спорить, но давайте задумаемся об этом на минуту. Можем ли мы быть уверены, что у нас будет консоль для вывода? А что, если это приложение с GUI? У GUI нет stdout
, они отображают пользователям ошибки визуально через какое-нибудь окно уведомления или сообщения. А может, это веб-приложение? В веб-приложениях мы отображаем ошибки, возвращая пользователю HTTP-ответ об ошибке. Должна ли эта функция знать, какой тип приложения запущен и как ошибки отображаются пользователю? Принцип разделения ответственности гласит, что не должна.Повторюсь: то, что мы не делаем ничего в этой функции, не означает, что ошибка игнорируется, это означает, что мы позволяем ошибке всплыть вверх до какой-то другой части приложения с большим количеством контекста, способной правильно её обрабатывать.
Перехват всех исключений
Одна из причин, по которым вы можете сомневаться в том, что четвёртый тип ошибок должен быть самым частым в вашем приложении, заключается в том, что если мы позволим исключениям свободно всплывать вверх, то они могут добраться до самой вершины, не пойманными ни на одном из уровней, и привести к вылету приложения. Это вполне разумное беспокойство, но решить эту проблему очень легко.
Нужно проектировать приложение так, чтобы исключение ни в коем случае не могло достичь слоя Python. И это можно сделать, добавив на самый высокий уровень блок
try
/except
, перехватывающий все сбежавшие исключения.Если бы мы писали приложение командной строки, то сделали бы это следующим образом:
import sys
def my_cli()
# ...
if __name__ == '__main__':
try:
my_cli()
except Exception as error:
print(f"Unexpected error: {error}")
sys.exit(1)
Здесь верхний уровень приложения находится в условном операторе
if __name__ == '__main__'
, и он считает все достигшие этого уровня ошибки как восстанавливаемые. Механизм восстановления заключается в отображении ошибки пользователю и в выходе из приложения с кодом выхода 1
, сообщающим шеллу или родительскому процессу, что приложение завершилось неудачно. При такой логике приложение знает, как выполнить выход со сбоем, поэтому теперь нет необходимости повторно реализовывать его в каком-то другом месте. Приложение может просто позволить ошибкам всплывать вверх, и в конечном итоге они будут здесь перехвачены, будет отображено сообщение об ошибке, а приложение завершится с кодом ошибки.Как вы можете помнить, выше я говорил, что перехват всех исключений — это плохая практика. Тем не менее, именно это я здесь и делаю! Причина в том, что на этом уровне мы ни за что не можем позволить исключениям добраться до Python, поэтому что не хотим, чтобы программа когда-нибудь вылетала. И именно в этой ситуации есть смысл перехватывать все исключения. Это то самое исключение (оцените игру слов), которое доказывает правило.
Наличие высокоуровневого блока, перехватывающего все исключения — это достаточно распространённый паттерн, реализованный в большинстве фреймворков приложений. Вот два примера:
- Веб-фреймворк Flask: Flask считает каждый запрос отдельно запущенным выполнением приложения, верхним слоем которого считается метод
full_dispatch_request()
. Код, перехватывающий все исключения, находится здесь: https://github.com/pallets/flask/blob/2fec0b206c6e83ea813ab26597e15c96fab08be7/src/flask/app.py#L893-L900. - GUI-тулкит Tkinter (часть стандартной библиотеки Python): Tkinter считает каждый обработчик событий приложения отдельным выполнением приложения и добавляет общий блок перехвата всех исключений при каждом вызове обработчика, чтобы препятствовать вылету GUI из-за сбойных обработчиков приложения. Код можно посмотреть здесь: https://github.com/python/cpython/blob/b3e2c0291595edddc968680689bec7707d27d2d1/Lib/tkinter/__init__.py#L1965-L1972. Обратите внимание, что в этом фрагменте кода Tkinter допускает всплывание исключения
SystemExit
(обозначающего выполнение выхода из приложения), но перехватывает все остальные, чтобы избежать вылета.
Пример
Хочу продемонстрировать вам пример того, как можно улучшить свой код при помощи продуманной архитектуры обработки ошибок. Для этого я воспользуюсь Flask, но принцип применим к большинству других фреймворков и типов приложений.
Допустим, это приложение баз данных, использующее расширение Flask-SQLAlchemy. Судя по моему опыту консультирования и ревью кода, многие разработчики пишут операции с базами данных в конечных точках Flask следующим образом:
# ВНИМАНИЕ: это пример того, как НЕ надо выполнять обработку исключений!
@app.route('/songs/<id>', methods=['PUT'])
def update_song(id):
# ...
try:
db.session.add(song)
db.session.commit()
except SQLAlchemyError:
current_app.logger.error('failed to update song %s, %s', song.name, e)
try:
db.session.rollback()
except SQLAlchemyError as e:
current_app.logger.error('error rolling back failed update song, %s', e)
return 'Internal Service Error', 500
return '', 204
Здесь route пытается сохранить песню в базу данных и перехватывает ошибки базы данных; все они являются подклассами класса исключений
SQLAlchemyError
. Если ошибка возникает, она записывает в лог сообщение с объяснением, а затем откатывает назад сессию базы данных. Но, разумеется, операция отката тоже иногда может выполняться со сбоями, поэтому есть второй блок перехвата исключений для поимки ошибок отката назад и записи их в лог. После всего этого пользователю возвращают ошибку 500, чтобы он знал, что возникла серверная ошибка. Этот паттерн повторяется в каждой конечной точке, выполняющей запись в базу данных.Это очень плохое решение. Во-первых, эта функция не может сделать ничего для восстановления после ошибки отката назад. Если возникает ошибка отката, то это означает наличие серьёзных проблем с базой данных, так что вы, скорее всего, захотите продолжить. чтобы увидеть ошибки, и логгинг ошибки отката никак вам не поможет. Логгинг сообщения об ошибке в случае сбоя записи в базу данных поначалу может показаться полезным, но в этом конкретном логе нет важной информации, в частности, трассировки стека ошибки, то есть самого важного инструмента отладки, который вам понадобится позже при поисках причин произошедшего. Как минимум, этот код должен использовать
logger.exception()
вместо logger.error()
, так как в этом случае в лог будет записано сообщение об ошибке и трассировка стека. Но можно сделать и лучше.Эта конечная точка относится к категории типа 4, поэтому её код можно написать по принципу «ничего не делать», благодаря чему реализация будет гораздо лучше:
@app.route('/songs/<id>', methods=['PUT'])
def update_song(id):
# ...
db.session.add(song)
db.session.commit()
return '', 204
Почему это работает? Как мы видели выше, Flask перехватывает все ошибки, поэтому ваше приложение никогда не вылетит по причине невозможности перехвата ошибки. В рамках обработки ошибок Flask записывает в лог сообщение об ошибке и трассировку стека, что нам и нужно, поэтому не придётся делать это самостоятельно. Кроме того, Flask также возвращает клиенту ошибку 500, чтобы сообщить о неожиданной серверной ошибке. Кроме того, расширение Flask-SQLAlchemy прикрепляется к механизму обработки исключений во Flask и само откатывает сессию при возникновении ошибки базы данных, что тоже нам важно и необходимо. Таким образом, на этом пути нам ничего не придётся делать самим!
Процесс восстановления после ошибок баз данных для большинства приложений одинаков, поэтому можно оставить всю грязную работу фреймворку, сильно упростив таким образом код своего приложения.
Ошибки в продакшене и ошибки в разработке
Я говорил, что одно из преимуществ перемещения максимально возможного объёма логики обработки ошибок на высокие уровни стека вызовов приложения заключается в повышении удобства поддержки и читаемости кода.
Ещё одно преимущество переноса основной части кода обработки ошибок в отдельную часть приложения заключается в том, что если код обработки ошибок будет находиться в одном месте, то вам будет удобнее управлять реакциями приложения на ошибки. Лучший пример здесь — это простота изменения поведения в случае ошибок в конфигурациях продакшена и разработки приложения.
В процессе разработки вполне допускается вылет приложений и отображение трассировки стека. На самом деле, это даже хорошо, ведь мы хотим, чтобы ошибки и баги были заметны и их можно было исправить. Но, разумеется, то же приложение должно быть идеально надёжным в продакшене, ошибки должны записываться в лог с отправкой уведомлений разработчикам, если это возможно. При этом не должны возникать утечки пользователям внутренних или конфиденциальных подробностей ошибок.
Это становится гораздо легче реализовать, когда вся обработка ошибок находится в одном месте и отделена от логики приложения. Давайте вернёмся к примеру приложения командной строки и добавим в него режимы разработки и продакшена:
import sys
mode = os.environ.get("APP_MODE", "production")
def my_cli()
# ...
if __name__ == '__main__':
try:
my_cli()
except Exception as error:
if mode == "development":
raise # в режиме разработки мы позволяем приложению вылетать!
else:
print(f"Unexpected error: {error}")
sys.exit(1)
Разве не замечательно? Когда мы запускаем программу в режиме разработки, то вызываем исключения для вылета приложений, чтобы при работе можно было видеть ошибки и трассировки стека. Но мы делаем это без ущерба надёжности версии для продакшена, которая продолжает перехватывать все ошибки и предотвращать вылеты. Что ещё более важно, логике приложения не нужно знать об этих различиях конфигурации.
Напоминает ли это вам то, что делают Flask, Django и другие веб-фреймворки? У многих веб-фреймворков есть режим разработки или отладки, отображающий вылеты в консоли, а иногда и в веб-браузере. То же самое решение, которое я показал вам на примере выдуманного CLI-приложения, но реализованное в веб-приложении!