Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Исключения в Python: дизайн и практика

Исключения в Python: иерархия, EAFP и дизайн ошибок библиотеки

Работа с исключениями по-взрослому: EAFP против LBYL, своя иерархия ошибок библиотеки, exception chaining и from, группировка except, antipatterns bare except.

Прямой ответ

Python-идиома обработки ошибок — EAFP (easier to ask forgiveness than permission: сделать и перехватить, а не проверять заранее — LBYL): try: value = d[key] except KeyError: ... вместо if key in d — короче, атомарнее (между проверкой и использованием состояние может измениться) и быстрее на happy path. Три дисциплины, отделяющие зрелый код: перехватывать конкретное (except KeyError, не except Exception: и тем более не голый except: — широкий перехват проглатывает опечатки-AttributeError и KeyboardInterrupt; широкий — только на границе системы с логированием и ретраем/трансформацией); сохранять цепочку (raise NewError(...) from err — оригинал виден в трейсбеке; «подменил исключение без from» теряет причину — error tracking получает бесполезный стек); проектировать иерархию в библиотеках (базовый MyLibError + конкретные классы — потребитель ловит нужный уровень, не ValueError вообще: перехват чужих stdlib-исключений вокруг чужого кода — хрупкость, своя иерархия — контракт). Современность 3.11+: except*/ExceptionGroup для конкурентных ошибок (asyncio-сценарии) и err.add_note("контекст: заказ 42") — обогащение без подклассов. И граница: исключения — для исключительного (ошибки), не для потока управления (итерация — StopIteration внутри протокола — да; «return через исключение» в бизнес-логике — нет).

EAFP против LBYL: когда что

EAFP силён, когда проверка и действие разъезжаются по времени (файл существует → открыли → между ними удалён; ключ есть → прочитали → словарь изменился в потоке) и когда проверка дороже действия (dict-lookup дешевле, чем in + lookup дважды); LBYL уместен, когда провал ожидаем и не исключителен (валидация пользовательского ввода — if not email: return error — дешевле и яснее, чем try вокруг всего), когда исключение дорого на частом пути (миллион итераций с 50% провалов — try/except на каждом дороже проверки — редкий, но реальный случай), и в публичных API с явной валидацией входных данных (ошибки входа — не «исключения» семантически — возвращаются структурой ответа — FastAPI-валидация). Практическое правило: EAFP для взаимодействия со средой (файлы/сеть/конкурентные структуры), LBYL/валидация для входных данных на границах; смешение в одном модуле по ситуациии — нормально, догматизм с любой стороны — нет.

Иерархия ошибок библиотеки: контракт с потребителем

Паттерн: class MyLibError(Exception) — корень иерархии (документируется: «ловите это»); ниже — смысловые ветви: ConfigError (проблемы конфигурации — скорее fatal), TransientError (сеть/таймауты — ретрай уместен), ValidationError (неверные аргументы — ретрай бесполезен) — потребитель решает по типу, что делать (ретраить/падать/чинить конфиг) — классификация «что делать» в типах, не в сообщениях. Наследование от stdlib осознанно: class NotFoundError(MyLibError, KeyError) — и свой контракт, и совместимость с ловящими KeyError; «отнаследоваться от ValueError по привычке» — связывает ваш контракт с чужой семантикой. Чего не делать: не использовать общие stdlib-классы как публичные ошибки (потребитель, ловящий ValueError вокруг вашего кода, поймает и свой баг — своя иерархия изолирует); не кодировать детали только в строке (raise MyLibError("code=42 user=bob") — парсинг строк ломается; поля — атрибутами: err.code, err.user_id — идемпотентность-контекст и структурированные логи проще); не плодить класс на каждый случай (иерархия = ветви решений потребителя, не каталог всех мыслимых провалов — 5–15 классов на библиотеку).

Перехват: конкретность, цепочки, группы

