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

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

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

Обработка ошибок — это не «отлов падений». Это проектное решение о том, кто в системе знает, что делать, когда что-то пошло не так. Функция, которая читает конфиг, не знает, надо ли пользователю показать диалог, повторить попытку или упасть: у неё нет контекста. Зато у неё есть точная информация о том, что именно сломалось. Исключения — это механизм, который позволяет разнести эти два знания: место обнаружения проблемы и место принятия решения могут находиться в разных слоях приложения, и между ними не надо тащить коды возврата через каждый вызов.

В Python этот механизм доведён до предела: исключения тут не «аварийный выход», а штатный способ передать управление. Цикл for заканчивается через StopIteration, dict[key] промахивается через KeyError, импорт необязательной зависимости проверяется через ImportError. Отсюда вся идиоматика: EAFP, contextlib.suppress, контекстные менеджеры. И отсюда же главные грабли: нет проверяемых исключений, никто не заставит вас задокументировать, что функция бросает, а один except Exception: pass способен превратить продовый инцидент в неделю расследования.

Азы (try/except, чтение трассировки, отладка) разобраны в курсе Программирование с нуля — здесь они не повторяются. Мы разберём модель исполнения, дизайн иерархий, границы трансляции ошибок и продакшн-практику. Понимание того, что исключение — обычный объект, берём из модели данных, а with и генераторы — из идиоматичного Python.

Что происходит при raise: модель исполнения

Когда интерпретатор выполняет raise, происходит три вещи.

  1. Создаётся объект исключения — обычный экземпляр обычного класса. У него есть args, есть __dict__, ему можно добавить любые поля.
  2. К объекту привязывается трассировка (__traceback__) — односвязный список кадров стека. Она не «снимается снимком»: каждый кадр, через который исключение пролетает наверх, дописывается в цепочку. Поэтому трассировка читается сверху вниз от внешнего вызова к месту сбоя.
  3. Начинается разматывание стека (unwinding): интерпретатор идёт по кадрам вверх и в каждом ищет подходящий обработчик. Не найдя — выполняет finally и __exit__ контекстных менеджеров этого кадра, уничтожает кадр и идёт дальше.

Ключевое следствие: поиск обработчика идёт по стеку вызовов, а не по тексту программы. Вы можете поймать 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 — чтобы кадр не держал ссылку на трассировку, а трассировка на кадр (циклическая ссылка со всеми локальными переменными). Если исключение нужно позже, сохраните его в другую переменную явно — и помните, что вы удерживаете в памяти весь стек с данными.

Анатомия объекта исключения: traceback, cause, context, notes

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 выигрывает по трём причинам.

  1. Атомарность. LBYL создаёт классическую TOCTOU-гонку (time-of-check to time-of-use): между exists() и open() файл может исчезнуть, ключ — быть удалён другим потоком, строка в БД — измениться. Проверка + действие никогда не атомарны, а «попробовать» атомарно по построению.
  2. Полнота. Проверить все предусловия обычно невозможно. os.path.exists вернёт True, а open упадёт с PermissionError, IsADirectoryError или OSError: too many open files.
  3. Утиная типизация. Проверять 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 внутри генератора превращается в RuntimeErrorPEP 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.getNone, re.matchMatch | 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.

Типичные ошибки и грабли

  1. except Exception: pass. Приложение продолжает работать в неопределённом состоянии. Если подавление осознанное — contextlib.suppress(КонкретныйТип) с комментарием, почему это безопасно.
  2. Голый except:. Ловит KeyboardInterrupt и SystemExit. Процесс перестаёт останавливаться по Ctrl+C и корректно завершаться в контейнере.
  3. Слишком широкий перехват в середине стека. except Exception вокруг вызова репозитория маскирует опечатку AttributeError под «ошибку БД».
  4. raise без from внутри except. Формально работает, но в трассировке появляется «During handling…», что читается как баг обработчика.
  5. raise type(exc)(str(exc)). Теряются поля, цепочка и трассировка. Нужно перебросить — пишите голый raise.
  6. assert для валидации. Флаг -OPYTHONOPTIMIZE) вырезает assert из байткода: python3 -O выполнит check(-5) без единого возражения. assert — для внутренних инвариантов и тестов, для проверки входа — if ...: raise ValueError.
  7. Исключение как флаг в горячем цикле. 110 нс против 15 нс на промах. На миллионах итераций разница заметна — см. производительность.
  8. Конструктор исключения не совпадает с args. Объект не переживает pickle: ломается multiprocessing, Celery, кэширование результатов.
  9. return/break в finally. Молча съедает летящее исключение.
  10. Хранение исключения в долгоживущей структуре. __traceback__ держит кадры, кадры держат локальные переменные — утечка памяти на гигабайты. Храните traceback.TracebackException или строку.
  11. Ловля asyncio.CancelledError без повторного raise. Задача становится неотменяемой, TaskGroup и graceful shutdown ломаются.
  12. Логирование на каждом уровне. Пять записей об одном событии в разных местах — и вы никогда не найдёте первопричину.
  13. Порядок except от общего к частному. except Exception перед except ValueError делает вторую ветку мёртвым кодом; линтеры это ловят.
  14. Пустая строка вместо ошибки. Возврат 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 try statement — формальная семантика из справочника языка.
  • PEP 3151 — реорганизация иерархии OSError.
  • PEP 479StopIteration внутри генераторов.
  • PEP 654ExceptionGroup и except*.
  • PEP 678add_note и __notes__.
  • PEP 3134 — цепочки исключений и raise ... from.
  • PEP 765 — запрет return/break/continue в finally.
  • What’s New In Python 3.11 — zero-cost exceptions и точные указатели в трассировке.
  • contextlib и tracebacksuppress, 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.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов