Свои классы исключений в большом Python-проекте
Когда весь код ловит голый Exception, отладка превращается в раскопки. Разбираем, как построить иерархию своих исключений, где её граница и чем платишь за каждый лишний класс.

Сервис отдаёт клиенту 500 Internal Server Error с текстом «что-то пошло не так», а в логах — трейсбек на KeyError где-то в глубине сериализации ответа платёжного шлюза. Проблема не в самой ошибке, а в том, что по трейсбеку невозможно понять: это баг в нашем коде, отвал внешнего API или пользователь прислал мусор. Разные причины требуют разной реакции — retry, 400 клиенту или алерт дежурному, — но код обрабатывает их одинаково, потому что все они прилетают как безымянные встроенные исключения.
Здесь разбираем, как проектировать иерархию собственных классов исключений в проекте от нескольких десятков тысяч строк: что выносить в свои типы, а что оставить встроенным, где граница ответственности и чем вы платите за каждый новый класс. Инвалидация внешних ошибок в свои на границе интеграции — отдельная большая тема, её касаемся только в части «где ловить». Примеры на Python 3.11+.
Зачем вообще свой класс, а не встроенный
Встроенных исключений хватает, пока проект маленький. Проблемы начинаются, когда одно и то же место кода вызывается из разных контекстов, а обработчик должен различать причины. Голый except Exception ловит и вашу бизнес-логику, и KeyboardInterrupt-подобные вещи (формально это BaseException, но MemoryError — уже Exception), и баги вроде опечатки в имени атрибута, которую вы бы хотели видеть как краш, а не глотать.
Свой класс решает три задачи разом:
- Различение по типу, а не по тексту.
except PaymentDeclinedнадёжнее, чемif 'declined' in str(e). Текст ошибки меняется, тип — нет. - Перенос данных. В экземпляр кладут код ошибки шлюза, ID заказа, retry-after — обработчик разбирает их как поля, а не парсит строку.
- Единая точка обработки. Один
except AppErrorна границе HTTP-слоя ловит всё «своё» и превращает в аккуратный JSON, а всё чужое пропускает наверх как настоящий баг.
Базовый класс и минимальная иерархия
Начните с одного корня для всего домена и разветвляйте по мере реальной необходимости, а не заранее. Иерархия из тридцати классов, написанная «на вырост», — это тридцать классов, которые никто не ловит по отдельности.
# errors.py — Python 3.11+
class AppError(Exception):
"""Корень всех ожидаемых ошибок домена.
Всё, что не наследует AppError, считаем багом."""
# человекочитаемое сообщение по умолчанию
default_message = "Внутренняя ошибка приложения"
# машиночитаемый код для API-ответа и логов
code = "app_error"
def __init__(self, message: str | None = None, **context):
self.message = message or self.default_message
# произвольные поля контекста: order_id, gateway_code и т.п.
self.context = context
super().__init__(self.message)
class ValidationError(AppError):
"""Данные от клиента не прошли проверку -> 400"""
default_message = "Некорректные данные запроса"
code = "validation_error"
class NotFoundError(AppError):
"""Запрошенный объект не существует -> 404"""
default_message = "Объект не найден"
code = "not_found"
class ExternalServiceError(AppError):
"""Сбой внешнего сервиса -> 502/503, кандидат на retry"""
default_message = "Внешний сервис недоступен"
code = "external_error"Дальше от ExternalServiceError наследуются конкретные интеграции, если их нужно различать: PaymentGatewayError, SearchIndexError. Но плодите подклассы только тогда, когда реально есть код, который ловит именно этот подкласс отдельно от родителя. Класс, который всегда ловится вместе с базовым, не нужен — хватит поля code.
Правило, которое экономит недели: новый класс исключения оправдан только если где-то есть except именно на него. Если различие нужно лишь в логах или ответе — это поле экземпляра, а не новый тип.Где ловить, а где пробрасывать
Главная ошибка в больших проектах — try/except на каждом втором вызове. Исключения для того и придуманы, чтобы промежуточный код о них не думал. Ловить нужно в трёх местах, не чаще:
- Граница интеграции. В модуле, который дёргает внешний API или БД: перехватить чужое исключение (
requests.Timeout,psycopg.OperationalError) и перевыбросить как своё черезraise ... from. Дальше по коду ходит только ваш тип. - Граница транзакции/юнита работы. Где нужно откатить и решить, retry или нет.
- Верхняя граница приложения. Один обработчик, который превращает
AppErrorв ответ, а всё остальное — логирует как баг и отдаёт 500.
Перевыброс с сохранением причины — обязателен, иначе теряете исходный трейсбек:
import httpx
from errors import ExternalServiceError
def fetch_rate(currency: str) -> float:
try:
resp = httpx.get(f"https://api.example.com/rate/{currency}", timeout=5)
resp.raise_for_status()
except httpx.HTTPError as exc:
# from exc сохраняет оригинальный трейсбек в __cause__
raise ExternalServiceError(
"Не удалось получить курс валюты",
currency=currency,
) from exc
return resp.json()["rate"]Без from exc в логах вы увидите только ExternalServiceError и не поймёте, был ли это таймаут или 500 от чужого сервиса.
Верхний обработчик на примере FastAPI
В веб-фреймворке вся иерархия сходится в один-два обработчика. Для FastAPI 0.11x это выглядит так:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from errors import AppError, ValidationError, NotFoundError
app = FastAPI()
# сопоставление типа ошибки с HTTP-статусом
STATUS_MAP = {
ValidationError: 400,
NotFoundError: 404,
}
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError):
status = STATUS_MAP.get(type(exc), 500)
return JSONResponse(
status_code=status,
content={
"error": {"code": exc.code, "message": exc.message,
"context": exc.context},
},
)Всё, что не AppError, до этого обработчика не дойдёт — его подхватит стандартный обработчик 500 фреймворка, и это правильно: незнакомая ошибка должна быть шумной.
Чем платите за иерархию
Свои исключения — не бесплатны. Реальные издержки, о которых стоит знать заранее:
| Решение | Что выигрываете | Чем платите |
|---|---|---|
Единый базовый AppError | Один except ловит всё «своё» | Соблазн ловить всё разом и глотать реальные баги домена |
| Глубокая иерархия подклассов | Точечная обработка | Классы, которые никто не ловит; рост связности модулей |
| Данные в полях экземпляра | Обработчик не парсит текст | Обязанность заполнять контекст в каждом месте raise |
Перевыброс from exc на границах | Чистый тип в домене, сохранён трейсбек | Boilerplate: обёртка вокруг каждой интеграции |
Типичные ошибки
- Наследование от
BaseExceptionвместоException. Ваши исключения станут проходить мимоexcept Exceptionи ронять то, что должно перехватываться. Наследуйтесь только отException. - Логика в
__init__исключения. Запрос к БД или форматирование через внешний сервис внутри конструктора ошибки — потенциальный второй сбой во время обработки первого. Конструктор должен только складывать поля. - Пустой
except AppError: pass. Проглоченная ошибка без лога — самый дорогой баг в проде: он невидим, пока не соберётся статистика недоплат или потерянных заказов. - Разные иерархии в соседних модулях. Если каждая команда завела свой
AppError, верхний обработчик не поймает половину. Один корень на проект.
Что делать дальше
Заведите errors.py с одним корнем и тремя-четырьмя подклассами под реальные ветки обработки — не больше. Настройте верхний обработчик, который маппит тип в статус, и убедитесь, что незнакомые исключения до него не доходят, а логируются как 500 с полным трейсбеком. Затем пройдитесь по интеграциям и оберните чужие исключения через raise ... from.
В проде смотрите на два показателя: долю ответов 500 (это ваши необёрнутые баги — их должно стремиться к нулю) и распределение по полю code среди 4xx/5xx — оно показывает, какая интеграция или проверка сыпется чаще всего. Следующий шаг после иерархии — структурное логирование: писать code и context отдельными полями в JSON-логи, чтобы фильтровать по ним в системе агрегации.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: Python docs — Built-in Exceptions, Python docs — Errors and Exceptions (raise from)
Частые вопросы
А если у меня не FastAPI, а Django или Flask?
Сколько классов исключений вообще нужно проекту?
Ловить Exception на верхнем уровне — это плохо?
Что делать, если исключение не перехватилось и упал воркер?
Стоит ли складывать в исключение чувствительные данные — пароли, токены?
raise from обязателен или можно просто raise MyError()?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.