Обработка исключений в Python: разбор на примере
Голый except прячет ошибки и превращает падение сервиса в тихую порчу данных. Разбираем на живом коде, где обработка исключений в Python работает против вас и как её выстроить правильно.

Сервис принимал платежи и раз в сутки падал с непонятным поведением: транзакция помечалась как проведённая, а деньги не списывались. В логах — тишина. Причина нашлась в одной строке: try/except Exception: pass вокруг блока, который вызывал внешний API. Любая ошибка — таймаут, невалидный ответ, опечатка в имени переменной — гасилась молча, и код шёл дальше, будто всё в порядке.
Разберём на конкретном коде, где механизм исключений в Python (речь про CPython 3.11+, но всё справедливо и для 3.9+) даёт сбой не сам по себе, а из-за того, как его применяют. Углубляться в устройство фреймов и трейсбека здесь не будем — фокус на приёмах, которые ломаются в проде.
Почему широкий except — источник инцидентов
Конструкция except Exception ловит почти всё: и ту ошибку, которую вы ждали, и десяток тех, о которых не думали. Классический пример — разбор ответа стороннего сервиса.
import requests
def get_balance(user_id: int) -> float:
try:
response = requests.get(f"https://api.example/balance/{user_id}", timeout=5)
return response.json()["balance"]
except Exception:
return 0.0 # "на всякий случай"Что здесь ловится под видом «нет остатка пакета»:
- ConnectionError — сеть недоступна, а вы вернули ноль, будто у пользователя пусто;
- Timeout — сервис жив, но медленный, ответ был бы корректным;
- KeyError — API сменил формат, в ответе больше нет ключа
balance; - NameError или AttributeError — ваша же опечатка, которая теперь не всплывёт никогда.
Три первых случая требуют разной реакции: повторить запрос, отдать ошибку клиенту, залогировать смену контракта. Четвёртый — вообще ваш баг, который широкий except замаскировал под штатную ситуацию. Цена — часы отладки, когда что-то «иногда не работает».
Широкий except не обрабатывает ошибки. Он превращает громкий сбой в тихую порчу данных, а её замечают клиенты, а не мониторинг.
Ловите узко: конкретные типы вместо Exception
Правильная версия перечисляет именно те исключения, на которые есть осмысленная реакция, и оставляет остальные всплывать.
import requests
import logging
logger = logging.getLogger(__name__)
class BalanceUnavailable(Exception):
"""Баланс временно недоступен, вызывающий код решает, что делать."""
def get_balance(user_id: int) -> float:
try:
response = requests.get(
f"https://api.example/balance/{user_id}", timeout=5
)
response.raise_for_status()
return response.json()["balance"]
except requests.Timeout as exc:
# сервис жив, но не ответил вовремя — вызывающий код может повторить
raise BalanceUnavailable("таймаут") from exc
except requests.ConnectionError as exc:
raise BalanceUnavailable("нет соединения") from exc
except KeyError as exc:
# изменился формат ответа — это баг контракта, его надо видеть
logger.error("API вернул ответ без ключа balance для user_id=%s", user_id)
raiseЗдесь KeyError логируется и пробрасывается дальше — не глушится. А NameError в этом коде не ловится вообще: если вы опечатаетесь в имени, программа упадёт при первом же тесте, а не через месяц в проде.
raise ... from: не теряйте исходную причину
Обратите внимание на raise BalanceUnavailable(...) from exc. Ключевое слово from связывает новое исключение с исходным. В трейсбеке вы увидите обе строки: «The above exception was the direct cause of the following exception». Без from цепочка тоже сохранится неявно (через __context__), но с явным from намерение читается однозначно, а при from None исходную причину можно осознанно спрятать — например, чтобы не светить внутренности во внешний API.
Что почти всегда делают не так
1. except без типа
Голый except: (без указания класса) ловит даже KeyboardInterrupt и SystemExit. Ваш скрипт перестанет останавливаться по Ctrl+C. Минимум пишите except Exception — эти два исключения наследуются от BaseException, а не от Exception, и мимо пройдут.
2. Логирование через print или без трейсбека
Внутри except используйте logger.exception(...) — он сам подставит трейсбек текущего исключения. logger.error(str(exc)) отдаст только текст, без строки, где всё сломалось.
try:
process(order)
except ValueError:
# exception() логирует с полным трейсбеком на уровне ERROR
logger.exception("не удалось обработать заказ %s", order.id)
raise3. finally, который проглатывает исключение
Если в finally стоит return или raise, он перебивает исключение из try. Ошибка исчезнет, а вы этого не заметите.
# антипаттерн: return в finally гасит любое исключение из try
def read_config(path):
try:
return open(path).read()
finally:
return "" # если файла нет, FileNotFoundError пропадёт4. Ресурсы освобождаются вручную вместо with
Файлы, соединения, блокировки закрывайте контекстным менеджером, а не try/finally с ручным close(). Меньше кода — меньше мест, где освобождение забыли.
Сравнение подходов к перехвату
| Подход | Что выигрываете | Чем платите | Когда уместно |
|---|---|---|---|
except Exception: pass | Ничего | Скрытые баги, порча данных | Практически никогда |
| Узкие типы + reraise | Реакция по делу, баги видны | Больше строк, надо знать типы | Прод-код, библиотеки |
| Своё исключение над чужим | Стабильный контракт для вызывающего | Лишний слой абстракции | Границы модуля, публичный API |
| EAFP: try вместо проверок | Меньше гонок, чище код | Хуже при частых исключениях | Работа со словарями, файлами |
EAFP: почему try дешевле проверки
В Python принят стиль EAFP — «проще попросить прощения, чем разрешения»*. Вместо предварительной проверки вы пробуете действие и ловите исключение, если не вышло.
# LBYL — проверяем заранее, есть гонка между check и доступом
if "balance" in data and data["balance"] is not None:
value = data["balance"]
# EAFP — пробуем, ловим отсутствие
try:
value = data["balance"]
except KeyError:
value = 0.0Цена вопроса: если исключение — редкость, EAFP быстрее, потому что успешный путь не тратит проверки. Если ключа нет в половине случаев, стоимость возбуждения исключения перевешивает, и разумнее data.get("balance", 0.0). Меряйте на своих данных, а не додумывайте.
Группы исключений в 3.11
С Python 3.11 появился синтаксис except* и класс ExceptionGroup — для случаев, когда параллельные задачи (например, в asyncio.TaskGroup) падают разом и нужно обработать несколько исключений сразу, а не первое попавшееся. Это отдельная большая тема; если вы на 3.9–3.10, синтаксиса except* у вас нет, и группы придётся собирать вручную.
Что делать дальше
- Пройдитесь по кодовой базе поиском
except Exceptionиexcept:— каждый такой блок кандидат на сужение. - Настройте
logger.exceptionв блоках, где исключение гасится осознанно, чтобы факт остался в логах. - Включите линтер: правила Ruff из группы
BLE(blind-except) иTRYнаходят большинство описанных антипаттернов автоматически. - Договоритесь в команде, какие ошибки — штатная ситуация (ловим), а какие — баг (пусть падает). Без этого правила у каждого разработчика своя граница.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Python docs — Errors and Exceptions, Python docs — Built-in Exceptions
Частые вопросы
Стоит ли вообще когда-нибудь писать except Exception?
У меня Python 3.9, синтаксис except* не работает — что делать?
Чем raise from отличается от обычного raise внутри except?
Что использовать в блоке except для логирования — error или exception?
Почему нельзя ловить исключение и возвращать значение по умолчанию?
Как найти опасные except в большом проекте автоматически?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.