Свои исключения или стандартные в Python-библиотеке
Что бросать из публичной функции библиотеки: ValueError или собственный класс. Разбираем цену обоих подходов, иерархию исключений и как не сломать код тех, кто вас импортирует.

Пользователь вашей библиотеки пишет except ValueError, чтобы поймать неверный аргумент вашей функции — и заодно ловит ValueError, который прилетел из int() где-то в глубине его собственного кода. Он не может отличить одно от другого. Это не гипотетическая проблема: именно из-за неё requests заводит requests.exceptions.RequestException, а sqlalchemy — целое дерево sqlalchemy.exc. Разберём, когда стандартного исключения хватает, когда нужен свой класс, и как построить иерархию, чтобы её потом не пришлось ломать. Актуально для Python 3.8+, ничего версионно-специфичного здесь нет.
Главный критерий: сможет ли вызывающий отличить вашу ошибку от чужой
Вопрос не в стиле, а в том, что делает код на другой стороне import. Он ловит исключения, чтобы принять решение: повторить запрос, показать пользователю сообщение, откатить транзакцию. Чтобы принять решение, ему нужно точно понять источник ошибки.
Стандартное исключение из вашего API работает ровно до тех пор, пока в стеке нет второго такого же по другой причине. Как только появляется — except становится слишком широким или слишком узким, и вызывающий начинает разбирать текст сообщения. Разбор текста сообщения — это уже сломанный контракт: вы поменяете формулировку, у него отвалится ветка.
Исключение — часть публичного API ровно в той же мере, что и имена функций и типы аргументов. Смена типа брошенного исключения ломает вызывающих так же, как переименование метода.
Когда стандартного достаточно
Если ошибка означает нарушение общего контракта Python, а не специфику вашего домена — бросайте стандартное. Это честнее и понятнее:
- TypeError — аргумент не того типа: передали строку, где ждали список.
- ValueError — тип верный, значение бессмысленное: отрицательный размер буфера, пустая строка там, где нужна непустая.
- KeyError, IndexError — если ваш объект ведёт себя как словарь или последовательность и обращение по ключу промахнулось.
- NotImplementedError — метод-заглушка в базовом классе, который наследник обязан переопределить.
Ключевое: эти ошибки — про как вас вызвали, а не про что пошло не так в вашей логике. Ошибку вызова программист исправляет в своём коде и больше её не ловит. Ловить TypeError в проде смысла нет — это баг, а не штатная ситуация.
Когда нужен свой класс
Свой класс нужен, когда ошибка — часть штатного поведения, которую вызывающий будет ловить осознанно. Сетевой таймаут, недоступный ресурс, конфликт версий, невалидный ответ сервера — это не баг в коде пользователя, это событие, на которое он строит логику ретраев и фолбэков.
Как строить иерархию
Одно правило, которое экономит будущую боль: один базовый класс на всю библиотеку, от которого наследуются все ваши исключения. Тогда вызывающий может одной строкой отделить всё ваше от всего чужого.
# errors.py — модуль исключений библиотеки myclient (пример для Python 3.8+)
class MyClientError(Exception):
"""Базовый класс для всех ошибок библиотеки. Ловите его,
если хотите поймать любую нашу ошибку и ничего чужого."""
class ConnectionFailed(MyClientError):
"""Не удалось установить соединение с сервисом."""
class RequestTimeout(MyClientError):
"""Сервис не ответил за отведённое время."""
class InvalidResponse(MyClientError):
"""Сервер вернул ответ, который не удалось разобрать."""
def __init__(self, message, status_code=None, body=None):
super().__init__(message)
# Дополнительный контекст для вызывающего — не только текст
self.status_code = status_code
self.body = body
Теперь на стороне пользователя работают оба уровня:
from myclient import MyClientError, RequestTimeout
try:
data = client.fetch(url)
except RequestTimeout:
# Узкий случай: имеет смысл повторить запрос
data = client.fetch(url)
except MyClientError as exc:
# Всё остальное наше — логируем и сдаёмся
logger.error("Клиент не смог получить данные: %s", exc)
raise
Множественное наследование от стандартных
Иногда хочется и свой класс, и совместимость со стандартным ловцом. Наследуйте от обоих:
class ConfigValueError(MyClientError, ValueError):
"""Некорректное значение в конфиге. Ловится и как наша ошибка,
и как обычный ValueError — для тех, кто уже писал except ValueError."""
Это приём стандартной библиотеки: например, у встроенных исключений так устроена совместимость между новыми и старыми именами. Но не злоупотребляйте — каждый лишний базовый класс расширяет то, чем вас случайно поймают. Если сомневаетесь, наследуйтесь только от своего базового.
Сравнение подходов
| Критерий | Стандартное исключение | Свой класс |
|---|---|---|
| Отличимость от чужих ошибок | Нет | Да, по типу |
| Нужен ли импорт для ловли | Нет | Да, из вашего пакета |
| Дополнительный контекст (код ответа, URL) | Только в тексте | Атрибуты объекта |
| Порог входа для пользователя | Ниже: всё знакомо | Выше: надо прочитать docs |
| Когда уместно | Нарушение общего контракта вызова | Штатная доменная ошибка, которую ловят осознанно |
Что ломается на практике
- Проглоченная причина. Вы ловите
OSErrorиз сокета и бросаете свойConnectionFailedбезraise ... from. Пользователь видит ваш класс, но в трейсбеке теряется, что реально произошло на уровне ОС. Всегда пробрасывайте причину:raise ConnectionFailed("нет связи") from exc. - Слишком широкий базовый класс. Наследование от
Exception— норма. Наследование отBaseException— почти всегда ошибка: ваше исключение начнёт ловиться там, где ждут толькоKeyboardInterrupt, и голыйexcept Exceptionпользователя его пропустит. - Смена типа в минорной версии. Заменили
ValueErrorна свой класс в патче — сломали всех, кто ловилValueError. Это breaking change, его место в мажорной версии, и об этом надо написать в changelog. - Исключение вместо возвращаемого значения. Если «ошибка» — ожидаемый и частый исход (искомого нет в кеше), исключение дорого и шумно. Иногда честнее вернуть
Noneили sentinel, а исключение оставить для по-настоящему нештатного.
Пробрасывание причины — коротко
import socket
def connect(host, port):
try:
sock = socket.create_connection((host, port), timeout=5)
except socket.timeout as exc:
# from exc сохраняет исходный трейсбек в __cause__
raise RequestTimeout(f"{host}:{port} не ответил") from exc
except OSError as exc:
raise ConnectionFailed(f"не удалось подключиться к {host}") from exc
return sock
Без from Python всё равно покажет «During handling of the above exception», но с from exc связь становится явной, и инструменты вроде логгеров и Sentry разбирают цепочку корректно.
Что делать дальше
Заведите отдельный модуль exceptions.py (или errors.py) и экспортируйте все публичные классы из __init__.py пакета — чтобы пользователь писал from mylib import MyLibError, а не лез во внутренний модуль. Задокументируйте в docstring каждой публичной функции, какие исключения она бросает и в каком случае: для вызывающего это такой же контракт, как типы аргументов. И зафиксируйте правило для себя: смена типа брошенного исключения идёт только в мажорную версию и попадает в changelog. Инвалидация старых имён исключений при рефакторинге — отдельная тема, здесь мы её не разворачиваем.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: Python docs — Built-in Exceptions, Python docs — Errors and Exceptions (chaining, raise from)
Частые вопросы
Наследоваться от Exception или от чего-то более конкретного?
А если у меня старая версия Python, синтаксис raise ... from работает?
Стоит ли добавлять свои исключения, если библиотека маленькая и внутренняя?
Что если код всё равно падает с чужим ValueError, хотя я бросаю свой класс?
Нужно ли документировать, какие исключения бросает функция?
Можно ли переопределять сообщение или атрибуты исключения между версиями?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.