Правила except: от конкретного к общему (порядок ветвей значим: except Exception первым перехватит всё нижеследующее — мёртвый код); bare except: запрещён (ловит SystemExit/KeyboardInterrupt — «процесс не убивается по Ctrl+C» — классический симптом; минимум — except BaseException с переосмыслением, почти всегда — конкретные); перехват с действием (лог + ретрай + трансформация; «перехватил и pass» — молчаливое проглатывание: если проглатывание осознанное — комментарий почему и лог уровня debug — иначе баг не найдётся никогда); трансформация с from (raise PaymentDeclined(...) from err — явная причина; без from Python добавит «During handling... another exception» — шумнее и неоднозначнее); finally/cleanup — контекст-менеджеры вместо try/finally-простыней (with закрывает ресурс при любом исходе). Python 3.11+: ExceptionGroup/except* — конкурентные провалы приходят группой (TaskGroup — несколько тасков упали одновременно; except* TransientError as eg — обработать подмножество группы — async-паттерны); add_note — контекст на месте перехвата без своих классов (err.add_note(f"order_id={oid}") — нота видна в трейсбеке и в Sentry).

Практический сценарий: обработка ошибок платёжного модуля

Модуль интеграции с платёжным провайдером, требования: сетевые сбои ретраятся, отказы карты — наверх бизнес-слою, неожиданные — в алерт. Дизайн: иерархия PaymentsError → TransientPaymentError (таймауты/5xx — ретрай-дисциплина с backoff), DeclinedError(code, reason) (бизнес-отказ — поля, не строка), ProviderProtocolError (ответ не по контракту — баг их или наш — алерт); маппинг на границе: httpx-исключения → Transient с from, коды провайдера → Declined, неожиданное → ProtocolError с from и нотой (сырой ответ в add_note — разбор без логов-раскопок). Потребитель: except TransientPaymentError: retry, except DeclinedError as e: показать e.reason, except PaymentsError: 502 + Sentry — три ветви решений, ни одной ловли Exception. Тесты: каждый класс ошибки — тест маппинга (параметризация по кодам провайдера), тест «цепочка сохранена» (pytest.raises + проверка __cause__ — from не потерялся при рефакторинге — регрессия на молчаливое исключение причины). Эволюция через полгода: провайдер добавил новый код отказа —DeclinedError с новым code, иерархия не тронута (потребители не сломаны — контракт выдержал); новый сбой «провайвер отвечает чужим JSON» — ProtocolError поймал, алерт сработал, разбор по add_note — дизайн ошибок окупился в штатной работе, не в красивых схемах.

FAQ

Исключения медленные — стоит ли избегать? В современном CPython выброс/перехват — микросекунды (3.11+ ещё ускорили zero-cost-представлением); «исключения медленны» значимо только в горячих циклах с миллионами выбросов (и тогда вопрос — к дизайну потока, не к механизму); на путях запросов/API стоимость исключений незаметна на фоне IO.

Возвращать Result/Tuple вместо исключений? Для ожидаемых ветвлений бизнес-логики (валидация, «не найдено» как норма) — да, возврат структуры/Optional чище (pydantic-модели типизируют результат); для сбоев (среда, контракты, баги) — исключения (проходят сквозь слои без явной передачи на каждом уровне — их главное свойство); гибрид — норма: ожидаемое — возвратом значения, исключительное — raise.

Что логировать при перехвате и где? Логируется один раз на границе, где принимается решение (ретрай/ответ 500/падение) — со стеком (logger.exception) и контекстом (структура логов); промежуточные перехваты-трансформации не логируют (цепочка from сохранит картину наверху) — «лог в каждом except» даёт пять записей одной ошибки и шум в агрегаторе.

Как тестировать, что код выбрасывает нужное? pytest.raises(MyError) as ei + проверки атрибутов (ei.value.code == 42) и цепочки (ei.value.__cause__ — from на месте); негативные проверки — «не выбрасывает при валидном входе»; «тесты на исключения» — полноценная часть контрактных тестов библиотеки, не второстепенная.

Источники: документация Python (errors), PEP 3134 (chaining), PEP 654 (ExceptionGroup) — ссылки в шапке.