← назад к разделу

В Python исключение летит само, пока его кто-нибудь не поймает, и язык почти не ограничивает, как именно ловить: можно поймать всё подряд, можно перебросить, потеряв причину, можно молча вернуть из finally. Интерпретатор на это не жалуется, а последствия приходят в проде. Ниже десять ошибок, которые чаще всего всплывают на ревью и в инцидентах, с тем, как выглядит правильный вариант. Единый обработчик на границе HTTP, в который всё это должно впадать, разобран в отдельной статье.

Обязательно

Проглоченное исключение: except: pass и широкий except Exception

try:
    config = json.loads(raw)
except Exception:
    config = {}

try:
    await notify(order)
except:
    pass

Первый блок молча оставляет конфигурацию пустой и прячет под собой не только сломанный JSON, но и TypeError от опечатки строкой ниже. Второй хуже: голый except: ловит BaseException, то есть и KeyboardInterrupt, и SystemExit, и asyncio.CancelledError, которым цикл событий просит задачу остановиться. Сервис после этого не останавливается по SIGTERM, пока его не убьют.

Что делать: ловить конкретный тип (json.JSONDecodeError), держать блок try коротким — только та строка, которая может упасть, остальное в else:, — и либо обрабатывать исключение, либо писать его в журнал с объяснением, почему его можно пережить. Для редких случаев «ошибка действительно не важна» есть contextlib.suppress(FileNotFoundError) с конкретным типом: он читается как решение, а не как забывчивость. Голый except: находит ruff правилом E722, пустой блок — S110, широкий except Exception — BLE001.

Потерянная причина: raise без from

try:
    row = await repo.by_id(order_id)
except NoResultFound:
    raise NotFound("ORDER_NOT_FOUND", "заказ не найден")
raise NewError(str(e)) исключение A новое B без причины в логе только B raise NewError() from e исключение A B.__cause__ = A в логе оба стека

Без from исходный стек теряется, и расследование начинается с середины; from e связывает исключения, и в журнале видно, откуда всё началось.

Исходное исключение не пропало: Python сам положил его в __context__, и в журнале будут обе трассировки. Но между ними встанет строка During handling of the above exception, another exception occurred — так интерпретатор описывает сбой внутри обработчика, а не осознанный перевод одной ошибки в другую. Читающий журнал видит два несвязанных падения и ищет баг в except.

except NoResultFound as e:
    raise NotFound("ORDER_NOT_FOUND", "заказ не найден") from e

from e кладёт причину в __cause__, и журнал пишет The above exception was the direct cause of the following exception. from None причину прячет совсем — это уместно, когда исходное исключение не несёт смысла для читателя (например, KeyError при поиске в словаре, который означает «нет такой настройки»). Правило B904 в ruff требует одно из двух явно. Перевод чужих исключений в свои на границе слоя — не только про читаемость: NoResultFound внутри репозитория становится NotFound снаружи, и когда репозиторий переедет с SQLAlchemy на другую библиотеку, вызывающие не сломаются.

raise e вместо raise

try:
    await process(order)
except Exception as e:
    await rollback()
    raise e

Работает, но трассировка получает лишний кадр: к пути до места ошибки добавляется строка самого raise e, и в журнале функция-обработчик встречается дважды. Голый raise перебрасывает исключение с исходной трассировкой как есть; ruff подсказывает это правилом TRY201. Блок, который только перебрасывает и ничего больше не делает, не нужен вовсе (TRY203).

Мелочь из той же области: переменная e удаляется по выходе из except — вернуть её после блока или сослаться на неё в finally не получится, будет NameError. Если исключение нужно позже, его присваивают другому имени внутри блока.

Сравнение по тексту

except IntegrityError as e:
    if "duplicate key" in str(e):
        raise Conflict(...)

Текст зависит от драйвера, локали сервера и версии библиотеки; переехали с psycopg на asyncpg — проверка перестала срабатывать, и конфликт стал пятисоткой. У ошибок есть структура: e.orig.sqlstate == "23505" для PostgreSQL, e.errno у ошибок ОС, e.response.status_code у httpx.HTTPStatusError. Тип исключения тоже структура: except (ConnectionError, TimeoutError) вместо разбора текста. Сравнивают по типу и коду, текст оставляют человеку.

Лог и проброс одновременно

