Собственные исключения и raise в Python

Встроенных исключений вроде ValueError и KeyError хватает для простых скриптов, но в приложении побольше они перестают говорить, что именно пошло не так. Собственные классы исключений превращают ошибки в часть API: вызывающий код ловит PaymentDeclined, а не гадает, какой из десятка ValueError прилетел. Ниже разобрано, как проектировать такие классы, когда и как использовать raise и raise from, как устроены else и finally, что такое ExceptionGroup и как правильно логировать ошибки.
Зачем нужны собственные исключения
Представьте функцию оплаты, которая может упасть по трем причинам: недостаточно средств, карта заблокирована, банк не ответил. Если все три случая выбрасывают ValueError с разным текстом, вызывающему коду придется разбирать строку сообщения, чтобы понять, повторять ли запрос. Это хрупко: поменяли формулировку, и логика сломалась.
Собственные классы решают сразу несколько задач:
- позволяют ловить конкретную ситуацию по типу, а не по тексту;
- несут дополнительные данные (код ошибки, сумму, идентификатор заказа) в виде атрибутов;
- группируются в иерархию, чтобы одним
exceptловить все ошибки вашего модуля; - отделяют ожидаемые бизнес-ошибки от настоящих багов вроде
TypeError.
Если вы только начинаете работать с исключениями, сначала прочитайте базовую статью про обработку исключений в Python, здесь речь пойдет о следующем шаге.
Простейший класс исключения
Исключение в Python - это обычный класс, унаследованный от Exception. Минимальное определение занимает две строки:
class InsufficientFunds(Exception):
"""Недостаточно средств на счете."""
def withdraw(balance: int, amount: int) -> int:
if amount > balance:
raise InsufficientFunds(f"Нужно {amount}, на счете {balance}")
return balance - amount
try:
withdraw(100, 250)
except InsufficientFunds as e:
print("Ошибка:", e) # -> Ошибка: Нужно 250, на счете 100
print(type(e).__name__) # -> InsufficientFunds
print(e.args) # -> ('Нужно 250, на счете 100',)
Тело класса может состоять только из докстринга, pass не нужен. Названия по соглашению PEP 8 заканчиваются на Error, если класс описывает ошибку (ConfigError, ValidationError). Для бизнес-ситуаций допустимы и выразительные имена без суффикса, как InsufficientFunds.
Наследуйтесь именно от Exception, а не от BaseException. Последний зарезервирован для системных сигналов вроде KeyboardInterrupt и SystemExit, и такие исключения не ловятся обычным except Exception, что почти всегда неожиданно для пользователей вашего кода.
Исключение с дополнительными данными
Чтобы передать в исключение структурированные данные, переопределите __init__ и обязательно вызовите super().__init__() с текстом сообщения. Тогда str(e) и трейсбек будут выглядеть правильно.
class InsufficientFunds(Exception):
def __init__(self, balance: int, amount: int):
self.balance = balance
self.amount = amount
self.shortage = amount - balance
super().__init__(f"Не хватает {self.shortage} (баланс {balance}, списание {amount})")
try:
raise InsufficientFunds(balance=100, amount=250)
except InsufficientFunds as e:
print(e) # -> Не хватает 150 (баланс 100, списание 250)
print(e.shortage) # -> 150
Теперь обработчик может предложить пользователю пополнить счет ровно на e.shortage, не разбирая строку. Если забыть вызвать super().__init__, str(e) вернет пустую строку или кортеж аргументов, и в логах окажется непонятное сообщение.
Короче записывается тот же класс через dataclass, но тогда сообщение формируют в __str__:
from dataclasses import dataclass
@dataclass(eq=False)
class HttpError(Exception):
status: int
url: str
def __str__(self) -> str:
return f"HTTP {self.status} при запросе {self.url}"
try:
raise HttpError(404, "/api/users/7")
except HttpError as e:
print(e) # -> HTTP 404 при запросе /api/users/7
print(e.status) # -> 404
Иерархия исключений
Встроенные исключения уже образуют дерево: ZeroDivisionError наследует ArithmeticError, FileNotFoundError наследует OSError, все они наследуют Exception. Обработчик except ArithmeticError ловит всех потомков. Тот же принцип стоит применить к своим ошибкам.
| Базовый класс | Примеры потомков | Когда возникает |
|---|---|---|
BaseException | SystemExit, KeyboardInterrupt | завершение программы, Ctrl+C |
Exception | все обычные ошибки | базовый класс для своих исключений |
ArithmeticError | ZeroDivisionError, OverflowError | ошибки вычислений |
LookupError | KeyError, IndexError | нет ключа или индекса |
OSError | FileNotFoundError, PermissionError, TimeoutError | файлы, сеть, система |
ValueError | UnicodeDecodeError | правильный тип, неверное значение |
Полный список встроенных исключений с объяснениями собран в статье коды ошибок Python.
Как спроектировать иерархию для своего модуля
Проверенная схема: один корневой класс на пакет или приложение, от него ветки по смыслу, на листьях конкретные ситуации. Если ошибка по смыслу совпадает со встроенной, наследуйтесь от обеих, тогда код, который ловит ValueError, продолжит работать.
class ShopError(Exception):
"""Корень всех ошибок магазина."""
class ValidationError(ShopError, ValueError):
"""Некорректные входные данные."""
class NotFoundError(ShopError, LookupError):
"""Товар, заказ или пользователь не найден."""
class PaymentError(ShopError):
"""Ошибки оплаты."""
class PaymentDeclined(PaymentError):
"""Банк отклонил платеж, повтор бесполезен."""
class PaymentGatewayTimeout(PaymentError):
"""Платежный шлюз не ответил, можно повторить."""
def handle(exc: Exception) -> str:
try:
raise exc
except PaymentGatewayTimeout:
return "повторить позже"
except PaymentError:
return "оплата невозможна"
except ShopError as e:
return f"ошибка магазина: {e}"
print(handle(PaymentGatewayTimeout())) # -> повторить позже
print(handle(PaymentDeclined())) # -> оплата невозможна
print(handle(NotFoundError("заказ 15"))) # -> ошибка магазина: заказ 15
print(isinstance(ValidationError(), ValueError)) # -> True
Порядок except важен: Python проверяет блоки сверху вниз и выполняет первый подходящий. Поэтому более конкретные классы всегда ставят выше общих. Если поставить except PaymentError первым, ветка с таймаутом никогда не сработает.
Не увлекайтесь глубиной. Два-три уровня и десяток классов закрывают потребности даже крупного сервиса. Класс исключения имеет смысл заводить, только если кто-то будет ловить его отдельно.
Оператор raise
У raise три формы, и каждая нужна в своей ситуации.
raise ValueError("возраст не может быть отрицательным") # экземпляр с сообщением
raise ValueError # класс: Python создаст экземпляр сам
raise # повторный выброс текущего исключения
Голый raise допустим только внутри блока except. Он пробрасывает пойманное исключение дальше без изменений, сохраняя исходный трейсбек. Типичный сценарий: записать ошибку в лог и отдать ее выше.
def load_user(user_id: int) -> dict:
try:
return {"id": user_id, "name": DATA[user_id]}
except KeyError:
print(f"Пользователь {user_id} не найден, пробрасываю дальше")
raise
DATA = {1: "Анна"}
try:
load_user(2)
except KeyError as e:
print("Выше поймали:", repr(e))
# -> Пользователь 2 не найден, пробрасываю дальше
# -> Выше поймали: KeyError(2)
Не пишите raise e вместо голого raise без нужды: в трейсбек добавится лишняя строка, указывающая на место повторного выброса.
Заметки к исключению через add_note
С Python 3.11 к любому исключению можно приложить пояснение методом add_note(). Заметки выводятся в трейсбеке после сообщения. Это удобно, когда нужно добавить контекст, не меняя тип исключения:
def parse_line(n: int, line: str) -> int:
try:
return int(line)
except ValueError as e:
e.add_note(f"строка {n} файла prices.txt")
raise
parse_line(17, "12,5")
# ValueError: invalid literal for int() with base 10: '12,5'
# строка 17 файла prices.txt
raise from: цепочки исключений
Частая задача - перевести низкоуровневую ошибку в ошибку своего уровня абстракции. Пользователю модуля конфигурации неинтересен KeyError из словаря, ему нужен ConfigError. Но и терять исходную причину нельзя, иначе отладка превратится в гадание. Для этого служит raise ... from ...:
class ConfigError(Exception):
pass
def get_port(env: dict) -> int:
try:
return int(env["PORT"])
except KeyError as e:
raise ConfigError("не задан параметр PORT") from e
except ValueError as e:
raise ConfigError(f"PORT должен быть числом, получено {env['PORT']!r}") from e
try:
get_port({"PORT": "80a"})
except ConfigError as e:
print(e) # -> PORT должен быть числом, получено '80a'
print(repr(e.__cause__)) # -> ValueError("invalid literal for int() with base 10: '80a'")
Исходное исключение сохраняется в атрибуте __cause__, а трейсбек показывает оба, связывая их фразой "The above exception was the direct cause of the following exception". Если же выбросить новое исключение внутри except без from, связь тоже сохранится (в атрибуте __context__), но с формулировкой "During handling of the above exception, another exception occurred". Она читается как "при обработке ошибки случилась еще одна", то есть как баг в обработчике. Поэтому при намеренном преобразовании всегда пишите from.
Форма raise NewError(...) from None подавляет показ исходного исключения. Применяйте ее, когда внутренняя причина действительно не несет полезной информации и только загромождает вывод, например при поиске в словаре с понятным сообщением об ошибке.
| Запись | __cause__ | Что видно в трейсбеке |
|---|---|---|
raise B внутри except A | None | A, затем B с фразой "During handling..." |
raise B from a | a | A, затем B с фразой "direct cause" |
raise B from None | None | только B |
try, except, else и finally
Полная форма конструкции содержит четыре блока. Каждый выполняется в своем случае:
try- код, который может выбросить исключение;except- выполняется, если исключение подходящего типа произошло;else- выполняется, только если вtryисключения не было;finally- выполняется всегда: после успеха, после обработанной ошибки, после необработанной, послеreturnиbreak.
def read_number(path: str) -> int | None:
f = None
try:
f = open(path, encoding="utf-8")
text = f.read()
except FileNotFoundError:
print("Файл не найден")
return None
else:
print("Файл прочитан, разбираю")
return int(text)
finally:
if f is not None:
f.close()
print("finally отработал")
read_number("нет_такого.txt")
# -> Файл не найден
# -> finally отработал
Зачем нужен else, если код можно просто дописать в try? Чтобы не ловить лишнего. В примере int(text) может выбросить ValueError, и если бы он стоял в try рядом с более широким обработчиком, ошибка разбора смешалась бы с ошибкой открытия файла. Правило: в try держите только строки, ошибки которых вы действительно собираетесь обработать.
Для закрытия файлов и соединений вместо finally обычно используют контекстный менеджер with, он делает то же самое короче. Но finally остается незаменимым для произвольной очистки: снять блокировку, вернуть настройку, удалить временный файл.
Ловушка: return внутри finally
Если в finally выполнить return, он заменит собой и возвращаемое значение, и летящее исключение. Ошибка просто исчезнет:
def broken() -> str:
try:
1 / 0
finally:
return "все хорошо" # ZeroDivisionError молча проглочен
print(broken()) # -> все хорошо
Python 3.14 выдает на такой код SyntaxWarning, но в 3.12 и 3.13 он работает молча. Не используйте return, break и continue внутри finally.
ExceptionGroup и except*
Иногда за одну операцию происходит несколько независимых ошибок: валидация формы нашла сразу три проблемы, из десяти параллельных запросов упали два. До Python 3.11 приходилось выбирать одну ошибку или изобретать свой контейнер. Теперь есть встроенный ExceptionGroup, который упаковывает список исключений, и синтаксис except* для их разбора.
def validate(user: dict) -> None:
errors = []
if not user.get("name"):
errors.append(ValueError("пустое имя"))
if not isinstance(user.get("age"), int):
errors.append(TypeError("age должен быть int"))
if "@" not in user.get("email", ""):
errors.append(ValueError("некорректный email"))
if errors:
raise ExceptionGroup("ошибки валидации", errors)
try:
validate({"name": "", "age": "20", "email": "test"})
except* ValueError as eg:
for e in eg.exceptions:
print("ValueError:", e)
except* TypeError as eg:
for e in eg.exceptions:
print("TypeError:", e)
# -> ValueError: пустое имя
# -> ValueError: некорректный email
# -> TypeError: age должен быть int
В отличие от обычного except, здесь могут сработать несколько веток подряд: каждая забирает из группы подходящие исключения, а в переменную попадает подгруппа, тоже ExceptionGroup. Если какие-то исключения остались необработанными, они пробрасываются дальше, снова в виде группы.
На практике ExceptionGroup чаще всего приходит из asyncio.TaskGroup: если упали несколько задач, вы получите группу со всеми ошибками. Подробнее о параллельных задачах в статье про async и await. Смешивать except и except* в одной конструкции try нельзя.
Логирование ошибок
print(e) выводит только сообщение и теряет самое ценное: трейсбек и цепочку причин. В реальном коде ошибки пишут через модуль logging, а внутри обработчика используют метод logger.exception(). Он логирует сообщение с уровнем ERROR и автоматически добавляет полный трейсбек вместе с __cause__ и заметками.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("shop.payments")
class PaymentDeclined(Exception):
pass
def pay(order_id: int, amount: int) -> bool:
try:
if amount > 10_000:
raise PaymentDeclined(f"лимит превышен: {amount}")
return True
except PaymentDeclined:
logger.warning("Заказ %s: платеж отклонен", order_id, exc_info=True)
return False
except Exception:
logger.exception("Заказ %s: непредвиденная ошибка", order_id)
raise
pay(42, 50_000)
# 2026-10-25 12:00:00,123 WARNING shop.payments: Заказ 42: платеж отклонен
# Traceback (most recent call last):
# ...
# PaymentDeclined: лимит превышен: 50000
Несколько правил, которые экономят часы при разборе инцидентов:
- Ожидаемые бизнес-ошибки логируйте уровнем WARNING или INFO, неожиданные уровнем ERROR через
logger.exception. - Передавайте параметры через
%sи аргументы, а не через f-строку: сообщение форматируется только если реально будет записано. - Не логируйте одну и ту же ошибку на каждом уровне стека. Либо обработайте и залогируйте, либо пробросьте выше.
- Никогда не пишите пустой
except: pass. Если ошибку действительно нужно проигнорировать, используйтеcontextlib.suppress(КонкретныйТип), это хотя бы явно.
Настройка обработчиков, ротации файлов и форматов подробно описана в статье логирование в Python.
Практический сценарий: повтор только для временных ошибок
Иерархия исключений приносит максимальную пользу, когда от типа ошибки зависит поведение программы. Типичный пример - повтор запроса к внешнему сервису. Повторять имеет смысл таймауты и перегрузку, но не отказ в доступе или неверные данные. Если временные ошибки помечены общим базовым классом, логика повтора укладывается в несколько строк:
import time
class ServiceError(Exception):
pass
class TemporaryError(ServiceError):
"""Можно повторить: таймаут, 503, перегрузка."""
class PermanentError(ServiceError):
"""Повтор бесполезен: 403, неверный запрос."""
def with_retry(func, attempts: int = 3, delay: float = 0.1):
for attempt in range(1, attempts + 1):
try:
return func()
except TemporaryError as e:
if attempt == attempts:
raise
print(f"Попытка {attempt} не удалась: {e}, повторяю")
time.sleep(delay * attempt)
calls = iter([TemporaryError("таймаут"), TemporaryError("503"), "ok"])
def flaky():
result = next(calls)
if isinstance(result, Exception):
raise result
return result
print(with_retry(flaky))
# -> Попытка 1 не удалась: таймаут, повторяю
# -> Попытка 2 не удалась: 503, повторяю
# -> ok
Функция with_retry ничего не знает о конкретных сервисах, ей достаточно базового класса. PermanentError пролетает сквозь нее сразу, без лишних ожиданий. На последней попытке голый raise отдает исходное исключение вызывающему коду с полным трейсбеком.
Типичные ошибки при работе с исключениями
- Слишком широкий
except Exceptionглубоко в коде. Он перехватывает и опечатки вродеNameError, превращая баг в молчаливо неверный результат. Широкие обработчики уместны только на верхнем уровне: в главном цикле, обработчике запроса веб-сервера, точке входа скрипта. - Исключения для обычного управления потоком. Если отсутствие ключа - нормальная ситуация, используйте
dict.get(), а неtry/except KeyErrorв горячем цикле. - Потеря исходной причины. Новое исключение внутри
exceptбезfrom eзапутывает трейсбек, а сfrom Noneможет скрыть важные детали. - Сообщение без контекста. Текст "неверное значение" бесполезен в логе. Пишите, какое значение, в каком поле и что ожидалось.
Чек-лист по своим исключениям
- Наследуйтесь от
Exception, заводите один корневой класс на приложение. - В
__init__вызывайтеsuper().__init__(сообщение). - При переводе ошибки в другую используйте
raise ... from e. - Ловите конкретные типы, более узкие обработчики ставьте выше.
- Для нескольких одновременных ошибок применяйте
ExceptionGroupиexcept*. - Логируйте через
logger.exception, а неprint.
Частые вопросы
От чего наследовать свое исключение: Exception или BaseException?
От Exception или его потомков. BaseException нужен только для исключений, которые не должны перехватываться общим except Exception, как KeyboardInterrupt. В прикладном коде таких случаев практически не бывает.
Как задать сообщение по умолчанию для своего исключения?
Сделайте аргумент необязательным: def __init__(self, msg="Доступ запрещен"): super().__init__(msg). Тогда raise AccessDenied и raise AccessDenied("нет роли admin") оба дадут осмысленный текст.
Как поймать несколько типов исключений в одном except?
Перечислите их кортежем: except (KeyError, IndexError) as e:. Если типы часто ловятся вместе, это сигнал, что им нужен общий базовый класс.
Чем raise отличается от assert?
assert предназначен для проверки внутренних инвариантов при отладке и отключается при запуске с флагом -O. Проверки входных данных и бизнес-правил всегда делайте через if и raise.
Где хранить классы исключений в проекте?
Обычно в отдельном модуле exceptions.py или errors.py внутри пакета. Так их легко импортировать и в коде, который выбрасывает ошибки, и в коде, который их ловит, без циклических импортов.