Когда в коде что-то идёт не так, Python выбрасывает исключение. Стандартная библиотека предоставляет около 70 встроенных типов исключений: ValueError, TypeError, FileNotFoundError, KeyError и другие. Может возникнуть вопрос: зачем создавать собственные, если стандартных так много?
Рассмотрим реальный сценарий. Функция обработки платежа падает с ошибкой. В первом варианте:
raise RuntimeError("Payment declined: insufficient funds")Во втором:
raise InsufficientFundsError(
amount=1500,
available=300,
currency="RUB"
)Первый вариант — просто строка с текстом. Код, который перехватывает это исключение, вынужден парсить строку, чтобы понять причину. Это хрупко и неудобно.
Второй вариант — объект с данными. Код-перехватчик может обратиться к exc.amount, exc.available и принять решение на основе структурированных данных: предложить пополнить баланс, предложить рассрочку, вернуть конкретный HTTP-статус.
Собственные исключения дают три ключевых преимущества.
Первое — точная диагностика. except InsufficientFundsError поймает именно недостаток средств, а не любую ошибку при оплате. Разные типы ошибок обрабатываются разными блоками except.
Второе — структурированные данные. Исключение может нести любую информацию: поле с ошибкой, код ошибки, HTTP-статус, детали для лога. Это не просто строка.
Третье — иерархия. except PaymentError поймает и InsufficientFundsError, и CardDeclinedError, и PaymentTimeoutError — одним блоком. Это удобно, когда нужна общая обработка для категории ошибок.
Создать собственное исключение предельно просто:
class MyError(Exception):
passЭто полноценное, рабочее исключение. Можно выбрасывать, ловить, передавать сообщение:
raise MyError("Что-то пошло не так")try:
raise MyError("Произошла ошибка")
except MyError as e:
print(e) # Произошла ошибка
print(repr(e)) # MyError('Произошла ошибка')
print(e.args) # ('Произошла ошибка',)Атрибут args — кортеж аргументов, переданных при создании исключения. __str__ по умолчанию возвращает str(args[0]) если аргумент один, или строку кортежа если их несколько. Всё это наследуется от Exception автоматически.
Теперь разберём, почему наследоваться нужно именно от Exception, а не от BaseException.
В Python иерархия базовых классов исключений выглядит так:
BaseException
├── SystemExit ← sys.exit() — завершение программы
├── KeyboardInterrupt ← Ctrl+C — прерывание пользователем
├── GeneratorExit ← закрытие генератора
└── Exception ← ВСЕ обычные исключения
├── StopIteration
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── KeyError
│ └── IndexError
├── ValueError
├── TypeError
├── RuntimeError
└── ... (около 60 других)
SystemExit, KeyboardInterrupt и GeneratorExit наследуют от BaseException напрямую — они не являются «ошибками» в обычном смысле, это системные события. Поэтому except Exception не поймает KeyboardInterrupt — и это правильно: если пользователь нажал Ctrl+C, программа должна завершиться, а не поглотить это событие в except Exception.
Именно поэтому кастомные исключения должны наследоваться от Exception: они являются обычными ошибками приложения, которые должны перехватываться стандартными except блоками.
Никогда не наследуйтесь от BaseException напрямую, если только вы не создаёте системное событие — что в прикладном коде практически никогда не нужно.
Настоящая сила собственных исключений — в дополнительных атрибутах. Исключение становится объектом данных, несущим всю необходимую информацию об ошибке:
class ValidationError(Exception):
"""
Ошибка валидации входных данных.
Несёт информацию о конкретных полях с ошибками.
"""
def __init__(self, message, field=None, errors=None):
# Передаём message в родительский Exception через super().__init__()
# Это гарантирует, что str(exc) и exc.args работают корректно
super().__init__(message)
self.message = message
self.field = field # конкретное поле с ошибкой (если одно)
self.errors = errors or {} # словарь {поле: список_ошибок} (если несколько)
def __str__(self):
if self.errors:
error_list = "; ".join(
f"{field}: {', '.join(msgs)}"
for field, msgs in self.errors.items()
)
return f"Ошибки валидации: {error_list}"
if self.field:
return f"Ошибка поля '{self.field}': {self.message}"
return self.message
def __repr__(self):
return (f"ValidationError(message={self.message!r}, "
f"field={self.field!r}, errors={self.errors!r})")Демонстрируем разные способы использования:
# Простая ошибка с сообщением
try:
raise ValidationError("Email обязателен")
except ValidationError as e:
print(e) # Email обязателен
print(e.field) # None
# Ошибка конкретного поля
try:
raise ValidationError("Некорректный формат", field="email")
except ValidationError as e:
print(e) # Ошибка поля 'email': Некорректный формат
print(e.field) # email
# Ошибки нескольких полей (результат полной валидации формы)
try:
raise ValidationError(
"Форма содержит ошибки",
errors={
"username": ["Слишком короткое (минимум 3 символа)"],
"email": ["Некорректный формат", "Домен не существует"],
"age": ["Должно быть числом"],
}
)
except ValidationError as e:
print(e)
# Ошибки валидации: username: Слишком короткое (минимум 3 символа);
# email: Некорректный формат, Домен не существует;
# age: Должно быть числом
# Машинная обработка — работаем с атрибутами, а не парсим строку
for field, msgs in e.errors.items():
print(f" Поле '{field}': {msgs}")Обратите внимание на вызов super().__init__(message). Это важно: он устанавливает self.args = (message,), что обеспечивает корректную работу встроенного __str__ и корректное отображение исключения в трейсбеке. Если не вызвать super().__init__(), атрибут args останется пустым кортежем, и некоторые инструменты логирования не смогут правильно отобразить исключение.
Реальные приложения организуют исключения в иерархии. Это позволяет ловить ошибки как точечно (конкретный тип), так и широко (вся категория):
# Уровень 1: базовое исключение всего приложения
class AppError(Exception):
"""Базовое исключение приложения. Все кастомные ошибки наследуют от него."""
pass
# Уровень 2: категории ошибок
class DatabaseError(AppError):
"""Ошибки при работе с базой данных."""
pass
class AuthError(AppError):
"""Ошибки аутентификации и авторизации."""
pass
class ValidationError(AppError):
"""Ошибки валидации данных."""
def __init__(self, message, errors=None):
super().__init__(message)
self.errors = errors or {}
# Уровень 3: конкретные ошибки
class DatabaseConnectionError(DatabaseError):
"""Не удалось подключиться к базе данных."""
def __init__(self, host, port, original_error=None):
super().__init__(f"Не удалось подключиться к {host}:{port}")
self.host = host
self.port = port
self.original_error = original_error
class DatabaseQueryError(DatabaseError):
"""Ошибка выполнения запроса."""
def __init__(self, query, message):
super().__init__(f"Ошибка запроса: {message}")
self.query = query
class AuthenticationFailedError(AuthError):
"""Неверные учётные данные."""
def __init__(self, username):
super().__init__(f"Аутентификация не удалась для пользователя {username!r}")
self.username = username
class PermissionDeniedError(AuthError):
"""Доступ запрещён."""
def __init__(self, user, action, resource):
super().__init__(
f"Пользователю {user!r} запрещено действие {action!r} "
f"над ресурсом {resource!r}"
)
self.user = user
self.action = action
self.resource = resourceТеперь код-перехватчик может реагировать на разных уровнях детализации:
def handle_request(user, action, resource):
try:
# Имитация бизнес-логики
if user == "anonymous":
raise AuthenticationFailedError(user)
if resource == "admin_panel" and user != "admin":
raise PermissionDeniedError(user, action, resource)
if resource == "broken_db":
raise DatabaseConnectionError("db.example.com", 5432)
return {"success": True, "data": f"Результат для {resource}"}
except AuthenticationFailedError as e:
# Точный перехват — знаем, что это ошибка аутентификации
return {"error": "auth_failed", "message": str(e), "status": 401}
except PermissionDeniedError as e:
# Знаем конкретно: запрещённое действие
return {"error": "forbidden", "user": e.user,
"action": e.action, "status": 403}
except DatabaseError as e:
# Широкий перехват — любая ошибка БД
return {"error": "database_error", "message": str(e), "status": 503}
except AppError as e:
# Страховочная сетка — любая ошибка приложения
return {"error": "app_error", "message": str(e), "status": 500}
print(handle_request("anonymous", "read", "users"))
# {'error': 'auth_failed', 'message': "Аутентификация не удалась...", 'status': 401}
print(handle_request("bob", "delete", "admin_panel"))
# {'error': 'forbidden', 'user': 'bob', 'action': 'delete', 'status': 403}
print(handle_request("admin", "read", "broken_db"))
# {'error': 'database_error', 'message': 'Не удалось подключиться...', 'status': 503}Порядок блоков except важен: более специфичные типы должны идти раньше более общих. Если поставить except AppError первым — он поймает всё, и до except DatabaseError очередь никогда не дойдёт.
Часто новое исключение возникает в результате другого. Python предоставляет механизм явной цепочки исключений через raise ... from ...:
class ServiceError(Exception):
pass
class DatabaseError(Exception):
pass
def load_user_from_db(user_id):
# Имитируем ошибку на уровне БД
raise DatabaseError(f"Connection timeout при запросе пользователя {user_id}")
def get_user(user_id):
try:
return load_user_from_db(user_id)
except DatabaseError as db_error:
# Оборачиваем ошибку БД в ошибку сервисного уровня
# from db_error создаёт явную цепочку: __cause__ = db_error
raise ServiceError(f"Не удалось загрузить пользователя {user_id}") from db_error
try:
get_user(42)
except ServiceError as e:
print(f"Ошибка сервиса: {e}")
print(f"Причина: {e.__cause__}")Вывод трейсбека при raise ... from ...:
DatabaseError: Connection timeout при запросе пользователя 42
The above exception was the direct cause of the following exception:
ServiceError: Не удалось загрузить пользователя 42
Это очень удобно для отладки: трейсбек показывает полную цепочку причин.
Разберём три варианта поведения:
Вариант 1: raise NewError from original — явная цепочка. NewError.__cause__ = original. В трейсбеке: «The above exception was the direct cause of the following exception».
Вариант 2: raise NewError внутри except блока — неявная цепочка. NewError.__context__ = original. В трейсбеке: «During handling of the above exception, another exception occurred».
Вариант 3: raise NewError from None — скрытие причины. Полезно, когда детали реализации не должны «протекать» наружу:
class APIError(Exception):
pass
def call_external_api(endpoint):
try:
# Имитируем внутреннее исключение с деталями реализации
raise ConnectionError(f"SSL handshake failed for {endpoint}")
except ConnectionError:
# Скрываем детали реализации от внешнего кода
# from None обрывает цепочку — __cause__ и __context__ будут None
raise APIError(f"Внешний сервис недоступен") from None
try:
call_external_api("https://payments.example.com/charge")
except APIError as e:
print(e) # Внешний сервис недоступен
print(e.__cause__) # None — детали реализации скрыты
print(e.__context__) # Nonefrom None используется, когда вы намеренно создаёте абстракцию: пользователь API должен знать, что «внешний сервис недоступен», но не должен видеть внутренние детали вроде «SSL handshake failed».
В веб-разработке исключения часто должны конвертироваться в HTTP-ответы. Наиболее удобный подход — хранить всю необходимую информацию прямо в объекте исключения:
class HTTPException(Exception):
"""
Базовое HTTP-исключение. Аналог django.core.exceptions или
fastapi.HTTPException.
Хранит всё, что нужно для формирования HTTP-ответа:
статус-код, код ошибки, сообщение, детали.
"""
def __init__(self, message, status_code=500, error_code=None, details=None):
super().__init__(message)
self.message = message
self.status_code = status_code
self.error_code = error_code or f"HTTP_{status_code}"
self.details = details or {}
def to_dict(self) -> dict:
"""Конвертирует исключение в словарь для API-ответа."""
result = {
"error": self.error_code,
"message": self.message,
"status_code": self.status_code,
}
if self.details:
result["details"] = self.details
return result
def __str__(self):
return f"[{self.status_code}] {self.message}"
def __repr__(self):
return (f"{self.__class__.__name__}("
f"status={self.status_code}, "
f"code={self.error_code!r}, "
f"message={self.message!r})")
class BadRequestError(HTTPException):
"""400 Bad Request — некорректный запрос клиента."""
def __init__(self, message="Некорректный запрос", details=None):
super().__init__(message, status_code=400,
error_code="BAD_REQUEST", details=details)
class UnauthorizedError(HTTPException):
"""401 Unauthorized — требуется аутентификация."""
def __init__(self, message="Требуется аутентификация"):
super().__init__(message, status_code=401, error_code="UNAUTHORIZED")
class ForbiddenError(HTTPException):
"""403 Forbidden — доступ запрещён."""
def __init__(self, message="Доступ запрещён", required_permission=None):
details = {}
if required_permission:
details["required_permission"] = required_permission
super().__init__(message, status_code=403,
error_code="FORBIDDEN", details=details)
self.required_permission = required_permission
class NotFoundError(HTTPException):
"""404 Not Found — ресурс не найден."""
def __init__(self, resource_type, resource_id=None):
message = f"{resource_type} не найден"
if resource_id:
message = f"{resource_type} с id={resource_id!r} не найден"
super().__init__(message, status_code=404, error_code="NOT_FOUND")
self.resource_type = resource_type
self.resource_id = resource_id
class ValidationHTTPError(HTTPException):
"""422 Unprocessable Entity — ошибки валидации."""
def __init__(self, errors: dict):
super().__init__(
"Ошибки валидации входных данных",
status_code=422,
error_code="VALIDATION_ERROR",
details={"errors": errors}
)
self.validation_errors = errors
class InternalServerError(HTTPException):
"""500 Internal Server Error."""
def __init__(self, message="Внутренняя ошибка сервера"):
super().__init__(message, status_code=500,
error_code="INTERNAL_ERROR")Функция-обработчик, превращающая исключения в HTTP-ответы — аналог middleware в Django/FastAPI:
def exception_handler(func):
"""Декоратор: перехватывает исключения и конвертирует их в ответы."""
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except HTTPException as e:
return {"success": False, **e.to_dict()}
except Exception as e:
# Неожиданная ошибка — логируем и возвращаем 500
print(f"[ERROR] Неожиданное исключение: {type(e).__name__}: {e}")
server_error = InternalServerError()
return {"success": False, **server_error.to_dict()}
return wrapper
@exception_handler
def get_user(user_id, current_user):
if not current_user:
raise UnauthorizedError()
if user_id == 0:
raise NotFoundError("User", user_id)
if current_user.get("role") != "admin" and current_user.get("id") != user_id:
raise ForbiddenError(required_permission="admin")
return {"success": True, "user": {"id": user_id, "name": "Alice"}}
@exception_handler
def create_user(data, current_user):
errors = {}
if not data.get("username"):
errors["username"] = ["Поле обязательно"]
if not data.get("email") or "@" not in data.get("email", ""):
errors["email"] = ["Некорректный email"]
if errors:
raise ValidationHTTPError(errors)
return {"success": True, "user": {"id": 42, **data}}
# Тестируем
print(get_user(1, None))
# {'success': False, 'error': 'UNAUTHORIZED', 'message': 'Требуется аутентификация', 'status_code': 401}
print(get_user(0, {"id": 1, "role": "admin"}))
# {'success': False, 'error': 'NOT_FOUND', 'message': "User с id=0 не найден", 'status_code': 404}
print(get_user(99, {"id": 1, "role": "user"}))
# {'success': False, 'error': 'FORBIDDEN', ..., 'status_code': 403}
print(get_user(1, {"id": 1, "role": "admin"}))
# {'success': True, 'user': {'id': 1, 'name': 'Alice'}}
print(create_user({"username": "", "email": "bad"}, {"id": 1}))
# {'success': False, 'error': 'VALIDATION_ERROR', ..., 'status_code': 422}Реализуем систему обработки заказов с полноценной иерархией бизнес-исключений:
from decimal import Decimal
# Иерархия бизнес-исключений
class OrderError(Exception):
"""Базовое исключение для ошибок в системе заказов."""
def __init__(self, message, order_id=None):
super().__init__(message)
self.message = message
self.order_id = order_id
def to_dict(self) -> dict:
result = {"error_type": self.__class__.__name__, "message": self.message}
if self.order_id:
result["order_id"] = self.order_id
return result
class InsufficientFundsError(OrderError):
"""Недостаточно средств на счёте."""
def __init__(self, required: Decimal, available: Decimal,
currency: str, order_id=None):
super().__init__(
f"Недостаточно средств: требуется {required} {currency}, "
f"доступно {available} {currency}",
order_id=order_id
)
self.required = required
self.available = available
self.currency = currency
self.shortage = required - available
def to_dict(self) -> dict:
d = super().to_dict()
d.update({
"required": str(self.required),
"available": str(self.available),
"shortage": str(self.shortage),
"currency": self.currency,
})
return d
class ProductUnavailableError(OrderError):
"""Товар недоступен или закончился на складе."""
def __init__(self, product_id, product_name, requested_qty,
available_qty=0, order_id=None):
if available_qty == 0:
message = f"Товар '{product_name}' (id={product_id}) отсутствует на складе"
else:
message = (f"Товар '{product_name}' (id={product_id}): "
f"запрошено {requested_qty}, доступно {available_qty}")
super().__init__(message, order_id=order_id)
self.product_id = product_id
self.product_name = product_name
self.requested_qty = requested_qty
self.available_qty = available_qty
class OrderLimitExceededError(OrderError):
"""Превышен лимит заказов для пользователя."""
def __init__(self, user_id, current_count, max_allowed, order_id=None):
super().__init__(
f"Превышен лимит заказов: у пользователя {user_id} уже {current_count} "
f"активных заказов (максимум {max_allowed})",
order_id=order_id
)
self.user_id = user_id
self.current_count = current_count
self.max_allowed = max_allowed
class OrderService:
"""Сервисный слой для работы с заказами."""
MAX_ORDERS_PER_USER = 5
def __init__(self):
# Имитация данных
self._inventory = {
"prod_1": {"name": "Ноутбук", "qty": 3, "price": Decimal("89990")},
"prod_2": {"name": "Мышь", "qty": 0, "price": Decimal("1500")},
"prod_3": {"name": "Клавиатура", "qty": 10, "price": Decimal("3500")},
}
self._user_balances = {
"user_1": Decimal("150000"),
"user_2": Decimal("500"),
}
self._user_order_counts = {
"user_1": 2,
"user_2": 5,
}
def place_order(self, user_id: str, product_id: str,
quantity: int, order_id: str) -> dict:
"""
Оформляет заказ. Может выбросить:
- OrderLimitExceededError
- ProductUnavailableError
- InsufficientFundsError
"""
# Проверка лимита заказов
current_count = self._user_order_counts.get(user_id, 0)
if current_count >= self.MAX_ORDERS_PER_USER:
raise OrderLimitExceededError(
user_id=user_id,
current_count=current_count,
max_allowed=self.MAX_ORDERS_PER_USER,
order_id=order_id,
)
# Проверка наличия товара
product = self._inventory.get(product_id)
if not product:
raise ProductUnavailableError(
product_id=product_id,
product_name="(неизвестный)",
requested_qty=quantity,
order_id=order_id,
)
if product["qty"] < quantity:
raise ProductUnavailableError(
product_id=product_id,
product_name=product["name"],
requested_qty=quantity,
available_qty=product["qty"],
order_id=order_id,
)
# Проверка баланса
total_price = product["price"] * quantity
balance = self._user_balances.get(user_id, Decimal("0"))
if balance < total_price:
raise InsufficientFundsError(
required=total_price,
available=balance,
currency="RUB",
order_id=order_id,
)
# Всё проверено — оформляем заказ
self._inventory[product_id]["qty"] -= quantity
self._user_balances[user_id] -= total_price
self._user_order_counts[user_id] = current_count + 1
return {
"order_id": order_id,
"user_id": user_id,
"product": product["name"],
"quantity": quantity,
"total": str(total_price),
"status": "confirmed",
}
def process_order_request(service: OrderService, user_id: str,
product_id: str, quantity: int) -> dict:
"""
API-уровень: вызывает сервис и конвертирует бизнес-исключения в HTTP-ответы.
"""
import uuid
order_id = str(uuid.uuid4())[:8]
try:
result = service.place_order(user_id, product_id, quantity, order_id)
return {"success": True, "status": 200, **result}
except InsufficientFundsError as e:
# Специфический ответ: предлагаем пополнить баланс
return {
"success": False,
"status": 402,
**e.to_dict(),
"suggestion": f"Пополните баланс минимум на {e.shortage} {e.currency}",
}
except ProductUnavailableError as e:
return {"success": False, "status": 409, **e.to_dict()}
except OrderLimitExceededError as e:
return {
"success": False,
"status": 429,
**e.to_dict(),
"suggestion": "Дождитесь завершения активных заказов",
}
except OrderError as e:
# Страховочный блок для любой ошибки заказа
return {"success": False, "status": 400, **e.to_dict()}
service = OrderService()
# Успешный заказ
print(process_order_request(service, "user_1", "prod_1", 1))
# {'success': True, 'status': 200, 'order_id': ..., 'product': 'Ноутбук', ...}
print()
# Товар закончился
print(process_order_request(service, "user_1", "prod_2", 1))
# {'success': False, 'status': 409, ..., 'message': "Товар 'Мышь' отсутствует на складе"}
print()
# Недостаточно средств
print(process_order_request(service, "user_2", "prod_3", 1))
# {'success': False, 'status': 402, ..., 'suggestion': 'Пополните баланс...'}
print()
# Превышен лимит заказов
print(process_order_request(service, "user_2", "prod_1", 1))
# Сначала выбросит InsufficientFundsError (баланс 500 < 89990)
# Если бы баланс был достаточный — OrderLimitExceededError (5 заказов)В продакшне исключения должны не только обрабатываться, но и логироваться. Python предоставляет специальный метод для логирования с трейсбеком:
import logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
def process_payment(amount, user_id):
try:
# Имитируем ошибку
raise InsufficientFundsError(
required=Decimal("1500"),
available=Decimal("300"),
currency="RUB"
)
except InsufficientFundsError as e:
# logging.exception() — логирует сообщение + полный трейсбек
logger.exception(f"Ошибка оплаты для пользователя {user_id}: {e}")
return None
except Exception as e:
# Для неожиданных ошибок — тоже exception() с трейсбеком
logger.exception(f"Неожиданная ошибка при оплате: {e}")
raise # повторно выбрасываем — дальше решает вызывающий код
# logging.exception() эквивалентно logging.error(exc_info=True)
# exc_info=True добавляет полный трейсбек в лог
# Это критически важно для диагностики проблем в продакшнеРазница между методами логирования:
logger.error(msg)— только сообщение, без трейсбекаlogger.exception(msg)— сообщение + трейсбек (только внутриexcept)logger.error(msg, exc_info=True)— то же самое, можно использовать внеexcept
Всегда используйте logger.exception() или exc_info=True при логировании ошибок — трейсбек критически важен для диагностики в продакшне.
except Exception as e: pass — антипаттерн. Это «поглощает» все исключения без следа. Если произошла ошибка, она исчезнет молча — невозможно будет понять, что пошло не так:
# ПЛОХО: все ошибки исчезают
try:
result = process_payment(amount)
except Exception:
pass # ошибка съедена, программа продолжает с неверным состоянием
# ХОРОШО: логируем и решаем, что делать дальше
try:
result = process_payment(amount)
except InsufficientFundsError as e:
logger.warning(f"Недостаточно средств: {e}")
return None
except Exception as e:
logger.exception(f"Неожиданная ошибка: {e}")
raise # или возвращаем ошибку пользователюraise без аргументов повторно выбрасывает текущее исключение. Это полезно, когда нужно что-то сделать с исключением (залогировать) и продолжить его распространение:
try:
risky_operation()
except SomeError as e:
logger.exception("Произошла ошибка")
raise # повторно выбрасывает SomeError без измененийИсключения — объекты, их можно хранить и передавать. Это позволяет собирать список ошибок для валидации:
def validate_form(data):
errors = []
try:
validate_username(data.get("username"))
except ValidationError as e:
errors.append(e)
try:
validate_email(data.get("email"))
except ValidationError as e:
errors.append(e)
if errors:
# Создаём одно исключение со списком всех ошибок
raise ValidationError(
"Форма содержит ошибки",
errors={str(i): str(e) for i, e in enumerate(errors)}
)Несколько типов в одном except. Вместо дублирования кода:
# Нечитаемо и дублирует код
try:
result = parse_data(raw)
except ValueError:
logger.error("Ошибка значения")
return None
except TypeError:
logger.error("Ошибка типа")
return None
# Читаемо и лаконично
try:
result = parse_data(raw)
except (ValueError, TypeError) as e:
logger.error(f"Ошибка парсинга: {type(e).__name__}: {e}")
return NoneСобственные исключения — это не просто удобство, а инструмент проектирования. Они позволяют выразить семантику ошибок предметной области, нести структурированные данные об ошибке и строить гибкие иерархии для точечной обработки.
Минимальный синтаксис — class MyError(Exception): pass. Для нетривиальных случаев добавляйте __init__ с атрибутами и вызывайте super().__init__(message) — это обеспечивает корректную работу args и __str__.
Строите иерархии от общего к частному: AppError → DatabaseError → ConnectionTimeoutError. except DatabaseError поймёт и ConnectionTimeoutError — благодаря наследованию.
raise ... from original создаёт явную цепочку исключений, сохраняя контекст в __cause__. raise ... from None скрывает детали реализации. Оба механизма важны при проектировании API и сервисных слоёв.
В следующем уроке мы рассмотрим множественное наследование и алгоритм MRO — то, как Python разрешает конфликты имён при наследовании от нескольких родителей.
- Почему кастомные исключения следует наследовать от
Exception, а не отBaseException? Какие исключения наследуют отBaseExceptionнапрямую и почему? - Зачем вызывать
super().__init__(message)в__init__кастомного исключения? Что произойдёт, если не вызвать? - В иерархии исключений
AppError → DatabaseError → ConnectionTimeoutError: что именно поймётexcept DatabaseError? Что поймётexcept AppError? - Чем
raise MyError from original_errorотличается отraise MyErrorвнутри блокаexcept? - Что означает
raiseбез аргументов? В каком контексте это используется?§ - Почему
except Exception as e: passявляется антипаттерном? Как правильно поступать в ситуации, когда нужно перехватить «любую ошибку»? - Как
raise ... from Noneизменяет поведение цепочки исключений? Когда это полезно? - Можно ли хранить объект исключения в переменной и работать с ним после блока
try/except? Что при этом важно учитывать?
Иерархия исключений для интернет-магазина
Создайте иерархию исключений для системы интернет-магазина. Базовое исключение ShopError(Exception) с атрибутами message и code (строка-идентификатор ошибки). Метод to_dict() возвращает {"code": code, "message": message}. Дочерние категории (классы без реализации):
InventoryError(ShopError)— ошибки склада.PaymentError(ShopError)— ошибки оплаты.ShippingError(ShopError)— ошибки доставки.
Конкретные классы:
OutOfStockError(InventoryError)— принимаетproduct_name,requested,available.code = "OUT_OF_STOCK".CardDeclinedError(PaymentError)— принимаетreason(строка).code = "CARD_DECLINED".InvalidAddressError(ShippingError)— принимаетaddress.code = "INVALID_ADDRESS".
Напишите функцию process_purchase(product, quantity, card, address), которая имитирует покупку и выбрасывает соответствующие исключения при: quantity > 5 (нет на складе), card == "declined", address == "". Обработайте все исключения в одном блоке через иерархию.
Пример использования:
try:
process_purchase("Ноутбук", 10, "valid_card", "Москва")
except OutOfStockError as e:
print(e.to_dict()) # {'code': 'OUT_OF_STOCK', 'message': '...', ...}
try:
process_purchase("Мышь", 1, "declined", "Москва")
except PaymentError as e: # ловит CardDeclinedError
print(e.code) # CARD_DECLINED
# Перехват любой ошибки магазина
try:
process_purchase("Клавиатура", 1, "valid", "")
except ShopError as e:
print(e.to_dict())Исключение как объект данных для API
Создайте класс APIException(Exception) с атрибутами status_code (int), error_code (str), message (str), details (dict, по умолчанию {}). Метод to_response() возвращает словарь {"status": status_code, "error": error_code, "message": message, "details": details}. Переопределите __str__ в формате "[status_code] error_code: message".
Создайте конкретные исключения:
NotFound(APIException)— принимаетresourceиid. status=404, error="NOT_FOUND". Изresourceнужно сформироватьmessage:"{resource} с id={id} не найден".Conflict(APIException)— принимаетresourceиconflict_field. status=409, error="CONFLICT". Изresourceиconflict_fieldнужно сформироватьmessage:"{resource} с таким {conflict_field} уже существует".RateLimitExceeded(APIException)— принимаетlimitиreset_in_seconds. status=429, error="RATE_LIMIT".
Напишите декоратор api_endpoint, который оборачивает функцию: при APIException возвращает to_response(), при других исключениях логирует и возвращает 500-ответ (в формате метода to_response).
Пример использования:
@api_endpoint
def get_user(user_id):
if user_id == 0:
raise NotFound("User", user_id)
return {"id": user_id, "name": "Alice"}
@api_endpoint
def create_user(username):
if username == "alice":
raise Conflict("User", "username")
return {"created": True}
print(get_user(0))
# {'status': 404, 'error': 'NOT_FOUND', 'message': "User с id=0 не найден", 'details': {}}
print(get_user(1))
# {'id': 1, 'name': 'Alice'}
print(create_user("alice"))
# {'status': 409, 'error': 'CONFLICT', ...}Цепочка исключений при работе с базой данных
Создайте три уровня исключений: низкоуровневое DriverError(Exception) (имитирует ошибку драйвера БД), среднеуровневое RepositoryError(Exception) (ошибка репозитория), высокоуровневое ServiceError(Exception) (ошибка сервисного слоя). Все три уровня исключений только повторяют поведение Exception.
Реализуйте три функции-имитации:
db_query(query)— выбрасываетDriverErrorесли query содержит "broken".user_repository_find(user_id)— вызываетdb_query, приDriverErrorвыбрасываетRepositoryError("Не удалось получить пользователя")через явную цепочку (from).user_service_get(user_id)— вызываетuser_repository_find, приRepositoryErrorвыбрасываетServiceErrorчерезfrom, а приuser_id == 0—ServiceError from None(скрывает детали).
Продемонстрируйте: при обычной ошибке трейсбек показывает цепочку, при user_id=0 — только ServiceError.
Пример использования:
try:
user_service_get(999) # broken query
except ServiceError as e:
print(e)
print(f"__cause__: {e.__cause__}") # RepositoryError
print(f"__cause__.__cause__: {e.__cause__.__cause__}") # DriverError
try:
user_service_get(0) # from None
except ServiceError as e:
print(e)
print(f"__cause__: {e.__cause__}") # None — скрытоСборщик ошибок валидации
Создайте класс ValidationError(Exception) с атрибутами errors (словарь {поле: список_строк}) и методом add_error(field, message). Метод is_empty (property) — True если ошибок нет. Метод raise_if_errors() — выбрасывает себя если есть ошибки. Метод to_dict() возвращает {"valid": False, "errors": errors} или {"valid": True}.
Создайте функцию validate_user_registration(data: dict) -> ValidationError, которая проверяет:
username: обязательно, 3–20 символов, только[a-zA-Z0-9_].email: обязательно, должен содержать@и.после@.password: обязательно, минимум 8 символов, должен содержать хотя бы одну цифру.age: если передан, должен быть числом от 0 до 150.
Функция собирает ВСЕ ошибки (не останавливается на первой) и возвращает объект ValidationError.
Пример использования:
result = validate_user_registration({
"username": "ab", # слишком короткий
"email": "notanemail", # нет @
"password": "nodigits", # нет цифр
"age": 200, # вне диапазона
})
print(result.is_empty) # False
print(result.to_dict()) # {'valid': False, 'errors': {'username': [...], ...}}
try:
result.raise_if_errors()
except ValidationError as e:
for field, msgs in e.errors.items():
print(f"{field}: {msgs}")
# Валидные данные
valid = validate_user_registration({
"username": "alice_123",
"email": "alice@example.com",
"password": "secure123",
})
print(valid.is_empty) # True
print(valid.to_dict()) # {'valid': True}Система повторных попыток с исключениями
Создайте иерархию: RetryableError(Exception) (ошибку можно повторить), FatalError(Exception) (повтор бессмысленен), MaxRetriesExceededError(Exception) (принимает attempts и last_error, сообщение включает оба значения).
Напишите декоратор retry(max_attempts=3, exceptions=(RetryableError,)), который:
- вызывает функцию до
max_attemptsраз, - при
RetryableErrorповторяет попытку, выводя"[Retry N/max] ошибка: message", - при других исключениях — немедленно пробрасывает их без повтора,
- если все попытки исчерпаны — выбрасывает
MaxRetriesExceededError.
Создайте NetworkError(RetryableError) и AuthError(FatalError). Продемонстрируйте работу декоратора.
Пример использования:
attempt_counter = 0
@retry(max_attempts=3)
def fetch_data(url):
global attempt_counter
attempt_counter += 1
if attempt_counter < 3:
raise NetworkError(f"Connection timeout for {url}")
return {"data": "success"}
result = fetch_data("https://api.example.com")
print(result) # {'data': 'success'} — успех на 3-й попытке
@retry(max_attempts=2)
def restricted_resource():
raise AuthError("Invalid token")
try:
restricted_resource() # немедленно выбрасывает AuthError без повтора
except AuthError as e:
print(f"Фатальная ошибка: {e}")Исключения в pipeline обработки данных
Создайте систему исключений для pipeline обработки данных (продолжение задачи из урока 22). Базовое PipelineError(Exception) с атрибутами step_name (имя шага, где возникла ошибка) и input_data. Метод to_dict(). Дочерние: StepValidationError(PipelineError) — данные не прошли проверку, StepTransformError(PipelineError) — ошибка трансформации, PipelineAbortedError(PipelineError) — принимает список collected_errors (накопленные ошибки из всех шагов).
Доработайте класс Pipeline из урока 22: добавьте параметр fail_fast=True. При fail_fast=True — останавливается на первой ошибке и выбрасывает StepValidationError или StepTransformError. При fail_fast=False — собирает все ошибки и в конце выбрасывает PipelineAbortedError со списком всех ошибок.
Пример использования:
# fail_fast=True (по умолчанию)
pipeline = Pipeline([
Validator([lambda d: len(d) > 0]),
Transformer(lambda d: 1 / 0), # ZeroDivisionError → StepTransformError
], fail_fast=True)
try:
pipeline.run([1, 2, 3])
except StepTransformError as e:
print(e.step_name) # 'Transformer'
print(e.to_dict())
# fail_fast=False
pipeline2 = Pipeline([
Validator([lambda d: isinstance(d, list)]),
Transformer(lambda d: 1 / 0),
Validator([lambda d: len(d) > 100]), # тоже упадёт
], fail_fast=False)
try:
pipeline2.run([1, 2, 3])
except PipelineAbortedError as e:
print(f"Собрано ошибок: {len(e.collected_errors)}")
for err in e.collected_errors:
print(f" - {err.step_name}: {err}")