except Exception as e:
    log.error("load order failed: %s", e)
    raise

Каждый слой, который так делает, добавляет строку в журнал, и одна авария базы превращается в четыре записи с одним и тем же текстом на разных уровнях. Вдобавок log.error(e) пишет только сообщение, без трассировки: место ошибки по такой записи не найти. Правило: исключение либо обрабатывают (и тогда логируют там, где обработали — через log.exception, который добавляет трассировку с цепочкой причин), либо пробрасывают выше, обогатив через from. Логирует тот, кто принимает решение: обработчик HTTP, воркер очереди, точка входа. На границе HTTP при этом разделяют уровни: 4xx это ожидаемое поведение и в журнал ошибок не идёт, 5xx идёт с полной трассировкой.

try:
    order = await load(order_id)
except AppError as e:
    status = STATUS_BY_KIND[e.kind]
    log.log(logging.ERROR if status >= 500 else logging.DEBUG, "request failed", extra={"code": e.code})
    raise

ruff правилом TRY400 подсказывает logging.exception вместо logging.error внутри except, а TRY401 — не вставлять e в текст сообщения, когда трассировка и так пишется.

Задача в никуда

asyncio.create_task(send_email(order))
return {"status": "accepted"}

Вызывающий получит управление сразу и никогда не узнает, что отправка упала. Исключение всплывёт в журнале позже, когда сборщик мусора доберётся до задачи: Task exception was never retrieved. Хуже того, цикл событий держит задачи слабыми ссылками: задача без ссылки может быть собрана на середине работы. ruff ловит это правилом RUF006.

Для группы задач с общим результатом есть TaskGroup: первая ошибка отменяет остальные, а наружу выходит ExceptionGroup, который разбирают через except*.

async with asyncio.TaskGroup() as tg:
    for order_id in ids:
        tg.create_task(fetch(order_id))

С asyncio.gather осторожнее: без return_exceptions=True он поднимает первое исключение, но остальные задачи продолжают работать; с ним — возвращает исключения в списке вместе с результатами, и проверять каждый элемент приходится руками. Фоновая задача, которая переживает запрос, живёт в наборе ссылок (tasks.add(task); task.add_done_callback(tasks.discard)) и сама ловит свои исключения — переводит их в журнал и метрику, потому что ждать её некому.

return в finally

def save(order):
    try:
        repo.add(order)
    finally:
        return cleanup()

return в finally молча отменяет исключение, которое летело из try: ошибка записи превращается в успешный возврат. То же делают break и continue. С Python 3.14 интерпретатор предупреждает об этом SyntaxWarning: 'return' in a 'finally' block, ruff — правилом B012. В finally освобождают ресурсы и не управляют потоком; значение возвращают из try или else.

Отмена и таймаут как авария

Клиент закрыл вкладку, обработчик получил asyncio.CancelledError и записал в журнал ошибку уровня ERROR с полным стеком. Тысяча таких за минуту на графике неотличима от настоящей аварии. С Python 3.8 CancelledError наследует BaseException, поэтому except Exception его пропускает — это и есть правильное поведение; ловят его только except BaseException и голый except:, и оба после этого должны перебросить его дальше, иначе задачу нельзя остановить.

Таймаут — отдельный класс: asyncio.timeout() и wait_for поднимают TimeoutError, и с 3.11 это тот же встроенный класс, что и asyncio.TimeoutError. Он означает «сосед не уложился в бюджет» и заслуживает 504 с предупреждением, а не 500 с трассировкой.

Вторая сторона той же ошибки: повтор операции на любую ошибку. Цикл повторов не повторяет то, что повторять бессмысленно — ошибки валидации, 4xx, «не найдено» — и останавливается, когда задачу отменили:

from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential

@retry(
    retry=retry_if_exception_type((httpx.TransportError, httpx.TimeoutException)),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=0.1, max=2),
    reraise=True,
)
async def fetch_rate(client: httpx.AsyncClient, currency: str) -> Decimal:
    ...

CancelledError сквозь retry_if_exception_type проходит сам: это не Exception, и повторять отменённое tenacity не станет.

Транзакция после ошибки

async with session.begin():
    try:
        await session.execute(insert(users).values(login=login))
    except IntegrityError:
        log.info("login taken, continue")
    await session.execute(insert(audit).values(...))

