Короткий ответ
Если новые специфичные классы становятся настоящими подклассами существующего DataError и возбуждаются вместо него (а не вместе с полной заменой типа в местах, где raise возбуждал ровно DataError), весь код потребителей, перехватывающий except DataError, продолжает работать без изменений благодаря полиморфизму исключений, а потребители, которым нужна более точная обработка, могут постепенно переходить на перехват конкретных подклассов — риск сохраняется там, где потребители делают более узкие и хрупкие проверки (например, type(exc) is DataError, сравнение типа без учёта наследования) или полагаются на текст сообщения ошибки.
Как это работает подробнее
Механизм перехвата исключений в Python проверяет принадлежность классу через isinstance, который учитывает всю цепочку наследования — поэтому except DataError: продолжит перехватывать ConnectionDataError(DataError) и ValidationDataError(DataError) ровно так же, как раньше перехватывал голый DataError, если структура наследования правильно построена и библиотека просто начинает возбуждать более специфичные подклассы вместо родителя в тех же самых точках кода, где раньше возбуждался только родитель. Ломается именно тот код, который либо проверяет точный тип исключения без учёта наследования (type(exc) is DataError, а не isinstance(exc, DataError)), либо парсит текст сообщения ошибки как часть своей логики, либо зависит от того, что DataError — финальный, не расширяемый класс (например, через паттерн-матчинг по множеству атрибутов, специфичных только для старой версии).
Пример кода
# До 2.0: один общий класс для всех ошибок слоя данных
class DataError(Exception):
pass
# В 2.0: добавляем специализированные подклассы, НЕ убирая DataError
class ConnectionDataError(DataError):
pass
class ValidationDataError(DataError):
pass
def fetch(record_id):
raise ConnectionDataError(f"Не удалось получить запись {record_id}")
# Старый код потребителя продолжает работать без изменений благодаря наследованию:
try:
fetch(42)
except DataError as exc: # перехватывает и новый ConnectionDataError
print("Обработано как и раньше:", type(exc).__name__, exc)
# Новый код потребителя может уточнять обработку:
try:
fetch(42)
except ConnectionDataError:
print("Специфичная обработка: повторить позже")
except DataError:
print("Общая обработка прочих ошибок данных")Что использовать на практике
- Практика. Безопасный план релиза 2.0: ввести новые подклассы и начать возбуждать именно их (сохраняя наследование от
DataError) без удаления и без изменения смысла самогоDataError; задокументировать в changelog, чтоDataErrorостаётся валидным способом перехвата "любой ошибки данных", а новые подклассы — опциональное уточнение; при необходимости предоставить период, когда и старое, и новое поведение видимы одновременно (например, отдельный флаг или минорная версия 1.x, которая уже возбуждает подклассы, прежде чем 2.0 удалит что-либо ещё). Важно заранее опросить или иным образом оценить, какие потребители используют хрупкие паттерны проверки типа или парсинга сообщений, и явно предупредить их перед релизом, а не полагаться только на формальную корректность наследования.
Подводные камни
- Риски. Разработчики библиотеки предполагают, что раз наследование формально корректно, миграция автоматически безопасна для всех, и не учитывают реальные паттерны использования исключений у потребителей (сравнение точного типа, парсинг сообщения, сериализация типа исключения в лог/метрику, которая теперь покажет другое имя класса и может сломать алерты, настроенные на конкретную строку). Также полностью убирают возможность возбуждать именно
DataErrorнапрямую, вместо того чтобы на переходный период оставить его доступным как fallback для случаев, не покрытых новыми специфичными подклассами. - Компромисс. Более гранулярная иерархия исключений даёт потребителям точный контроль, но увеличивает площадь поверхности изменений при каждом релизе библиотеки и требует явной коммуникации и версионирования; сохранение единственного общего исключения проще поддерживать, но оставляет всех потребителей без возможности различать сценарии ошибок программно. Риск и объём работы на стороне библиотеки и потребителей нужно сопоставить с реальной потребностью в более точной обработке ошибок у большинства из десятков команд, а не проектировать иерархию исходя только из внутреннего удобства авторов библиотеки.