Перейти к содержимому

Практика 8. Практическая работа 8. Кастомные исключения и иерархии

Цель

  • Научиться проектировать иерархию доменных исключений с общим базовым классом.
  • Освоить передачу структурированного контекста ошибки через атрибуты, а не текст.
  • Отработать цепочки исключений (raise ... from ..., __cause__, from None).
  • Познакомиться с группами исключений (ExceptionGroup / except*, Python 3.11+).
  • Распределить обработку ошибок по слоям: репозиторий → сервис → представление.

Краткая теория

  • Кастомное исключение — обычный класс, наследник Exception (не BaseException). Общий базовый класс приложения даёт точный (except ConcreteError) и категорийный (except AppError) перехват.
  • Контекст в атрибутах. Храните данные об ошибке в полях объекта (balance, field, error_code), а не парсите из строки. Всегда вызывайте super().__init__(message), чтобы сообщение попало в args и трейсбек.
  • Цепочки. raise New() from err явно связывает причину (__cause__). __context__ ставится автоматически, если новое исключение возникло внутри except. from None подавляет цепочку — только осознанно.
  • Группы. raise ExceptionGroup("...", [e1, e2]) сообщает о нескольких ошибках сразу; except* «расщепляет» группу по типам, и сразу несколько блоков могут сработать. До Python 3.11 — собственный класс со списком ошибок.
  • Слои. Репозиторий переводит технические ошибки в доменные, сервис делает откаты, представление решает, что показать пользователю. Подробности и примеры — в lecture_11.md.

Задания

Задание 1. Проектирование иерархии доменных исключений

Спроектируйте иерархию исключений для системы бронирования (booking).

Требования:

  1. Базовый класс BookingError(Exception) с полем error_code: str (по умолчанию "UNKNOWN") и переопределённым __str__, выводящим [CODE] message.
  2. Подклассы (наследники BookingError):
    • RoomNotFoundError(room_id) — код ROOM_NOT_FOUND, хранит room_id.
    • RoomAlreadyBookedError(room_id, date) — код ROOM_BUSY, хранит room_id, date.
    • InvalidBookingDataError(field, value, reason) — код INVALID_DATA, хранит field, value, reason.
  3. Каждый подкласс формирует осмысленное сообщение и вызывает super().__init__().

Скелет:

class BookingError(Exception):
def __init__(self, message: str, error_code: str = "UNKNOWN"):
super().__init__(message)
self.message = message
self.error_code = error_code
def __str__(self) -> str:
... # [CODE] message
class RoomNotFoundError(BookingError):
def __init__(self, room_id: str):
...

Проверьте: один except BookingError ловит все три подкласса; у пойманного объекта доступны атрибуты (e.room_id, e.error_code) без разбора строки.


Задание 2. Контекст и цепочки причин (raise ... from ...)

Реализуйте «перевод» технической ошибки в доменную с сохранением причины.

Требования:

  1. Класс RoomRepository с хранилищем dict[str, Room] и методами:
    • get(room_id) — при отсутствии ключа выбрасывает RoomNotFoundError, подавив внутренний KeyError через from None.
    • save(room) — оборачивает любую неожиданную ошибку в RepositoryError через raise ... from e; доменные исключения (InvalidBookingDataError) пробрасывает как есть.
  2. Добавьте в иерархию RepositoryError(BookingError) с кодом STORAGE_ERROR.

Скелет:

def get(self, room_id: str) -> Room:
try:
return self._storage[room_id]
except KeyError:
raise RoomNotFoundError(room_id) from None
def save(self, room: Room) -> Room:
try:
room.validate()
self._storage[room.id] = room
return room
except InvalidBookingDataError:
raise
except Exception as e:
raise RepositoryError("save") from e

Проверьте: в обёрнутой ошибке e.__cause__ указывает на исходное исключение; для RoomNotFoundError цепочка подавлена (__cause__ is None, __suppress_context__). Объясните в комментарии разницу между __cause__ и __context__.


Задание 3. Группы исключений и валидация формы (ExceptionGroup / except*)

Соберите все ошибки валидации сразу, а не первую попавшуюся.