PostgreSQL после ошибки внутри транзакции отвергает все последующие команды: current transaction is aborted, commands ignored until end of transaction block. Поймали IntegrityError, решили продолжить — и следующая запись падает с InFailedSqlTransaction, уже без очевидной причины. Если ошибка ожидаемая и работу нужно продолжить, её ограждают точкой сохранения — async with session.begin_nested(): вокруг рискованной команды. Если нет — исключению дают вылететь: session.begin() откатит транзакцию сам, а наверху его переведут в 409.

Ошибка после начала ответа

@app.get("/report")
async def report():
    async def rows():
        async for row in repo.stream():
            yield to_csv(row)
    return StreamingResponse(rows(), media_type="text/csv")

Если repo.stream() упадёт на середине, клиент уже получил 200 и половину файла; изменить статус нельзя, а исключение из реестра обработчиков даст в журнале RuntimeError: Caught handled exception, but response already started. То же с BackgroundTasks: ошибка в фоновой задаче случается после того, как ответ ушёл. Если ответ может не собраться, его сначала собирают целиком, потом отправляют. Поток отдают из данных, которые уже готовы, либо договариваются с клиентом о признаке ошибки внутри самого потока.

Дополнительно: при первом чтении можно пропустить

Глубже: что оставить машинерасширенное

Шесть правил ruff закрывают большую часть перечисленного и стоят пять минут настройки: E722 и S110 ловят голый except и пустой блок, BLE001 — широкий except Exception, B904 — raise без from внутри except, TRY201 и TRY203 — лишние raise e, TRY400 — log.error вместо log.exception, B012 — return в finally, RUF006 — задачу без ссылки. Все живут в одном ruff check и включаются в pyproject.toml строкой select = ["E", "F", "B", "BLE", "TRY", "RUF", "S110"]; включать их стоит до того, как в репозитории появится вторая тысяча строк. Проверка типов этих ошибок не находит: mypy не знает, какие исключения функция бросает.

Отдельно про цену: с Python 3.11 блок try без исключения ничего не стоит — байткода на входе в него нет. Поэтому «не пишите try, это медленно» — довод из прошлого десятилетия; try вокруг одной строки и except конкретного типа это и быстро, и читаемо.

Глубже: когда исключение всё-таки можно не пробрасыватьрасширенное

Есть три честных случая. Необязательный ресурс: with suppress(FileNotFoundError): os.remove(tmp) — файла может не быть, и это не ошибка. Запись в журнал и метрики: если упал сам экспортёр метрик, пробросить ошибку некуда, и её пишут в stderr и живут дальше. Фоновая задача «по возможности» (прогрев кэша, отправка аналитики), где падение ничего не меняет для пользователя: ошибка идёт в метрику и журнал, вызывающий её не ждёт. Во всех трёх случаях исключение названо по типу и решение видно из кода; молчаливый except Exception: pass на вызове, у которого есть последствия, честным не бывает.

Коротко

  • Ловят конкретный тип и короткий блок try; голый except: ловит и CancelledError, и KeyboardInterrupt; E722, S110, BLE001 в ruff с первого дня.
  • Переводят исключение через raise ... from e; без from журнал говорит о сбое внутри обработчика; from None — только когда причина не нужна читателю.
  • Перебрасывают голым raise, а не raise e: иначе лишний кадр; переменная e после блока except не существует.
  • Сравнивают по типу и коду (sqlstate, errno, status_code), а не по тексту сообщения.
  • Исключение либо логируют там, где обработали (log.exception), либо пробрасывают выше; не то и другое сразу; 4xx не ошибка журнала.
  • Задача без ссылки теряет исключение и может быть собрана на лету; группы — через TaskGroup и except*; gather без return_exceptions оставляет остальные задачи работать.
  • return, break и continue в finally глушат исключение; с 3.14 это SyntaxWarning.
  • CancelledError пропускают, TimeoutError — это 504; повторяют только сетевые ошибки и не повторяют отменённое.
  • После ошибки в транзакции PostgreSQL отвергает команды до отката; ожидаемую ошибку ограждают begin_nested(), остальным дают вылететь.
  • Ответ, который уже начали отдавать, не исправить: собирают до отправки, фоновые задачи ловят ошибки сами.

Что почитать дальше