Свои исключения, raise from и ExceptionGroup

⏱ 35 мин · 🧩 заданий: 5

Зачем свои исключения

Встроенные ValueError и RuntimeError слишком общие: по ним не отличить «пользователь не найден» от «опечатки в коде». Свои исключения:

  • называют проблему на языке предметной области (OrderAlreadyPaid);
  • позволяют ловить ошибки вашей библиотеки отдельно от чужих;
  • несут контекст в атрибутах (какой заказ, какая сумма).
class AppError(Exception):
    """Базовое исключение приложения."""

class NotFound(AppError):
    def __init__(self, entity, entity_id):
        self.entity = entity
        self.entity_id = entity_id
        super().__init__(f"{entity} с id={entity_id} не найден")

class PermissionDenied(AppError):
    pass

USERS = {1: "Анна"}

def get_user(user_id):
    if user_id not in USERS:
        raise NotFound("Пользователь", user_id)
    return USERS[user_id]

for uid in [1, 42]:
    try:
        print(get_user(uid))
    except NotFound as e:
        print("404:", e, "| атрибуты:", e.entity, e.entity_id)

Как проектировать иерархию

ShopError(Exception) ← одно базовое на приложение/библиотеку ├── ValidationError ← ошибки входных данных → HTTP 400 ├── NotFound ← нет объекта → HTTP 404 ├── PaymentError ← группа по подсистеме │ ├── InsufficientFunds │ └── CardExpired └── ExternalServiceError ← сбой внешнего API → HTTP 502

Правила:

  1. Одно базовое исключение на пакет — пользователь библиотеки может поймать «всё ваше» одним except ShopError.
  2. Наследуйтесь от Exception (не BaseException).
  3. Имя заканчивается на Error (или описывает ситуацию: NotFound, CardExpired).
  4. Иногда полезно наследоваться и от встроенного: class InvalidEmail(ShopError, ValueError) — тогда сработает и except ValueError.
  5. Не плодите классы без нужды: новое исключение нужно, если его будут ловить отдельно.

Цепочки исключений: raise from

Когда низкоуровневая ошибка превращается в ошибку уровня приложения, причину нужно сохранить:

import json

class ConfigError(Exception):
    pass

def load_config(text):
    try:
        return json.loads(text)
    except json.JSONDecodeError as e:
        raise ConfigError("Конфигурация повреждена") from e    # явная причина

load_config("{неправильный json")

В трассировке будут оба исключения, связанные фразой «The above exception was the direct cause of the following exception» (в окне вывода курса — «Это исключение стало прямой причиной следующего»). Запустите пример и посмотрите.

def demo(mode):
    try:
        {}["key"]
    except KeyError as e:
        if mode == "from":
            raise RuntimeError("явно") from e
        if mode == "implicit":
            raise RuntimeError("неявно")           # ошибка внутри except
        if mode == "none":
            raise RuntimeError("без цепочки") from None

for mode in ["from", "implicit", "none"]:
    try:
        demo(mode)
    except RuntimeError as err:
        print(f"{mode:9} cause={err.__cause__!r:16} context={err.__context__!r:16} "
              f"suppress={err.__suppress_context__}")
Запись __cause__ Сообщение в traceback
raise New from orig orig «direct cause of»
raise New внутри except — (__context__ = orig) «During handling of the above exception, another exception occurred»
raise New from None — исходная ошибка скрыта

from None уместен, когда исходная ошибка — деталь реализации, которая только запутает пользователя.

Заметки к исключениям: add_note

def process_row(i, row):
    try:
        return int(row)
    except ValueError as e:
        e.add_note(f"Строка CSV №{i}: {row!r}")       # Python 3.11+
        raise

for i, row in enumerate(["1", "2", "три"], start=1):
    process_row(i, row)

ExceptionGroup и except*

Иногда ошибок несколько одновременно: параллельные задачи в asyncio.TaskGroup, валидация всех полей формы. Для этого есть ExceptionGroup (Python 3.11+):

def validate(user):
    errors = []
    if not user.get("name"):
        errors.append(ValueError("имя обязательно"))
    if "@" not in user.get("email", ""):
        errors.append(ValueError("некорректный email"))
    if not isinstance(user.get("age"), int):
        errors.append(TypeError("возраст должен быть числом"))
    if errors:
        raise ExceptionGroup("Ошибки валидации", errors)

