Исключения и обработка ошибок: иерархия, свои исключения, EAFP
Обработка ошибок — это не «отлов падений». Это проектное решение о том, кто в системе знает, что делать, когда что-то пошло не так. Функция, которая читает конфиг, не знает, надо ли пользователю показать диалог, повторить попытку или упасть: у неё нет контекста. Зато у неё есть точная информация о том, что именно сломалось. Исключения — это механизм, который позволяет разнести эти два знания: место обнаружения проблемы и место принятия решения могут находиться в разных слоях приложения, и между ними не надо тащить коды возврата через каждый вызов.
В Python этот механизм доведён до предела: исключения тут не «аварийный выход», а
штатный способ передать управление. Цикл for заканчивается через StopIteration,
dict[key] промахивается через KeyError, импорт необязательной зависимости проверяется
через ImportError. Отсюда вся идиоматика: EAFP, contextlib.suppress, контекстные
менеджеры. И отсюда же главные грабли: нет проверяемых исключений, никто не заставит вас
задокументировать, что функция бросает, а один except Exception: pass способен
превратить продовый инцидент в неделю расследования.
Азы (try/except, чтение трассировки, отладка) разобраны в курсе
Программирование с нуля — здесь
они не повторяются. Мы разберём модель исполнения, дизайн иерархий, границы трансляции
ошибок и продакшн-практику. Понимание того, что исключение — обычный объект, берём из
модели данных, а with и генераторы — из
идиоматичного Python.
Что происходит при raise: модель исполнения
Когда интерпретатор выполняет raise, происходит три вещи.
- Создаётся объект исключения — обычный экземпляр обычного класса. У него есть
args, есть__dict__, ему можно добавить любые поля. - К объекту привязывается трассировка (
__traceback__) — односвязный список кадров стека. Она не «снимается снимком»: каждый кадр, через который исключение пролетает наверх, дописывается в цепочку. Поэтому трассировка читается сверху вниз от внешнего вызова к месту сбоя. - Начинается разматывание стека (unwinding): интерпретатор идёт по кадрам вверх и в
каждом ищет подходящий обработчик. Не найдя — выполняет
finallyи__exit__контекстных менеджеров этого кадра, уничтожает кадр и идёт дальше.
except с подходящим типом?"} B -- "да" --> C["выполнить тело except
исключение считается обработанным"] B -- "нет" --> D["выполнить finally и __exit__
дописать кадр в __traceback__"] D --> E{"есть кадр выше?"} E -- "да" --> F["перейти в вызывающий кадр"] F --> B E -- "нет" --> G["sys.excepthook: печать трассировки
процесс завершается с кодом 1"] C --> H{"внутри except новый raise?"} H -- "да" --> I["новое исключение,
старое уходит в __context__"] I --> D H -- "нет" --> J["выполнение продолжается
после блока try"]
Ключевое следствие: поиск обработчика идёт по стеку вызовов, а не по тексту программы.
Вы можете поймать ValueError, поднятый в библиотеке на десять кадров ниже, — и именно
поэтому широкий except так опасен: он ловит и то, о чём вы даже не думали.
Второе следствие — про производительность. Начиная с CPython 3.11 действует
zero-cost exceptions: блок try
не генерирует никаких байткод-инструкций на входе, вместо этого компилятор строит
таблицу диапазонов (co_exceptiontable). Пока исключения нет, try бесплатен. Замеры
на CPython 3.12.3:
import timeit
setup = 'd = {"a": 1}'
# «горячий» путь: ключ есть
timeit.timeit('d["a"] if "a" in d else None', setup=setup, number=1_000_000) # ≈ 0.025 c
timeit.timeit('try:\n d["a"]\nexcept KeyError:\n pass', setup=setup, number=1_000_000) # ≈ 0.017 c
# «холодный» путь: ключа нет
timeit.timeit('d["z"] if "z" in d else None', setup=setup, number=1_000_000) # ≈ 0.015 c
timeit.timeit('try:\n d["z"]\nexcept KeyError:\n pass', setup=setup, number=1_000_000) # ≈ 0.110 c
Читается так: try/except дешевле предварительной проверки, когда исключение не
возникает (одна операция вместо двух), и примерно в 7 раз дороже, когда возникает.
Подъём собственного исключения через несколько кадров стоит дороже: около 200 нс через
один кадр и около 675 нс через шесть (сам вызов шести функций — 126 нс). Это микросекунды:
для 10 000 ошибок в секунду неважно, для внутреннего цикла на миллионы итераций — важно.
Иерархия исключений: почему except Exception, а не except:
Все исключения наследуются от BaseException. Прямо под ней живут три «особых» класса,
которые не являются ошибками программы — это сигналы управления:
Практическое правило: ловите Exception, никогда не пишите голый except:.
# ПЛОХО: перехватит Ctrl+C и sys.exit(), процесс станет неубиваемым
try:
do_work()
except: # это except BaseException
logger.warning("что-то сломалось")
# ХОРОШО: сигналы управления пролетают наверх
try:
do_work()
except Exception: # только «ошибки программы»
logger.exception("не удалось выполнить работу")
raise
asyncio.CancelledError с Python 3.8 тоже наследуется от BaseException — специально,
чтобы except Exception в вашем коде не проглотил отмену задачи. Если вы ловите
BaseException, обязаны сделать raise после уборки.
Отдельно стоит OSError. До Python 3.3 приходилось разбирать errno вручную;
PEP 3151 превратил коды ошибок ОС в подклассы:
import errno
try:
open("/no/such/file")
except FileNotFoundError as exc:
print(type(exc).__name__, exc.errno, exc.errno == errno.ENOENT, exc.filename)
# FileNotFoundError 2 True /no/such/file
Заодно исчез зоопарк синонимов: IOError is OSError, socket.timeout is TimeoutError,
asyncio.TimeoutError is TimeoutError — всё это один и тот же объект в современном Python.
Как выбрать правильный тип
| Ситуация | Тип |
|---|---|
Значение правильного типа, но недопустимое (int("abc")) |
ValueError |
Значение неправильного типа (len(5)) |
TypeError |
| Нет ключа/индекса | KeyError / IndexError |
| Атрибута нет | AttributeError |
| Метод абстрактный или ветка не реализована | NotImplementedError |
| Нарушен инвариант, состояние испорчено | RuntimeError или своё |
| Проблема окружения: файл, сокет, права | подкласс OSError |
| Нарушено бизнес-правило | своё доменное исключение |
Ошибка NotImplemented vs NotImplementedError — классические грабли: NotImplemented —
это синглтон-значение для __eq__/__add__ (см. ООП), а не
исключение. raise NotImplemented даст TypeError.
try / except / else / finally — полная семантика
Четыре блока, и у каждого своя роль.
import json
from pathlib import Path
def load_settings(path: Path) -> dict:
handle = path.open(encoding="utf-8")
try:
data = json.load(handle) # только рискованная операция
except json.JSONDecodeError as exc:
# ловим узко: сломанный JSON — ожидаемая ситуация
raise ConfigError(f"битый JSON в {path}") from exc
else:
# выполняется, ТОЛЬКО если исключения не было;
# сюда не попадёт исключение, которое мы не хотели ловить выше
return normalize(data)
finally:
handle.close() # выполняется в любом случае
else нужен именно для этого: держать в try только рискованную строку. Если положить
return normalize(data) внутрь try, а normalize внезапно бросит JSONDecodeError
(бывает), вы поймаете чужую ошибку и выдадите неверный диагноз.
Грабли: return и raise внутри finally
def broken():
try:
raise ValueError("исходная")
finally:
return "перезаписал" # исключение молча исчезает!
print(broken()) # перезаписал — ValueError испарился
return, break и continue внутри finally проглатывают летящее исключение.
С Python 3.12 линтеры и сам компилятор об этом предупреждают
(PEP 765 делает это предупреждением синтаксиса
в 3.14). Правило: в finally только уборка, никакого управления потоком.
Грабли: переменная except ... as e удаляется
try:
raise ValueError("v")
except ValueError as exc:
pass
print(exc) # NameError: name 'exc' is not defined
В конце блока except интерпретатор выполняет неявное del exc — чтобы кадр не держал
ссылку на трассировку, а трассировка на кадр (циклическая ссылка со всеми локальными
переменными). Если исключение нужно позже, сохраните его в другую переменную явно — и
помните, что вы удерживаете в памяти весь стек с данными.
EAFP против LBYL
Две стратегии:
- LBYL (Look Before You Leap) — проверить условия, потом действовать. Родной стиль C, Go, отчасти Java.
- EAFP (Easier to Ask Forgiveness than Permission) — действовать и ловить исключение. Родной стиль Python.
# LBYL
if os.path.exists(path):
with open(path) as f: # ← файл могли удалить между проверкой и открытием
data = f.read()
else:
data = ""
# EAFP
try:
with open(path) as f:
data = f.read()
except FileNotFoundError:
data = ""
EAFP выигрывает по трём причинам.
- Атомарность. LBYL создаёт классическую TOCTOU-гонку (time-of-check to time-of-use):
между
exists()иopen()файл может исчезнуть, ключ — быть удалён другим потоком, строка в БД — измениться. Проверка + действие никогда не атомарны, а «попробовать» атомарно по построению. - Полнота. Проверить все предусловия обычно невозможно.
os.path.existsвернётTrue, аopenупадёт сPermissionError,IsADirectoryErrorилиOSError: too many open files. - Утиная типизация. Проверять
isinstance(obj, list)— значит запретить работать любому объекту, который просто ведёт себя как список.
LBYL уместен, когда проверка дешёвая, а исключение — дорогое или слишком широкое: валидация пользовательского ввода перед серией операций, проверка в горячем цикле, где промах — норма (см. замеры выше: миллион промахов через исключения стоят 0.11 с против 0.015 с).
Идиоматичные инструменты вместо ручных проверок:
from contextlib import suppress
config.get("timeout", 30) # вместо if "timeout" in config
counts = collections.defaultdict(int) # вместо if key not in counts
with suppress(FileNotFoundError): # вместо try/except/pass
os.remove(tmp_path)
contextlib.suppress читается лучше, чем try/except/pass, и сразу видно, что
подавление намеренное. Но подавляйте только конкретный тип: suppress(Exception) —
тот же антипаттерн, только красивее.
Свои исключения: как проектировать иерархию
Правило масштаба: количество типов исключений равно количеству различных стратегий
обработки. Если вызывающий код обрабатывает OrderNotFound и OrderCancelled
одинаково — это один тип с полем, а не два класса.
Базовый скелет для пакета:
class ShopError(Exception):
"""Корень иерархии пакета. Позволяет одним except поймать «всё наше»."""
class DomainError(ShopError):
"""Нарушено бизнес-правило. Виноват пользователь или состояние данных."""
class InfrastructureError(ShopError):
"""Сломалось окружение: БД, сеть, диск. Виновата не логика."""
class TransientError(InfrastructureError):
"""Подкласс, который имеет смысл ретраить."""
class OrderNotFound(DomainError):
def __init__(self, order_id: str) -> None:
# ВАЖНО: все аргументы конструктора уходят в super().__init__,
# иначе объект не переживёт pickle (multiprocessing, celery, кэш)
super().__init__(order_id)
self.order_id = order_id
def __str__(self) -> str:
return f"заказ {self.order_id} не найден"
class InsufficientFunds(DomainError):
def __init__(self, required: int, available: int) -> None:
super().__init__(required, available)
self.required = required
self.available = available
def __str__(self) -> str:
return f"нужно {self.required}, доступно {self.available}"
class InvalidOrderPayload(DomainError):
"""Один класс на всю валидацию: стратегия обработки одна — вернуть 422."""
def __init__(self, field: str, reason: str) -> None:
super().__init__(field, reason)
self.field = field
self.reason = reason
def __str__(self) -> str:
return f"поле {self.field}: {self.reason}"
Почему super().__init__(*все_аргументы) — не косметика:
import pickle
class BadError(Exception):
def __init__(self, code, msg):
super().__init__(msg) # в args попал только msg
self.code = code
pickle.loads(pickle.dumps(BadError(503, "нет связи")))
# TypeError: BadError.__init__() missing 1 required positional argument: 'msg'
BaseException.__reduce__ восстанавливает объект как cls(*self.args). Если args
не совпадает с сигнатурой __init__, исключение не переживёт границу процесса —
и вы получите загадочный TypeError внутри пула воркеров вместо исходной ошибки.
Наследоваться ли от встроенных
Соблазн написать class OrderNotFound(KeyError) велик, но встроенные типы тащат
семантику. KeyError.__str__ печатает repr аргумента ('ORD-1' в кавычках),
а except LookupError в чужом коде внезапно поймает вашу доменную ошибку. Наследуйтесь
от встроенных, только если хотите сознательной подменяемости (например, ваш
ConfigValueError(ValueError) действительно должен ловиться как ValueError
в валидаторах). По умолчанию — от своего корня.
Ещё одна практика: не делайте иерархию глубже трёх уровней и не используйте
множественное наследование исключений без острой нужды — порядок except определяется
isinstance, и с ромбами MRO разбираться в 3 часа ночи не хочется
(см. MRO).
Цепочки: raise ... from ..., __cause__ и __context__
Python автоматически связывает исключения, поднятые внутри обработчика:
class ConfigError(Exception): ...
# 1. Явная связь: from exc
try:
int("nope")
except ValueError as exc:
raise ConfigError("плохой порт") from exc
# __cause__ = ValueError, __context__ = ValueError, __suppress_context__ = True
# печатается: "The above exception was the direct cause of the following exception"
# 2. Неявная связь: просто raise внутри except
try:
int("nope")
except ValueError:
raise ConfigError("плохой порт")
# __cause__ = None, __context__ = ValueError, __suppress_context__ = False
# печатается: "During handling of the above exception, another exception occurred"
# 3. Сознательный обрыв цепочки
try:
int(user_input)
except ValueError:
raise ConfigError("порт должен быть числом") from None
# внутренняя кухня не утекает наружу — полезно для публичных API и CLI
Разница между __cause__ и __context__ — разница между «я перевёл ошибку» и
«у меня сломалось во время обработки другой ошибки». Второе часто означает баг в самом
обработчике, и трассировка это честно показывает.
Правило ревью: любое raise внутри except содержит from. Либо from exc
(перевели), либо from None (осознанно скрыли). Отсутствие from — почти всегда
забывчивость.
Повторный подъём того же исключения:
try:
risky()
except TransientError:
metrics.inc("transient")
raise # ХОРОШО: голый raise, трассировка не тронута
except DomainError as exc:
raise DomainError(str(exc)) # ПЛОХО: потеряли тип, поля и всю трассировку
С Python 3.11 к исключению можно приклеить контекст без создания нового класса — PEP 678:
try:
process(record)
except Exception as exc:
exc.add_note(f"record_id={record.id}")
exc.add_note(f"tenant={tenant}")
raise
# exc.__notes__ == ['record_id=...', 'tenant=...'] — попадёт в печатную трассировку
Границы: где ловить и где не ловить
Самый частый архитектурный дефект — ловить везде понемногу. Правильная модель: в системе есть две зоны широкого перехвата — адаптеры (переводят чужие исключения в свои) и верхний обработчик (логирует и не даёт процессу умереть). Всё между ними ловит узко или не ловит вовсе.
# Слой адаптера: единственное место, знающее про psycopg
import psycopg
class OrderRepository:
def get(self, order_id: str) -> Order:
try:
row = self._conn.execute(SQL_GET, (order_id,)).fetchone()
except psycopg.OperationalError as exc:
# сеть/соединение — временная проблема, её можно ретраить
raise TransientError(f"БД недоступна при чтении {order_id}") from exc
except psycopg.Error as exc:
# всё остальное от драйвера — инфраструктурная, но не временная
raise InfrastructureError(f"ошибка БД при чтении {order_id}") from exc
if row is None:
raise OrderNotFound(order_id) # доменная ошибка, не «ошибка БД»
return Order.from_row(row)
# Слой транспорта (FastAPI): маппинг доменного словаря в HTTP
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
# таблица «доменный тип → HTTP-код» живёт ТОЛЬКО здесь, домен про неё не знает
STATUS = {OrderNotFound: 404, InsufficientFunds: 409, InvalidOrderPayload: 422}
@app.exception_handler(DomainError)
async def domain_handler(request: Request, exc: DomainError) -> JSONResponse:
# доменные ошибки — ожидаемые, логируем на INFO без трассировки
logger.info("domain_error", extra={"kind": type(exc).__name__, "detail": str(exc)})
return JSONResponse({"error": type(exc).__name__, "detail": str(exc)},
status_code=STATUS.get(type(exc), 400))
@app.exception_handler(Exception)
async def unexpected_handler(request: Request, exc: Exception) -> JSONResponse:
# всё остальное — баг: полная трассировка, алерт, безликий ответ клиенту
logger.exception("unhandled", extra={"path": request.url.path})
return JSONResponse({"error": "internal_error"}, status_code=500)
Так домен не знает про HTTP, транспорт не знает про SQL, а стектрейс с причиной доезжает до логов целиком. Подробнее про слои — в продакшн-архитектуре и веб и API.
Классификация ошибок: что ретраить, а что нет
Прежде чем писать except, ответьте на два вопроса: ошибка временная или постоянная,
и виноват наш код или окружение/вход.
Ретраить можно только то, что находится в верхней правой четверти, и только если
операция идемпотентна. Повтор неидемпотентного POST /payments — это двойное списание;
об идемпотентности и семантике доставки подробно в
распределённых системах.
Ретрай руками занимает десяток строк, но в проде лучше взять готовое — tenacity или её тонкую обёртку stamina, где уже есть джиттер, бюджет попыток и метрики:
import random, time
from typing import Callable, TypeVar
T = TypeVar("T")
def with_retries(fn: Callable[[], T], attempts: int = 3, base: float = 0.2) -> T:
"""Экспоненциальная задержка с полным джиттером. Ретраит ТОЛЬКО TransientError."""
for attempt in range(1, attempts + 1):
try:
return fn()
except TransientError as exc:
if attempt == attempts:
raise # исчерпали бюджет — отдаём наверх как есть
delay = random.uniform(0, base * 2 ** (attempt - 1))
logger.warning("retry %s/%s через %.2f c: %s", attempt, attempts, delay, exc)
time.sleep(delay)
raise AssertionError("недостижимо") # для mypy: цикл всегда возвращает или бросает
Три вещи, которые ломают наивные ретраи: отсутствие джиттера (все клиенты приходят
одновременно и добивают сервис), отсутствие общего дедлайна (три уровня по три попытки —
это 27 запросов) и ретрай постоянных ошибок (400, PermissionError) — он бесполезен
и удваивает нагрузку.
ExceptionGroup и except*: когда ошибок несколько
До 3.11 Python умел нести только одну ошибку за раз. Но при параллельном выполнении
падают сразу несколько задач, и терять их нельзя.
PEP 654 добавил ExceptionGroup и синтаксис except*:
def boom():
raise ExceptionGroup("параллельные сбои", [
ValueError("v1"), TimeoutError("t1"), ValueError("v2"),
])
try:
boom()
except* ValueError as eg:
print("значения:", [str(e) for e in eg.exceptions]) # ['v1', 'v2']
except* TimeoutError as eg:
print("тайм-ауты:", [str(e) for e in eg.exceptions]) # ['t1']
Отличия except* от обычного except, которые надо знать:
- Выполняются все подходящие ветки, а не первая (группа расщепляется по типам).
- В каждую ветку приходит группа, а не одно исключение, — даже если внутри один элемент.
- Нельзя смешивать
except*и обычныйexceptв одномtry. - Внутри
except*запрещеныreturn,breakиcontinue. ExceptionGroupсодержит только наследниковException; если внутри естьKeyboardInterrupt, конструктор вернётBaseExceptionGroup— иexcept Exceptionеё не поймает.
Программный разбор — через split и subgroup:
eg = ExceptionGroup("all", [ValueError("v"), KeyError("k")])
matched, rest = eg.split(ValueError) # (ExceptionGroup с ValueError, ExceptionGroup с KeyError)
Главный практический источник групп — asyncio.TaskGroup
(см. конкурентность):
import asyncio
async def bad(name: str, delay: float) -> None:
await asyncio.sleep(delay)
raise ValueError(name)
async def main() -> None:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(bad("a", 0.01))
tg.create_task(bad("b", 0.02))
except* ValueError as eg:
print("упало:", [str(e) for e in eg.exceptions]) # ['a']
Обратите внимание на вывод: ['a'], а не ['a', 'b']. TaskGroup при первой же ошибке
отменяет остальные задачи — вторая не успела дойти до raise. Это фича структурной
конкурентности: группа не оставляет висящих задач. Если нужно собрать все результаты,
берите asyncio.gather(..., return_exceptions=True) — но тогда исключения возвращаются
как значения, и забыть их проверить очень легко.
Логирование и наблюдаемость
Ошибка, которую никто не увидел, эквивалентна ошибке, которой не было.
import logging
logger = logging.getLogger(__name__)
try:
charge(order)
except PaymentDeclined as exc:
# ожидаемый бизнес-исход: WARNING без трассировки, это не инцидент
logger.warning("платёж отклонён: %s", exc, extra={"order_id": order.id})
except Exception:
# неожиданное: ERROR с полной трассировкой
logger.exception("сбой при списании", extra={"order_id": order.id})
raise
Что важно:
logger.exception(...)работает только внутри блокаexceptи эквивалентенlogger.error(..., exc_info=True). Он пишет трассировку целиком, включая всю цепочку__cause__.- Никогда не логируйте
str(exc)вместо исключения.str(KeyError("user_id"))— это"'user_id'". Ни типа, ни файла, ни строки: расследовать нечего. - Логируйте один раз. Паттерн «поймал — залогировал — пробросил» на каждом уровне даёт
пять записей об одном событии и убивает поиск по логам. Логирует тот, кто принимает
решение (обычно верхняя граница), остальные добавляют контекст через
add_note. - Ничто из перечисленного не заменяет трекер ошибок. Sentry, GlitchTip или свой
sys.excepthookдают дедупликацию, группировку и значения локальных переменных.
Полезные инструменты стандартной библиотеки:
import sys, traceback, faulthandler
sys.exception() # текущее обрабатываемое исключение (3.12+)
traceback.format_exception(exc) # список строк — для своего форматтера
traceback.TracebackException.from_exception(exc) # структура без ссылок на кадры (безопасно хранить)
sys.excepthook = my_hook # непойманное в главном потоке
threading.excepthook = my_thread_hook # непойманное в потоке (3.8+)
sys.unraisablehook = my_unraisable # ошибки в __del__ и GC
faulthandler.enable() # трассировка при segfault и по сигналу
Ресурсы, finally и контекстные менеджеры
finally гарантирует уборку, но писать его руками для каждого ресурса — шум. Идиома —
with (подробно в идиоматичном Python).
Две ловушки, связанные именно с исключениями.
__exit__, вернувший истину, глотает исключение.
class Swallow:
def __enter__(self): return self
def __exit__(self, exc_type, exc, tb) -> bool:
return True # ← молча съедает ВСЁ, что произошло внутри with
with Swallow():
raise ValueError("исчезну")
print("сюда мы попадём, а ошибки не будет")
Возвращайте None (или False), если не собираетесь подавлять. Забытый return True
в __exit__ — один из самых трудноуловимых багов в Python.
StopIteration внутри генератора превращается в RuntimeError —
PEP 479, обязательное поведение с 3.7:
def gen():
yield 1
raise StopIteration("хотел закончить")
list(gen())
# RuntimeError: generator raised StopIteration
# (причина сохранена в __cause__)
Раньше такой StopIteration тихо обрывал цикл, и баг маскировался под пустой результат.
Завершайте генератор return, а не raise StopIteration.
Для динамического набора ресурсов — contextlib.ExitStack:
from contextlib import ExitStack
with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
# при исключении на третьем файле первые два корректно закроются
Исключения и типизация
У Python нет проверяемых исключений. Ни mypy, ни pyright не отслеживают, что
функция бросает: аннотации описывают возвращаемый тип, а не множество ошибок. Это
сознательный выбор — Java-модель throws на практике приводит к catch (Exception e) {} —
но плата реальна: узнать, что бросает чужая библиотека, можно только из документации,
исходников или прода.
Что с этим делают на практике:
from typing import NoReturn
def fail(msg: str) -> NoReturn:
"""NoReturn говорит проверяющему, что функция никогда не возвращает управление."""
raise ConfigError(msg)
- Документируйте
Raises:в docstring публичных функций — это единственный контракт. - Прокидывайте ошибки через типы там, где «ошибка» — часть нормального результата
(
dict.get→None,re.match→Match | None), и через исключения там, где это исключительная ситуация. - Result-типы (
returns,result) в Python возможны, но неидиоматичны: они не комбинируются с генераторами,withи стандартной библиотекой и требуют дисциплины всей команды. Сравнение подходов — в функциональном программировании.
Подробнее про строгий mypy — в аннотациях типов.
Честно о месте Python-модели
Где выигрывает:
- Короткий счастливый путь. Нет
if err != nilчерез каждую вторую строку, как в Go; бизнес-логика читается как последовательность шагов. - Богатый контекст. Трассировка со значениями кадров, цепочка причин, заметки — всё бесплатно и из коробки.
- Единый механизм. Итерация, контекстные менеджеры, асинхронная отмена,
argparse— всё построено на одном примитиве.
Где проигрывает:
- Невидимый поток управления. Любая строка может бросить что угодно. Инвариант «после этой строки объект в согласованном состоянии» приходится держать в голове.
- Нет статической проверки полноты. Компилятор не скажет, что вы забыли обработать
новый тип ошибки. В Rust это ловит исчерпывающий
matchпоResult, в Python — только тесты и ревью. - Зоопарк библиотечных исключений.
requests,httpx,psycopg,boto3— у каждой своя иерархия, и без слоя трансляции они протекают по всему приложению. - Мутабельное состояние переживает исключение. В отличие от Elixir с его «let it crash» и изолированными процессами, упавшая функция в Python оставляет полуизменённые объекты живыми. Отсюда правило: сначала вычислить, потом присвоить.
Вывод прагматичный: исключения — это хорошо, но их надо ограничивать границами.
Именно поэтому раздел про трансляцию ошибок в этой статье длиннее, чем раздел про
синтаксис try.
Типичные ошибки и грабли
except Exception: pass. Приложение продолжает работать в неопределённом состоянии. Если подавление осознанное —contextlib.suppress(КонкретныйТип)с комментарием, почему это безопасно.- Голый
except:. ЛовитKeyboardInterruptиSystemExit. Процесс перестаёт останавливаться по Ctrl+C и корректно завершаться в контейнере. - Слишком широкий перехват в середине стека.
except Exceptionвокруг вызова репозитория маскирует опечаткуAttributeErrorпод «ошибку БД». raiseбезfromвнутриexcept. Формально работает, но в трассировке появляется «During handling…», что читается как баг обработчика.raise type(exc)(str(exc)). Теряются поля, цепочка и трассировка. Нужно перебросить — пишите голыйraise.assertдля валидации. Флаг-O(иPYTHONOPTIMIZE) вырезаетassertиз байткода:python3 -Oвыполнитcheck(-5)без единого возражения.assert— для внутренних инвариантов и тестов, для проверки входа —if ...: raise ValueError.- Исключение как флаг в горячем цикле. 110 нс против 15 нс на промах. На миллионах итераций разница заметна — см. производительность.
- Конструктор исключения не совпадает с
args. Объект не переживаетpickle: ломается multiprocessing, Celery, кэширование результатов. return/breakвfinally. Молча съедает летящее исключение.- Хранение исключения в долгоживущей структуре.
__traceback__держит кадры, кадры держат локальные переменные — утечка памяти на гигабайты. Хранитеtraceback.TracebackExceptionили строку. - Ловля
asyncio.CancelledErrorбез повторногоraise. Задача становится неотменяемой,TaskGroupи graceful shutdown ломаются. - Логирование на каждом уровне. Пять записей об одном событии в разных местах — и вы никогда не найдёте первопричину.
- Порядок
exceptот общего к частному.except Exceptionпередexcept ValueErrorделает вторую ветку мёртвым кодом; линтеры это ловят. - Пустая строка вместо ошибки. Возврат
None/""вместо исключения переносит падение на два слоя выше, где контекста уже нет.
Тестирование обработки ошибок
Ветки с ошибками — самый непокрытый тестами код в любом проекте, потому что «оно же редко». Именно поэтому в проде ломается ровно там.
import pytest
def test_missing_order_raises_domain_error(repo):
with pytest.raises(OrderNotFound, match="ORD-404") as info:
repo.get("ORD-404")
# проверяем не только тип, но и структуру
assert info.value.order_id == "ORD-404"
def test_db_failure_is_translated(repo, broken_conn):
with pytest.raises(TransientError) as info:
repo.get("ORD-1")
# причина сохранена — значит from exc не забыли
assert isinstance(info.value.__cause__, psycopg.OperationalError)
def test_group(tg_runner):
with pytest.raises(ExceptionGroup) as info:
tg_runner()
assert len(info.value.exceptions) == 2
match= принимает регулярное выражение и проверяется по str(exc) — это заодно тест
на читаемость сообщения. Подробнее про pytest и фикстуры —
в тестировании.
Чек-лист перед мержем
- Нет голых
except:иexcept Exception: pass. - Каждое
raiseвнутриexceptсодержитfrom excилиfrom None. - Чужие исключения (драйверы БД, HTTP-клиенты) не выходят за пределы адаптера.
- У пакета есть корневой класс исключений; типов ровно столько, сколько стратегий обработки.
- Конструкторы исключений передают все аргументы в
super().__init__. - В
finallyнетreturn,breakиcontinue. - Ретраятся только идемпотентные операции и только временные ошибки; есть джиттер и дедлайн.
- Ожидаемые бизнес-ошибки логируются на WARNING/INFO, неожиданные — через
logger.exceptionс трассировкой. - Есть верхний обработчик, который не даёт процессу умереть молча.
- Ветки обработки ошибок покрыты тестами через
pytest.raises. assertне используется для валидации входных данных.
Мини-итог
Исключение в Python — обычный объект с полями __traceback__, __cause__, __context__
и __notes__; raise запускает разматывание стека и поиск обработчика по кадрам вызовов.
С 3.11 неиспользованный try бесплатен, а сам подъём стоит сотни наносекунд — это дёшево
для ошибок и дорого для управления потоком в горячем цикле. Ловите Exception, а не
BaseException; узко, а не широко; переводите чужие исключения в свои на границе адаптера
и обязательно с from exc. Проектируйте иерархию по числу стратегий обработки, а не по
числу мест сбоя. EAFP — не стилистика, а защита от TOCTOU-гонок. ExceptionGroup и
except* закрывают параллельные сбои, add_note — контекст без новых классов. И помните
главное: у Python нет компилятора, который проверит вашу обработку ошибок, — эту работу
делают границы, тесты и ревью.
Источники
- Errors and Exceptions — официальный
туториал, включая
else,finallyи группы. - Built-in Exceptions — полная иерархия и семантика каждого класса.
- The
trystatement — формальная семантика из справочника языка. - PEP 3151 — реорганизация иерархии
OSError. - PEP 479 —
StopIterationвнутри генераторов. - PEP 654 —
ExceptionGroupиexcept*. - PEP 678 —
add_noteи__notes__. - PEP 3134 — цепочки исключений и
raise ... from. - PEP 765 — запрет
return/break/continueвfinally. - What’s New In Python 3.11 — zero-cost exceptions и точные указатели в трассировке.
- contextlib и
traceback —
suppress,ExitStack, программный разбор трассировок. - Luciano Ramalho, Fluent Python, 2nd ed. — главы о контекстных менеджерах и корутинах.
- Brett Slatkin, Effective Python, 3rd ed. — практические
рекомендации по исключениям и
try/except/else/finally. - Harry Percival, Bob Gregory, Architecture Patterns with Python — трансляция ошибок между слоями на реальном примере.
- tenacity и stamina — ретраи с бэкоффом и джиттером в продакшене.
- Michael Nygard, Release It!, 2nd ed. — стабильность, таймауты, circuit breaker и то, почему ретрай без бюджета опаснее ошибки.
Что дальше
Модули, пакеты и распространение: импорты, структура проекта, публикация —
разберём механику импортов и sys.path, циклические зависимости, компоновку пакета через
pyproject.toml, сборку wheel и публикацию на PyPI.