2025, Dec 18 03:01

Return в finally в Python: как он маскирует исключения

Почему return в finally в Python подавляет исключения: примеры с контекстными менеджерами и функциями, и как безопасно это исправить и не потерять ошибки.

Контекстные менеджеры в Python часто используют как самый чистый способ гарантировать корректную очистку, но если совместить их с некоторыми схемами управления потоком, поведение может оказаться неожиданным. Распространённая ловушка — поместить return внутри блока finally. Эффект тонкий и его легко не заметить: ожидаемое исключение так и не выходит наружу.

Как воспроизвести проблему

Рассмотрим контекстный менеджер, который всегда выполняет код очистки в finally, а затем делает return. Окружающий код поднимает и повторно выбрасывает исключение. Можно ожидать, что оно продолжит распространяться. Этого не происходит.

from contextlib import contextmanager

@contextmanager
def run_ctx():
    try:
        print("enter ctx")
        yield
    finally:
        print("leave ctx")
        return  # маскирует любое исключение, возникшее внутри тела блока with

with run_ctx():
    try:
        raise RuntimeError("ASDF")
    except Exception as err:
        print(f"caught {err}; re-raising")
        raise err

print("still running")

Код завершает выполнение после блока with, а не останавливается на RuntimeError. Исключение оказывается проглоченным.

Что на самом деле происходит

Это поведение не специфично для contextlib.contextmanager. Та же картина наблюдается в обычной функции, что указывает на роль самого finally, а не механики контекстных менеджеров.

def sample():
    try:
        raise Exception("Oh no")
    except:
        raise
    finally:
        return

sample()

Блок finally выполняется всегда. Если в нём срабатывает return, он перекрывает любой ожидающий raise или return из частей try или except. Функция просто возвращает значение (здесь None), и выполнение продолжается так, будто ничего необычного не произошло.

Это можно увидеть на минимальном примере:

try:
    raise Exception("oh no")
finally:
    return
# оператор return выше не даёт исключению продолжить распространение

Как только выполнение доходит до return в finally, в этой рамке вызова больше ничего произойти не может. Ранее подготовленное к выбросу исключение отбрасывается.

Как исправить управление потоком

Практическое правило простое: не используйте return в блоке finally. Дайте finally завершить очистку и позвольте обычному потоку выполнения продолжиться. Если было выброшено исключение, оно распространится; если нет — функция или контекстный менеджер отработают как обычно.

from contextlib import contextmanager

@contextmanager
def run_ctx():
    try:
        print("enter ctx")
        yield
    finally:
        print("leave ctx")  # здесь нет return

with run_ctx():
    try:
        raise RuntimeError("ASDF")
    except Exception as err:
        print(f"caught {err}; re-raising")
        raise err

Очистка по-прежнему выполняется, и исключение больше не подавляется. Если исключения нет, блок with завершается нормально.

Почему это важно

Тихие режимы отказа — одни из самых сложных для отслеживания в продакшене. Случайный return внутри finally может заглушить исключения, на которых держатся ваши тесты и логирование, серьёзно усложняя поиск первопричины. Поскольку эффект не ограничивается контекстными менеджерами, та же ошибка может проскользнуть в любую функцию с try/except/finally, ещё больше удивляя.

Выводы

Используйте finally для очистки, логирования и других безусловных действий и избегайте возврата из него. Среда выполнения гарантирует, что finally отработает; как только он завершится, возобновится обычный поток управления, включая корректное распространение исключений. Держа return вне finally, вы сохраняете это поведение и предотвращаете случайное подавление исключений.