Выброс исключений
Теория
Исключения выбрасывает не только Python — их выбрасывают и ваши функции через raise, когда работу невозможно сделать правильно: количество отрицательное, товара нет, промокод просрочен. Это лучше, чем вернуть «особое значение» (None, -1, строку "ошибка"): особое значение легко не заметить, и оно поедет дальше по программе как обычные данные, пока не сломает что-то далеко от места проблемы. Исключение же нельзя проигнорировать случайно — его нужно либо обработать, либо программа остановится.
Важно решить, где ловить. Функция расчёта обычно не знает, что делать с ошибкой, — она её выбрасывает или пропускает дальше. Ловят на границе: в обработчике запроса, где ошибку можно превратить в понятный ответ пользователю (400 Bad Request) или в запись в лог. Если поймали только чтобы записать в лог, ошибку перевыбрасывают голым raise. А если нужно сменить тип ошибки на более понятный, используют raise НоваяОшибка(...) from e — так сохраняется исходная причина. Свои классы ошибок (OutOfStockError) появятся в разделе ООП.
Наглядно
Таблица 1. Особое значение против исключения.
return None / return "ошибка" |
raise ValueError("...") |
|
|---|---|---|
| Можно не заметить | да — значение похоже на обычные данные | нет — без обработки программа остановится |
| Где проявится проблема | далеко от причины | ровно там, где случилась |
| Что знает вызывающий код | только «что-то не так» | тип ошибки и понятное сообщение |
| Когда уместно | «нет результата» — нормальная ситуация (dict.get) |
работу нельзя выполнить правильно |
Таблица 2. Формы raise.
| Запись | Что делает | Когда |
|---|---|---|
raise ValueError("сообщение") |
выбрасывает новое исключение | данные некорректны, продолжать нельзя |
raise (внутри except) |
перевыбрасывает пойманное исключение как есть | поймали, чтобы записать в лог, но обработать не можем |
raise ValueError("...") from e |
новое исключение с сохранённой причиной e |
переводим низкоуровневую ошибку в понятную |
На практике
Задача: проверять количество и наличие товара при оформлении заказа, правильно передавать ошибки наверх и повторять запрос к ненадёжному сервису оплаты.
Шаг 1. Выбрасываем исключение
Функция проверки не может «исправить» отрицательное количество — она сообщает о проблеме:
def validate_quantity(qty): if qty <= 0: raise ValueError(f"количество должно быть больше нуля, получено {qty}") return qty print(validate_quantity(2)) validate_quantity(-1)
Результат:
2 ValueError: количество должно быть больше нуля, получено -1
Тип ошибки — ValueError: тип данных правильный, значение — нет. Сообщение объясняет, что не так и какое значение пришло.
Шаг 2. Ловим ошибку в вызывающем коде
Обработчик формы знает, что делать: вернуть понятный ответ с кодом 400:
def handle_add_to_cart(raw_qty): try: qty = validate_quantity(int(raw_qty)) except ValueError as e: return {"status": 400, "error": str(e)} return {"status": 200, "qty": qty} print(handle_add_to_cart("2")) print(handle_add_to_cart("-3")) print(handle_add_to_cart("много"))
Результат:
{'status': 200, 'qty': 2}
{'status': 400, 'error': 'количество должно быть больше нуля, получено -3'}
{'status': 400, 'error': "invalid literal for int() with base 10: 'много'"}Шаг 3. Видим, чем плох возврат «ошибки»
Функция цены возвращает строку вместо ошибки — и проблема всплывает в другом месте:
def get_price(catalog, product_id): if product_id not in catalog: return "нет товара" return catalog[product_id] catalog = {42: 1499} total = 0 for product_id in (42, 7): total += get_price(catalog, product_id)
Результат:
TypeError: unsupported operand type(s) for +=: 'int' and 'str'
Ошибка — в строке суммирования, хотя причина — отсутствующий товар. В большой программе такую связь ищут долго.
Шаг 4. Перевыбрасываем после записи в лог
Поймали, записали, но обработать не можем — пропускаем дальше голым raise:
def charge(amount): try: return validate_quantity(amount) except ValueError: print("лог: неверная сумма", amount) raise try: charge(0) except ValueError as e: print("обработчик получил:", e)
Результат:
лог: неверная сумма 0 обработчик получил: количество должно быть больше нуля, получено 0
Шаг 5. Меняем тип ошибки и сохраняем причину
KeyError: 7 ничего не говорит пользователю API. Переводим его в понятную ошибку, сохранив исходную через from:
def find_price(catalog, product_id): try: return catalog[product_id] except KeyError as e: raise ValueError(f"товар {product_id} не найден") from e try: find_price({42: 1499}, 7) except ValueError as e: print(e, "| причина:", type(e.__cause__).__name__, e.__cause__)
Результат:
товар 7 не найден | причина: KeyError 7
В traceback такой ошибки видны обе: исходная KeyError и строка «The above exception was the direct cause of the following exception».
Шаг 6. Повторяем запрос через декоратор @retry
Сервис оплаты иногда отвечает сбоем сети (ConnectionError) — такой запрос стоит повторить. А ошибку данных (ValueError) повторять бессмысленно. Имитируем сервис, который падает два раза:
import functools def retry(times, exceptions): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except exceptions as e: print(f"попытка {attempt} не удалась: {e}") if attempt == times: raise return wrapper return decorator failures = [ConnectionError("таймаут"), ConnectionError("сброс соединения")] @retry(times=3, exceptions=ConnectionError) def pay(amount): if failures: raise failures.pop(0) return f"оплачено {amount} ₽" print(pay(1499))
Результат:
попытка 1 не удалась: таймаут попытка 2 не удалась: сброс соединения оплачено 1499 ₽
Если сбои не закончатся за три попытки, последняя ошибка уйдёт наружу через raise, а не превратится в тихий None. ValueError декоратор не ловит — она сразу дойдёт до вызывающего кода.
Частые ошибки
- Особое значение вместо исключения.
return Noneилиreturn "ошибка", когда работу нельзя выполнить, — проблема всплывёт далеко от причины. - Проглоченная ошибка.
except: passилиexcept ...: return Noneв глубине кода — программа продолжает работать с неверными данными. raise eвместоraise. Работает, но добавляет в traceback лишнюю строку; для перевыброса как есть пишут голыйraise.- Перевод ошибки без
from. Причина сохраняется неявно, но traceback говорит «During handling … another exception occurred» — будто это новая ошибка обработчика.from eпрямо говорит: «вот причина». - Повтор при любой ошибке.
@retryдляExceptionповторяет и ошибки данных, которые никогда не пройдут, и тратит время. Повторяйте только временные сбои.
Итог
raise
raise ValueError("понятное сообщение")— работу нельзя выполнить правильно.- Тип — по смыслу:
ValueError,TypeError,KeyError…; сообщение — что не так и какое значение пришло.
Где ловить
- Низкоуровневые функции выбрасывают или пропускают ошибки.
- На границе (обработчик запроса) ошибку превращают в ответ или запись в лог.
Передача дальше
- Голый
raiseвexcept— перевыбросить как есть. raise Новая(...) from e— сменить тип, сохранив причину (__cause__).
Повтор
@retry(times, exceptions)— только для временных сбоев; после последней попытки —raise.
Вопросы на собеседовании
Почему лучше выбросить исключение, чем вернуть None или код ошибки?
Особое значение легко не проверить: оно смешивается с нормальными данными, и ошибка проявляется далеко от причины. Исключение невозможно проигнорировать случайно, несёт тип и сообщение и останавливает выполнение там, где возникла проблема.
Чем raise отличается от raise e внутри except?
Голый raise перевыбрасывает текущее исключение без изменений. raise e тоже выбросит тот же объект, но добавит в traceback строку, где был raise e. Для простого перевыброса пишут raise.
Что делает raise ... from e?
Создаёт новое исключение и записывает исходное в его __cause__. В traceback показываются обе ошибки с пометкой, что первая — прямая причина второй. Используют, когда переводят низкоуровневую ошибку (KeyError) в ошибку уровня приложения.
Глубже
Как Python хранит цепочки исключений (__context__ и __cause__) и что делает return в finally с летящей ошибкой — в курсе «Python под капотом», тема «Исключения изнутри».