try:
    validate({"name": "", "email": "anna", "age": "28"})
except* ValueError as group:                     # обработать все ValueError из группы
    for e in group.exceptions:
        print("ValueError:", e)
except* TypeError as group:                      # и отдельно все TypeError
    for e in group.exceptions:
        print("TypeError:", e)

Обычный except ValueError не поймает ExceptionGroup — нужен except*, который «вынимает» из группы подходящие исключения. Несколько веток except* могут сработать для одной группы.

Исключения и слои приложения

Хорошая практика — переводить ошибки на границе слоёв:

class NotFound(Exception): ...

class Repository:                      # слой данных
    def get(self, order_id):
        data = {1: "заказ №1"}
        try:
            return data[order_id]
        except KeyError:
            raise NotFound(f"заказ {order_id}") from None

def http_handler(order_id):            # слой HTTP
    try:
        return 200, Repository().get(order_id)
    except NotFound as e:
        return 404, f"Не найден: {e}"
    except Exception:                  # всё неожиданное — 500 (и в лог!)
        return 500, "Внутренняя ошибка"

print(http_handler(1), http_handler(7))

KeyError — деталь хранилища; HTTP-слой знает только о NotFound. В FastAPI и Django это делают обработчики исключений (exception_handler, middleware).

assert — не для валидации

def apply_discount(price, percent):
    assert 0 <= percent <= 100, "процент вне диапазона"   # внутренний инвариант
    return price * (100 - percent) / 100

print(apply_discount(1000, 15))
try:
    apply_discount(1000, 150)
except AssertionError as e:
    print("AssertionError:", e)

python -O удаляет все assert. Поэтому данные от пользователя проверяют if ... raise ValueError, а assert оставляют для «этого не может быть» и тестов.

Вопрос на собеседовании

«Зачем нужен raise ... from?» — Чтобы при преобразовании одного исключения в другое сохранить исходную причину в __cause__: трассировка покажет обе ошибки, и отладка не потеряет контекст. from None наоборот скрывает исходную ошибку.

Итог

  • Своя иерархия: одно базовое исключение от Exception, подклассы по смыслу, контекст в атрибутах.
  • raise New from orig сохраняет причину (__cause__); from None скрывает её.
  • add_note добавляет пояснение к исключению.
  • ExceptionGroup и except* — для нескольких одновременных ошибок.
  • Переводите исключения на границах слоёв; assert — не валидация.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

Спроектируйте иерархию исключений для платёжного модуля: - PaymentError(Exception) — базовое; - InsufficientFunds(PaymentError) — хранит атрибуты balance и amount, а str(e) возвращает "Недостаточно средств: баланс 100, нужно 250"; - CardExpired(PaymentError). Функция pay(balance, amount, expired=False) бросает подходящее исключение или возвращает остаток.

Какие тесты проверяют код (3)
assert pay(500, 200) == 300
try:
    pay(100, 250)
    ok = False
except InsufficientFunds as e:
    ok = (e.balance, e.amount) == (100, 250) and str(e) == "Недостаточно средств: баланс 100, нужно 250"
assert ok

try:
    pay(1000, 1, expired=True)
    ok = False
except PaymentError as e:
    ok = type(e).__name__ == "CardExpired"
assert ok, "CardExpired должен ловиться через базовый PaymentError"

Задание 2 Программа на Python

Функция load_port(config) читает config["port"] и превращает в int. Любую ошибку (KeyError, ValueError) она должна превращать в ConfigError, сохранив исходную причину через raise ... from.

Какие тесты проверяют код (3)
assert load_port({"port": "8080"}) == 8080
try:
    load_port({})
    ok = False
except ConfigError as e:
    ok = isinstance(e.__cause__, KeyError)
assert ok, "ConfigError с причиной KeyError в __cause__"

try:
    load_port({"port": "abc"})
    ok = False
except ConfigError as e:
    ok = isinstance(e.__cause__, ValueError)
assert ok

Задание 3 Один вариант ответа

Чем отличаются __cause__ и __context__ исключения?

Задание 4 Один вариант ответа

Почему для проверки входных данных нельзя полагаться на assert?

Задание 5 Один вариант ответа

Что делает except* ValueError?

Войдите, чтобы отмечать пройденные уроки.