Короткий ответ
Гранулярность иерархии исключений должна отражать реальные различия в том, как вызывающий код должен реагировать на ошибку (повторить операцию, прервать транзакцию, показать пользователю конкретное сообщение), а не внутреннюю детализацию реализации библиотеки; устойчивый подход — ограниченное число стабильных публичных базовых классов исключений с возможностью добавлять более специфичные подклассы без слома уже написанного кода, который перехватывает базовые классы.
Как это работает подробнее
Если библиотека предоставляет только один общий DataAccessError, вызывающий код не может различить "временная сетевая проблема, стоит повторить" и "данные нарушают ограничение целостности, повторять бессмысленно" без парсинга текста ошибки, что хрупко и ломается при изменении формулировок. Если библиотека рассыпает десятки узких классов ошибок без общей структуры, внешний код вынужден либо перехватывать каждый класс по отдельности, либо ловить общий Exception, теряя весь смысл специализации; правильная практика — выстроить неглубокую иерархию (DataAccessError → TransientError / IntegrityError / NotFoundError и т.п.), где подклассы можно продолжать добавлять, не трогая уже объявленные базовые классы.
Пример кода
class DataAccessError(Exception):
"""Базовый класс всех ошибок доступа к данным библиотеки (публичный контракт)."""
class TransientError(DataAccessError):
"""Временная ошибка — операцию можно безопасно повторить."""
class IntegrityError(DataAccessError):
"""Нарушение ограничений данных — повтор без изменений бессмысленен."""
class NotFoundError(DataAccessError):
"""Запрошенная запись не найдена."""
def fetch_user(user_id):
try:
raise TimeoutError("db timeout")
except TimeoutError as exc:
raise TransientError("Таймаут при обращении к БД") from exc
# Потребитель может ловить узко (TransientError -> retry) или широко (DataAccessError -> log+502)Что использовать на практике
- Практика. Публичный контракт исключений library-кода стоит рассматривать как часть API наравне с сигнатурами функций: добавление нового более специфичного подкласса обычно безопасно (код, перехватывающий родителя, продолжит работать), а удаление или переименование существующего класса, или изменение его места в иерархии — это breaking change, требующий отдельного major-релиза или периода депрекации. Разные команды-потребители с разными требованиями (одним нужна гранулярная обработка retry-логики, другим достаточно "упало — залогировали и вернули 500") обе могут быть обслужены одной и той же неглубокой, но продуманной иерархией, если верхнеуровневые классы выбраны по осям, релевантным именно реакции на ошибку.
Подводные камни
- Риски. Проектируют иерархию исключений исходя из внутренней структуры реализации (отдельный класс ошибки на каждый внутренний модуль или драйвер БД) вместо того, чтобы отразить различия, важные для вызывающей стороны — в результате при внутреннем рефакторинге библиотеки приходится либо менять публичные классы исключений (breaking change), либо множить дублирующие классы. Также не документируют и не тестируют, какие именно классы исключений являются частью публичного контракта, из-за чего случайное изменение внутреннего исключения, от которого ничего не наследуется явно, тихо ломает код потребителей, перехватывавших его по имени.
- Компромисс. Более гранулярная иерархия даёт потребителям точный контроль над обработкой разных сценариев отказа, но увеличивает площадь публичного API, которую нужно поддерживать стабильной годами и документировать; слишком общая иерархия проще поддерживать автору библиотеки, но перекладывает сложность различения причин ошибки на всех потребителей. Критерий выбора — ожидаемое число различных стратегий обработки ошибок у реальных потребителей и готовность команды библиотеки поддерживать совместимость каждого публичного класса отдельно.