Требования:

  1. Функция validate_booking(data: dict) -> None: накапливает ошибки в список и, если он непуст, выбрасывает ExceptionGroup("Ошибки валидации", errors). Проверки (минимум 3):
    • room_id непустой → иначе InvalidBookingDataError;
    • guests — целое число > 0 → иначе TypeError/ValueError;
    • date в формате YYYY-MM-DD → иначе ValueError.
  2. Обработчик с except*, который отдельно считает ошибки значений (ValueError, InvalidBookingDataError) и ошибки типов (TypeError), выводя количество каждой.

Скелет:

def validate_booking(data: dict) -> None:
errors: list[Exception] = []
if not data.get("room_id"):
errors.append(InvalidBookingDataError("room_id", None, "пусто"))
...
if errors:
raise ExceptionGroup("Ошибки валидации", errors)
try:
validate_booking({"room_id": "", "guests": -1, "date": "bad"})
except* ValueError as eg:
...
except* TypeError as eg:
...

Дополнительно: реализуйте запасной класс MultipleValidationErrors(Exception) со списком errors и методом by_type(error_type) для Python < 3.11.


Задание 4. Обработка по слоям: репозиторий → сервис → представление

Соберите трёхслойное приложение, распределив ответственность за ошибки.

Требования:

  1. Репозиторий (RoomRepository из задания 2) — переводит технические ошибки в доменные.
  2. Сервис BookingService(repository):
    • book(room_id, date, guests) — получает комнату, проверяет занятость (RoomAlreadyBookedError), сохраняет бронь;
    • при ошибке сохранения выполняет откат изменённого состояния и пробрасывает исключение (raise).
  3. Представление view_book(service, **data):
    • перехватывает ошибки по типам отдельными блоками (InvalidBookingDataError, RoomNotFoundError, RoomAlreadyBookedError, общий BookingError);
    • формирует понятное сообщение пользователю, опираясь на атрибуты исключения.

Скелет:

def view_book(service: BookingService, **data) -> None:
try:
booking = service.book(**data)
print(f"OK: бронь {booking.id}")
except InvalidBookingDataError as e:
print(f"Проверьте поле '{e.field}': {e.reason}")
except RoomNotFoundError as e:
print(f"Нет комнаты: {e.room_id}")
except BookingError as e:
print(f"Ошибка [{e.error_code}]: {e.message}")

Проверьте: только представление решает, что показать; нижние слои либо обрабатывают свою зону ответственности, либо пробрасывают ошибку наверх с сохранённым контекстом.


Задание 5*. Логирование цепочки и пакетная обработка (повышенной сложности)

  1. Напишите функцию log_chain(exc), которая печатает исключение и всю цепочку причин, идя по __cause__ / __context__ до конца (с отступами по уровням).
  2. Реализуйте book_many(service, requests) — обрабатывает список запросов, собирает все неуспешные в ExceptionGroup и выбрасывает её после прохода по всем элементам (частичный успех не теряется).

Критерии оценки

КритерийВес
Задание 1: корректная иерархия, общий базовый класс, контекст в атрибутах20%
Задание 2: raise ... from ..., from None, разделение доменных/технических ошибок20%
Задание 3: ExceptionGroup и except* (или запасной класс), сбор всех ошибок20%
Задание 4: распределение обработки по слоям, откат, перехват по типам20%
Задание 5*: логирование цепочки и пакетная обработка10%
Качество кода: имена, docstrings, super().__init__, отсутствие «глотания» ошибок10%

Штрафы: пустой except: без типа, except Exception: pass, парсинг данных из текста сообщения, потеря причины при «переводе» ошибки (raise без from).


Вопросы для самопроверки

  1. Почему кастомные исключения наследуют от Exception, а не от BaseException?
  2. Какие преимущества даёт общий базовый класс иерархии доменных исключений?
  3. Чем хранение данных об ошибке в атрибутах лучше передачи их в тексте сообщения?
  4. В чём разница между __cause__ и __context__? Когда заполняется каждый?
  5. Что делает raise New() from err и когда уместно from None?
  6. Чем except* отличается от обычного except? Может ли сработать несколько блоков?
  7. Как реализовать «группу исключений» в Python до версии 3.11?
  8. Как распределяется ответственность за ошибки между слоями репозиторий → сервис → представление?
  9. Почему откат состояния (компенсация) уместен именно в слое сервиса?
  10. Назовите три антипаттерна обработки исключений и их корректные альтернативы.