Иерархия исключений, try/except/finally, пользовательские исключения
- Маршрут: Основы · тема 6 из 7
- Навыки:
J4,M6- До урока:
control_flow,functions.- Результат: предсказуемая политика ошибок, сохраняющая причину и контекст сбоя.
- Основа:
try/except/finally,raiseи собственные исключения.- Работа в эксплуатации: доменные ошибки, цепочки причин и очистка. Углубление:
ExceptionGroup, границы перехвата и политика частичного сбоя.- Подтверждение: таблица политики ошибок и тесты путей обработки сбоев.
Исключение сообщает, что текущая операция не может выполнить свой контракт.
Ловите его там, где можете восстановиться, перевести ошибку на уровень домена
или добавить полезный контекст. Обычное ветвление лучше оставлять в if.
BaseException
├── SystemExit # sys.exit() — выход из интерпретатора
├── KeyboardInterrupt # Ctrl+C
├── GeneratorExit # закрытие генератора
└── Exception # ← все обычные исключения наследуются отсюда
├── ArithmeticError
│ ├── ZeroDivisionError # деление на ноль
│ ├── OverflowError # число слишком большое
│ └── FloatingPointError
├── LookupError
│ ├── IndexError # индекс за пределами
│ └── KeyError # ключ не найден
├── ValueError # неверное значение (правильного типа)
├── TypeError # неверный тип
├── AttributeError # атрибут не найден
├── NameError # имя не определено
├── OSError # ошибки ОС (IO, файлы, сеть)
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── TimeoutError
├── RuntimeError # общие ошибки выполнения
│ └── RecursionError
├── StopIteration # итератор исчерпан
├── ImportError # ошибка импорта
└── NotImplementedError # метод не реализован
except Exception: — ловит всё кроме SystemExit, KeyboardInterrupt, GeneratorExitexcept BaseException: — ловит абсолютно всё (очень редко нужно)except: — то же что except BaseException: — антипаттернtry / except / else / finallytry:
result = risky_operation()
except ZeroDivisionError as e:
print(f"Деление на ноль: {e}")
result = 0
except (TypeError, ValueError) as e:
print(f"Неверный тип или значение: {e}")
raise # перебрасываем исключение
else:
# Выполняется ТОЛЬКО если исключений не было
print(f"Успех: {result}")
save_result(result)
finally:
# Выполняется ВСЕГДА: и при успехе, и при исключении, и при return
cleanup()| Ситуация | except | else | finally |
|---|---|---|---|
| Нет исключений | нет | да | да |
| Исключение поймано | да | нет | да |
| Исключение не поймано | нет | нет | да |
return в try | нет | нет | да, до возврата |
Имя после except ... as error существует только внутри обработчика. В
Python 3 оно удаляется после блока, чтобы локальная переменная не удерживала
исключение вместе с traceback и всеми объектами, на которые ссылаются его
кадры.
try:
int("not-a-number")
except ValueError as error:
message = str(error)
print(message) # доступно
# print(error) # NameError: имя удалено после exceptwith manager as value: вызывает manager.__enter__() перед телом блока и
manager.__exit__(exc_type, exc_value, traceback) при выходе. Если ошибки не
было, три аргумента с информацией об исключении равны None. Возврат True
из __exit__ подавляет ошибку; False или None пропускают её дальше.
class Transaction:
def __enter__(self):
begin()
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
commit()
else:
rollback()
return Falsedef demo():
try:
return 1
finally:
print("finally!") # выведется перед возвратом 1
return 2 # перезаписывает return 1!
demo() # prints "finally!", returns 2Осторожно:
returnвfinallyперезаписываетreturnизtry. Не делайте это.
raise)# Создание нового исключения
raise ValueError("Значение не в допустимом диапазоне")
# С дополнительными атрибутами
raise ValueError("Ошибка", "field_name", 400)
# Перебросить текущее исключение (внутри except)
try:
risky()
except ValueError:
log_error()
raise # re-raise — сохраняет оригинальный traceback
# Raise None — тоже ре-рейз (редко нужно)
raiseraise ... from ...)try:
config = json.loads(config_text)
except json.JSONDecodeError as e:
raise ConfigError("Неверный формат конфига") from e
# Трейсбэк покажет ОБА исключения с пометкой "The above exception was the direct cause of"
# Подавить контекст (скрыть оригинальное исключение):
try:
...
except Exception:
raise NewError("Другая ошибка") from None
# Контекст подавлен — трейсбэк только для NewError# __cause__ — явная цепочка (raise ... from ...)
# __context__ — неявная цепочка (исключение в except блоке)
# __suppress_context__ — True если использован from NoneКогда except перехватывает одно исключение и поднимает новое, Python сохраняет
ссылку на оригинал. Это нужно, чтобы не потерять корневую причину сбоя при
добавлении контекста.
Когда что использовать:
raise X from Y — когда Y является прямой причиной X. Например,
не удалось прочитать файл → FileNotFoundError → вы оборачиваете в
ConfigError("не удалось загрузить конфиг") from original_error. Трейсбэк
покажет оба исключения с пометкой The above exception was the direct cause of the following exception.__context__) — когда Y произошло случайно во время
обработки X. Например, в except ValidationError вы пытаетесь залогировать
и случайно делите на ноль. Python покажет оба исключения, но без «direct cause».raise X from None — когда вы намеренно хотите скрыть оригинальное
исключение. Удобно на границе API, где внутренние детали не должны попасть
клиенту. __suppress_context__ становится True и __context__ не
отображается в трейсбэке.На практике raise ... from ... — самый частый паттерн. Неявная цепочка обычно
означает баг в обработчике (вы случайно сломали код в except). from None
используйте осознанно, чтобы не скрыть полезную диагностику.
# Базовое исключение для модуля/домена
class AppError(Exception):
"""Базовое исключение приложения."""
pass
# Конкретные исключения с метаданными
class ValidationError(AppError):
def __init__(self, message: str, field: str = None, code: int = 400):
super().__init__(message)
self.field = field
self.code = code
def __str__(self):
if self.field:
return f"[{self.field}] {super().__str__()}"
return super().__str__()
class NotFoundError(AppError):
def __init__(self, resource: str, id: int):
super().__init__(f"{resource} with id={id} not found")
self.resource = resource
self.id = id
# Иерархия для API
class APIError(AppError):
status_code = 500
class AuthenticationError(APIError):
status_code = 401
class AuthorizationError(APIError):
status_code = 403
class RateLimitError(APIError):
status_code = 429
def __init__(self, retry_after: int):
super().__init__(f"Rate limit exceeded. Retry after {retry_after}s")
self.retry_after = retry_afterДва стиля обработки ошибок:
LBYL (Look Before You Leap) — проверяй перед использованием:
if key in d:
value = d[key]
process(value)EAFP (Easier to Ask Forgiveness than Permission) — Pythonic стиль:
try:
value = d[key]
process(value)
except KeyError:
pass| Критерий | LBYL | EAFP |
|---|---|---|
| Ошибки редки | Избыточная проверка | Оптимально |
| Ошибки часты | Оптимально | Много except-блоков |
| Race conditions | Возможны (проверил → изменилось) | Безопасен |
| Читаемость | Явные условия | Чище в happy path |
Общее правило: для I/O (файлы, сеть, БД) — EAFP. Для предсказуемых условий — LBYL.
import logging
import traceback
logger = logging.getLogger(__name__)
def process_safely(data):
try:
return heavy_computation(data)
except MemoryError:
logger.critical("Недостаточно памяти!", exc_info=True)
raise
except Exception as e:
# exc_info=True включает полный traceback в лог
logger.exception(f"Ошибка обработки: {e}")
# logger.exception эквивалентно logger.error(..., exc_info=True)
return None
# Получить traceback как строку:
try:
risky()
except Exception:
tb_str = traceback.format_exc()
send_to_monitoring(tb_str)contextlib.suppress — тихое подавлениеfrom contextlib import suppress
# Вместо:
try:
os.remove("temp.txt")
except FileNotFoundError:
pass
# Можно:
with suppress(FileNotFoundError):
os.remove("temp.txt")
# Подавляет только указанные типы — остальные пробрасываются
with suppress(KeyError, IndexError):
value = data["key"][0]warnings)import warnings
# Генерация предупреждений (не исключений)
def deprecated_function():
warnings.warn(
"deprecated_function устарела, используйте new_function()",
DeprecationWarning,
stacklevel=2 # указывает на вызывающий код, не на эту строку
)
# ...
# Фильтрация предупреждений:
warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("error", category=RuntimeWarning) # превратить в исключение
# Временный фильтр:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
noisy_legacy_function()ExceptionGroup позволяет одновременно выбросить несколько исключений — полезно для asyncio Task Group и Trio.
# ExceptionGroup содержит список исключений
eg = ExceptionGroup("multiple errors", [
ValueError("ошибка 1"),
TypeError("ошибка 2"),
])
raise eg
# Ловить через except*:
try:
risky_concurrent_task()
except* ValueError as eg:
for exc in eg.exceptions:
print(f"ValueError: {exc}")
except* TypeError as eg:
for exc in eg.exceptions:
print(f"TypeError: {exc}")# Проблема: Голый except — ловит всё, включая KeyboardInterrupt
try:
something()
except:
pass
# Проблема: except Exception: pass — проглатывает ошибки молча
try:
critical_operation()
except Exception:
pass # никто не узнает об ошибке!
def bad_fallback(default):
# Проблема: широкий except маскирует реальную причину.
try:
return complex_logic()
except Exception:
return default
def expected_fallback(default):
# Вариант: конкретный тип + логирование; неожиданное не перехватывается.
try:
return complex_logic()
except SpecificError as error:
logger.error("Expected error: %s", error)
return default
# Проблема: Использование исключений для обычного управления потоком
def get_first(lst):
try:
return lst[0]
except IndexError:
return None
# Вариант: Явная проверка
def get_first(lst):
return lst[0] if lst else Noneimport time
import functools
from typing import Type
def retry(exceptions=(Exception,), max_attempts=3, delay=1.0, backoff=2.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
attempt = 0
current_delay = delay
while attempt < max_attempts:
try:
return func(*args, **kwargs)
except exceptions as e:
attempt += 1
if attempt >= max_attempts:
raise
print(f"Попытка {attempt} не удалась: {e}. Повтор через {current_delay}s")
time.sleep(current_delay)
current_delay *= backoff
return wrapper
return decorator
@retry(exceptions=(ConnectionError, TimeoutError), max_attempts=3, delay=0.5)
def fetch_data(url):
return requests.get(url).json()Декоратор с fallback строится так же, но типы ожидаемых ошибок нужно передать
явно. Широкий Exception в библиотечном коде может скрыть дефект.
def handle_errors(default_value, exceptions):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except exceptions:
return default_value
return wrapper
return decorator
@handle_errors(default_value=None, exceptions=(ValueError, KeyError))
def read_field(payload, key):
return int(payload[key])# Структура для большого проекта:
class DomainError(Exception):
"""Базовый класс для всех бизнес-исключений."""
class UserError(DomainError):
pass
class UserNotFoundError(UserError):
def __init__(self, user_id: int):
super().__init__(f"Пользователь {user_id} не найден")
self.user_id = user_id
class InsufficientBalanceError(UserError):
def __init__(self, balance: float, required: float):
super().__init__(f"Недостаточно средств: баланс {balance}, требуется {required}")
self.balance = balance
self.required = required
class PaymentError(DomainError):
pass
def handle_payment(user_id, amount):
try:
process_payment(user_id, amount)
except UserError as error:
return 400, str(error)
except PaymentError as error:
return 402, str(error)
except DomainError:
return 500, "Внутренняя ошибка"
return 204, ""tracebackimport traceback
import sys
try:
raise ValueError("test error")
except ValueError:
# Получить traceback как строку
tb_str = traceback.format_exc() # полный traceback
tb_lines = traceback.format_tb(sys.exc_info()[2]) # только TB (без типа/значения)
# Напечатать в stderr (стандартное поведение)
traceback.print_exc()
# Напечатать в файл или StringIO
import io
buf = io.StringIO()
traceback.print_exc(file=buf)
error_report = buf.getvalue()
# Форматирование без активного исключения:
try:
risky()
except Exception as e:
frames = traceback.extract_tb(e.__traceback__)
for frame in frames:
print(f"{frame.filename}:{frame.lineno} in {frame.name}")
print(f" {frame.line}")sys.exc_info() — тройка (type, value, traceback)import sys
def handle_any_exception():
exc_type, exc_val, exc_tb = sys.exc_info()
if exc_type is None:
return # нет активного исключения
# Полезно для обёрток и middleware где нет прямого доступа к `as e`
if issubclass(exc_type, ValueError):
logger.warning(f"Validation error: {exc_val}")
else:
logger.exception(f"Unexpected: {exc_val}")import pytest
def divide(a, b):
if b == 0:
raise ZeroDivisionError("division by zero")
return a / b
# pytest.raises как контекстный менеджер
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError) as exc_info:
divide(10, 0)
assert "division by zero" in str(exc_info.value)
assert exc_info.type is ZeroDivisionError
# pytest.raises как декоратор-проверка (Python 3.10+)
def test_raises_any():
with pytest.raises((ValueError, TypeError)):
raise ValueError("ok")
# Проверка match (regex):
with pytest.raises(ValueError, match=r"invalid.*value"):
raise ValueError("invalid input value")
# Проверка что исключение НЕ вызывается:
def test_no_exception():
result = divide(10, 2)
assert result == 5.0 # просто нет raises = исключение не вызваноfrom dataclasses import dataclass
from typing import TypeVar, Generic
T = TypeVar('T')
E = TypeVar('E', bound=Exception)
@dataclass
class Ok(Generic[T]):
value: T
def is_ok(self) -> bool: return True
def unwrap(self) -> T: return self.value
@dataclass
class Err(Generic[E]):
error: E
def is_ok(self) -> bool: return False
def unwrap(self): raise self.error
Result = Ok[T] | Err[Exception]
def safe_parse(s: str) -> Result:
try:
return Ok(int(s))
except ValueError as e:
return Err(e)
result = safe_parse("42")
if result.is_ok():
print(result.unwrap()) # 42
# Полезно для: API-responses, pipeline-операций, когда caller решает что делать с ошибкойСначала разделите ожидаемые ошибки данных и системные сбои. Результат основы — узкая граница перехвата, стабильный доменный код ошибки и сохранённая исходная причина.
class InvalidAmountError(ValueError):
pass
def parse_amount(raw: str) -> int:
try:
return int(raw)
except ValueError as error:
raise InvalidAmountError("amount должен быть целым") from error
try:
parse_amount("ten")
except InvalidAmountError as error:
assert isinstance(error.__cause__, ValueError)
else:
raise AssertionError("ошибка входа должна быть явной")Практика с подсказками: составьте таблицу «источник сбоя → кто обрабатывает →
что видит вызывающий код». Не перехватывайте BaseException; отдельно
проверьте ошибку данных, отсутствие файла, отказ доступа и сбой записи.
Смоделируйте пакетную обработку, где несколько независимых задач завершаются
ошибкой. Сравните последовательный сбор ошибок с ExceptionGroup и объясните,
где частичный результат допустим, а где операция должна завершиться целиком.
safe_divide(a, b) с EAFP-стилем и правильным логированием.retry(func, n=3, exceptions=(Exception,)) с экспоненциальным backoff.raise e vs raise внутри except Exception as e:.@handle_errors(default_value), возвращающий default при любом Exception.ExceptionGroup для сбора нескольких ошибок валидации полей формы и обработайте каждый тип отдельно через except*.Result[T, E] тип и перепишите через него функцию парсинга конфига.safe_divide перехватывает только ожидаемую ошибку деления, пишет контекст
без секретных данных и не скрывает TypeError.500 не должен
раскрывать traceback пользователю.retry подменяет ожидание и проверяет число попыток, интервалы и
немедленное распространение неожиданной ошибки.raise и raise error: первый сохраняет исходную
точку повторного возбуждения.ExceptionGroup проверьте, что необработанный тип продолжает
распространяться.Закрепите тему в работе «Граница ошибок при приёме событий». В ней ожидаемые ошибки одной строки отделяются от системных и программных сбоев, которые нельзя скрывать частичным результатом.
git clone --branch v0.6.2 --depth 1 https://gitlab.potapov.me/courses/python-labs.git
cd python-labs
uv sync --group test
uv run --group test pytest exceptions/testsДалее: Тестирование: pytest и фикстуры