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, вы сохраняете это поведение и предотвращаете случайное подавление исключений.