Лекция 8

Defensive Programming

Обработка ошибок и логирование

План лекции

Семь разделов

СекцияТемаЧему научимся
AКонтекст и мотивацияПонимать, зачем это нужно
BОсновы исключенийЧитать ошибки, ловить через try/except
CУглубление try/exceptelse, finally, типичные ловушки
DraiseВыбрасывать свои исключения по бизнес-правилам
EКонтекстные менеджеры (with)Безопасно работать с ресурсами
FМодуль loggingПисать нормальные логи вместо print()
GФинальная практикаСобрать всё вместе на реальной задаче

Концепция

Карта уровней Defensive Programming

УровеньИнструментЧто защищает
1if / raiseВходные аргументы. Блокируем мусор сразу
2try / exceptВнешние ресурсы, сеть, I/O, парсинг
3with / contextmanagerРесурсы: файлы, БД, локи
4loggingМониторинг и аудит в продакшне

Принцип: чем раньше поймана ошибка, тем дешевле её исправить. В реальной системе все уровни работают вместе.

Контекст

Один изменённый ключ

Пятница, 18:32, день зарплат. Сервис обрабатывает переводы через Humo. Партнёр обновил API: переименовал один атрибут в JSON-ответе.

До обновления
{ "transaction_id": "HM-88912", "status": "SUCCESS", "currency": "UZS", "amount": 350000.00 }
После обновления
{ "transaction_id": "HM-88912", "status": "SUCCESS", "currency_code": "UZS", "amount": 350000.00 }

Контекст

Каскад от KeyError до outage

  • Код читает response["currency"]. Ключа больше нет: KeyError.
  • Воркер падает. Celery поднимает новый, тот падает тоже.
  • Очередь растёт. Через 4 минуты сервис полностью лежит.

SLA банка 99.99% uptime = максимум 52 минуты простоя в год. Один необработанный KeyError сжигает половину годового лимита за вечер.

Концепция

Что такое Defensive Programming

Это подход, при котором код пишут с предположением «всё может пойти не так»: сеть упадёт, БД лагает, партнёрский API вернёт мусор, кто-то напишет опечатку.

1

Валидация на входе

Не доверяем входным данным.

2

Обработка ошибок

Не даём упавшему ресурсу убить процесс.

3

Логирование

Знаем, что упало и когда.

Связь с лекцией 7

Где Duck Typing не помогает

На прошлой лекции мы писали полиморфные шлюзы: UzCardGateway, HumoGateway. Через Duck Typing вызываем gateway.process_settlement(). Одна опечатка ломает прод.

class UzCardGateway: def process_settlement(self, amount: float) -> bool: print(f"[UzCard] Проводим {amount} UZS") return True # Интеграция в конце рабочего дня: class HumoGateway: def process_settlemant(self, amount: float) -> bool: # опечатка return True gateways = [UzCardGateway(), HumoGateway()] for gw in gateways: gw.process_settlement(350_000.0)
[UzCard] Проводим 350000.0 UZS AttributeError: 'HumoGateway' object has no attribute 'process_settlement'

UzCard прошёл, Humo упал. Часть клиентов получила зарплату, часть нет.

Основы исключений

Что такое исключение

Исключение: сигнал «что-то пошло не так», который Python отправляет, когда не может выполнить операцию. Без обработки программа падает.

data = {"name": "Aziz", "balance": 350_000} # Пытаемся получить ключ, которого нет value = data["account_id"] # здесь Python бросает исключение print("Эта строка никогда не выполнится")
Traceback (most recent call last): File "demo.py", line 5, in <module> value = data["account_id"] KeyError: 'account_id'

Python создал объект KeyError, никто его не обработал, программа упала с Traceback.

Основы исключений

Как читать Traceback

Traceback читается снизу вверх. Нижняя строка: суть проблемы. Строки выше: путь, по которому пришли.

def extract_rate(api_response: dict) -> float: return float(api_response["data"]["rate"]) def calculate_amount(amount_uzs: float, api_response: dict) -> float: rate = extract_rate(api_response) return amount_uzs / rate def process_order(order: dict) -> float: cbu_response = { "status": "OK", "data": {"exchange_rate": 12550.0} # ключ переименован } return calculate_amount(order["amount"], cbu_response) process_order({"amount": 5_000_000.0})

Основы исключений

Разбор Traceback построчно

KeyError: 'rate' <-- ЧТО упало File "demo.py", line 2, in extract_rate <-- ГДЕ упало return float(api_response["data"]["rate"]) File "demo.py", line 6, in calculate_amount <-- КТО вызвал rate = extract_rate(api_response) File "demo.py", line 12, in process_order <-- выше по стеку return calculate_amount(order["amount"], cbu_response) File "demo.py", line 13, in <module> <-- точка входа process_order({"amount": 5_000_000.0})
ШагЧто делаем
1Читаем последнюю строку: KeyError: 'rate', ключ rate отсутствует
2Смотрим строку выше: extract_rate, строка 2, там падение
3Идём в код, проверяем строку 2: api_response["data"]["rate"]
4Проверяем содержимое api_response: там exchange_rate, а мы ищем rate
5Готово: партнёр переименовал ключ

Основы исключений

Базовый синтаксис try/except

Чтобы поймать исключение и не дать программе упасть.

data = {"currency": "UZS", "amount": 350_000} try: value = data["currency_code"] # KeyError: такого ключа нет print(f"Получили: {value}") # не выполнится except KeyError as e: print(f"Ключ не найден: {e}") # выполнится это print("Программа продолжает работать")
Ключ не найден: 'currency_code' Программа продолжает работать

try оборачивает опасный код, except ловит конкретный тип ошибки. Программа не падает.

Основы исключений · Типичные ошибки

KeyError и ValueError

fee_config = {"P2P_TRANSFER": 1000.0, "UTILITY_PAYMENT": 500.0} def get_fee(tx_type: str) -> float: return fee_config[tx_type] # KeyError если нет ключа try: fee = get_fee("CRYPTO_SWAP") except KeyError as e: print(f"[KeyError] Тип операции {e} не настроен") fee = 0.0 def set_daily_limit(limit: float) -> None: if limit <= 0: raise ValueError(f"Лимит должен быть положительным: {limit}") print(f"Лимит установлен: {limit:,.0f} UZS") try: set_daily_limit(-5_000_000.0) # тип float, но значение недопустимое except ValueError as e: print(f"[ValueError] {e}")
[KeyError] Тип операции 'CRYPTO_SWAP' не настроен [ValueError] Лимит должен быть положительным: -5000000.0

Основы исключений · Типичные ошибки

TypeError и AttributeError

def calculate_interest(principal: float, rate: float) -> float: return principal * rate try: result = calculate_interest("1000000", 0.18) # строка вместо float except TypeError as e: print(f"[TypeError] {e}") class LegacyConnector: def send(self, data: str) -> None: pass connector = LegacyConnector() try: connector.process_payment(50_000.0) # такого метода нет except AttributeError as e: print(f"[AttributeError] {e}")
[TypeError] can't multiply sequence by non-int of type 'float' [AttributeError] 'LegacyConnector' object has no attribute 'process_payment'

TypeError: "1000000" это строка (sequence), её нельзя умножить на float. AttributeError: опечатка в имени метода или метод не реализован.

Основы исключений

Иерархия исключений: это тоже ООП

Исключения в Python это обычные классы. KeyError наследует LookupError, тот наследует Exception. Каждый except SomeError работает как isinstance(): ловит сам класс и любого его потомка.

# Базовые исключения определены так: class BaseException: ... class Exception(BaseException): ... class LookupError(Exception): ... class KeyError(LookupError): ... # KeyError IS-A LookupError try: raise KeyError("нет ключа") except LookupError: # поймает через родителя print("поймали через родителя")
поймали через родителя

Основы исключений

Дерево стандартных исключений

BaseException ├── SystemExit sys.exit() - НЕ перехватывать ├── KeyboardInterrupt Ctrl+C - НЕ перехватывать └── Exception всё, что можно обрабатывать ├── ArithmeticError │ └── ZeroDivisionError деление на ноль ├── AttributeError obj.method() - нет метода ├── LookupError │ ├── KeyError dict["missing"] │ └── IndexError list[999] ├── OSError │ └── ConnectionError │ └── ConnectionRefusedError ├── TypeError неверный тип └── ValueError правильный тип, неверное значение

Не перехватывайте BaseException: туда входят SystemExit и KeyboardInterrupt. Иначе нельзя будет даже корректно остановить программу.

Основы исключений

Визуальная карта иерархии

Иерархия исключений Python: BaseException, Exception, и их потомки

Основы исключений

Объект исключения: полезные атрибуты

Исключение это обычный объект. Полезно знать, что у него внутри, чтобы писать осмысленные логи.

def simulate_error(account_id: str) -> None: raise ConnectionRefusedError( f"PostgreSQL недоступен. account_id={account_id}" ) try: simulate_error("UZ_ACC_9912") except ConnectionRefusedError as exc: print(f"Тип: {type(exc).__name__}") print(f"Сообщение: {exc}") print(f"args: {exc.args}")
Тип: ConnectionRefusedError Сообщение: PostgreSQL недоступен. account_id=UZ_ACC_9912 args: ('PostgreSQL недоступен. account_id=UZ_ACC_9912',)

type(exc).__name__ идёт в систему мониторинга: по нему фильтруют инциденты.

Вопрос аудитории

Что напечатает этот код?

try: raise KeyError("нет ключа") except Exception as e: print(f"[Exception] поймали: {e}") except KeyError as e: print(f"[KeyError] поймали: {e}")
  • А) [KeyError] поймали: 'нет ключа'
  • Б) [Exception] поймали: 'нет ключа'
  • В) Оба блока выполнятся

Подумайте 30 секунд. Ответ на следующем слайде.

Ответ

Б: [Exception] поймали

Python проверяет блоки except сверху вниз и останавливается на первом совпадении. KeyError это потомок Exception, поэтому первый блок ловит ошибку, а второй уже не выполнится.

Правило: от конкретного к общему. Специфичные except сверху, общие снизу.

# Правильно try: operation() except KeyError as e: # сначала самый конкретный print(f"Нет ключа: {e}") except LookupError as e: # потом родитель print(f"Ошибка поиска: {e}") except Exception as e: # последним общий print(f"Неожиданная ошибка: {e}") # Неправильно: Exception поглотит всё try: operation() except Exception as e: # поймает всё print(f"Ошибка: {e}") except KeyError: # никогда не выполнится ...

Углубление try/except

Минимум кода в try

Чем меньше кода в try, тем точнее вы понимаете, где упало. Заворачивайте только опасные операции.

# Плохо: весь метод в try def bad_process(payload: dict) -> float | None: try: user_id = payload["user_id"] amount = float(payload["amount"]) currency = payload["currency"] return amount * 1.02 except Exception: return None # что упало и где - непонятно # Хорошо: каждая опасная зона отдельно def good_process(payload: dict) -> float | None: try: user_id = payload["user_id"] raw_amount = payload["amount"] currency = payload["currency"] except KeyError as missing: print(f"[REJECT] Нет поля: {missing}") return None try: amount = float(raw_amount) except ValueError: print(f"[REJECT] Сумма не число: '{raw_amount}'") return None return amount * 1.02

Углубление try/except

Множественные except: разные ошибки, разные реакции

Один except Exception на всё это одна кнопка для всех ситуаций. Гранулярные except позволяют реагировать по-разному.

Тип ошибкиРеакция
Сеть упала (Timeout)Ретрай через несколько секунд
Невалидные данные (ValueError)Отклонить с кодом 422
Превышен лимитУведомить комплаенс
Битый JSON от партнёраЗалогировать, вернуть PARTNER_ERROR

В Python нет страховочной сетки компилятора, который заставит обработать исключения, как в Java. За это отвечаете вы.

Углубление try/except

Пример: шлюз Kapitalbank

import json class KapitalbankTimeout(ConnectionError): pass class KapitalbankSignatureError(ValueError): pass def call_api(simulate: str | None) -> dict: if simulate == "timeout": raise KapitalbankTimeout("Таймаут") if simulate == "signature": raise KapitalbankSignatureError("HMAC не совпал") if simulate == "json": return json.loads("{broken}") return {"status": "OK"} def process(simulate: str | None) -> dict: try: return call_api(simulate) except KapitalbankTimeout as err: print(f"[RETRY] Таймаут, в очередь ретрая: {err}") return {"status": "RETRY_SCHEDULED"} except KapitalbankSignatureError as err: print(f"[SECURITY] Подпись не совпала: {err}") return {"status": "BLOCKED"} except json.JSONDecodeError as err: print(f"[BAD_RESPONSE] Партнёр прислал мусор: {err}") return {"status": "PARTNER_ERROR"} except Exception as err: print(f"[UNKNOWN] Эскалация дежурному: {err}") return {"status": "ESCALATED"}

Вопрос аудитории

Что вернёт process() для фродовой транзакции?

def check_fraud(tx: dict) -> bool: score = 8500 if scor > 9000: return True return False def process(tx: dict) -> dict: try: is_fraud = check_fraud(tx) return {"status": "BLOCKED" if is_fraud else "APPROVED"} except Exception: return {"status": "APPROVED"}
  • А) BLOCKED: антифрод сработал правильно
  • Б) APPROVED: антифрод не отработал, программа не упала
  • В) Программа упадёт с NameError

Ответ

Б: APPROVED, антифрод не отработал

  • scor это опечатка. Python бросает NameError: name 'scor' is not defined.
  • except Exception ловит всё, включая NameError.
  • Функция возвращает APPROVED. Мошенник провёл транзакцию.

Это самая опасная конструкция в коде. Широкий except Exception без логирования прячет любые баги, включая опечатки в коде.

# Правильное решение def process(tx: dict) -> dict: try: is_fraud = check_fraud(tx) return {"status": "BLOCKED" if is_fraud else "APPROVED"} except KeyError as e: # ожидаемая ошибка return {"status": "REJECTED", "reason": f"Missing field: {e}"} # NameError здесь НЕ перехватывается, падает наружу, # попадает в логи, дежурный увидит и исправит опечатку.

Правило: перехватывайте только то, что вы ожидаете и умеете обработать.

Углубление try/except

Блок else: выполняется только при успехе

Чёткое разделение: «опасная операция», «обработка ошибки» и «код при успехе» лежат в разных блоках.

try: опасная_операция() # может бросить исключение except SomeError: обработка_ошибки() # только если было исключение else: при_успехе() # только если исключения НЕ было finally: всегда() # выполняется в любом случае

Если положить «код при успехе» внутрь try, его ошибки тоже поймает except и обработает их как ошибки опасной операции. Это путаница в логах.

Углубление try/except

else на практике: COMMIT только при успехе

class MockDB: def execute(self, query: str, params: dict) -> None: print(f" [SQL] {query[:40]}...") def commit(self) -> None: print(f" [SQL] COMMIT") def close(self) -> None: print(f" [SQL] Соединение закрыто") def save_payment(db: MockDB, tx_data: dict) -> str | None: try: tx_id = tx_data["tx_id"] amount = float(tx_data["amount"]) if amount <= 0: raise ValueError(f"Сумма должна быть > 0: {amount}") db.execute("INSERT INTO ledger ...", {"tx_id": tx_id, "amount": amount}) except KeyError as e: print(f"[EXCEPT] Нет поля: {e}") return None except ValueError as e: print(f"[EXCEPT] {e}") return None else: print("[ELSE] Запись прошла, делаем COMMIT") db.commit() return tx_id finally: print("[FINALLY] Закрываем соединение") db.close()

try делает запись, except ловит ошибки, else подтверждает транзакцию, finally закрывает соединение в любом случае.

Углубление try/except

else на практике: запуск и вывод

print("=== Успех ===") save_payment(MockDB(), {"tx_id": "TX_001", "amount": "450000"}) print("=== Ошибка ===") save_payment(MockDB(), {"tx_id": "TX_002", "amount": "-500"})
=== Успех === [SQL] INSERT INTO ledger ... [ELSE] Запись прошла, делаем COMMIT [SQL] COMMIT [FINALLY] Закрываем соединение [SQL] Соединение закрыто === Ошибка === [EXCEPT] Сумма должна быть > 0: -500.0 [FINALLY] Закрываем соединение [SQL] Соединение закрыто

В успехе срабатывают try → else → finally. В ошибке: try → except → finally. Блок finally отрабатывает оба раза.

Углубление try/except

Блок finally: выполняется всегда

Для гарантированного освобождения ресурсов: закрыть файл, снять блокировку, закрыть соединение с БД. finally срабатывает даже когда внутри try был return.

def deduct_funds(account_id: str, amount: float) -> str | None: print(f" [LOCK] Захват лока для {account_id}") try: if amount > 5_000_000: raise ValueError("Превышение лимита") print(f" [OK] Списали {amount} UZS") return f"TX_{account_id}" # finally сработает ДО return except ValueError as e: print(f" [ERROR] {e}") return None finally: print(f" [LOCK] Снят лок для {account_id}") deduct_funds("UZ_5521", 500_000.0) deduct_funds("UZ_5521", 8_000_000.0)
[LOCK] Захват лока для UZ_5521 [OK] Списали 500000.0 UZS [LOCK] Снят лок для UZ_5521 [LOCK] Захват лока для UZ_5521 [ERROR] Превышение лимита [LOCK] Снят лок для UZ_5521

Лок снимается всегда. Без finally блокировка могла бы остаться навсегда.

Вопрос аудитории

Что напечатает этот код?

def connect_to_db() -> None: try: raise ConnectionRefusedError("PostgreSQL упал") db_conn = {"session": "active"} except ConnectionRefusedError as e: print(f"[EXCEPT] {e}") finally: print("[FINALLY] Закрываем...") db_conn.close() connect_to_db()
  • А) Напечатает [EXCEPT], потом [FINALLY], и спокойно завершится
  • Б) Напечатает [EXCEPT], [FINALLY], потом упадёт с UnboundLocalError
  • В) Напечатает только [EXCEPT], до finally не дойдёт

Ответ

Б: UnboundLocalError поверх оригинальной ошибки

  • В try выбросилось ConnectionRefusedError.
  • Строка db_conn = ... не выполнилась, переменной не существует.
  • except отработал. finally вызвал db_conn.close().
  • Переменная не определена. Python бросает UnboundLocalError.
  • Новая ошибка затирает оригинальную в логах. Дежурный чинит не то.

Исправление: инициализируйте ресурс None до try.

def connect_to_db() -> None: db_conn = None # инициализация ДО try try: raise ConnectionRefusedError("PostgreSQL упал") db_conn = {"session": "active"} except ConnectionRefusedError as e: print(f"[EXCEPT] {e}") return finally: if db_conn is not None: # безопасная проверка db_conn.close()

raise

Выбрасываем исключение сами

raise переводит бизнес-правила на язык исключений. То, что синтаксически корректно, может быть бизнес-ошибкой: amount = -50000, перевод на тот же счёт, превышение лимита.

MAX_TX = 10_000_000.0 # лимит ЦБ def validate_payment(sender: dict, recipient: dict, amount: float) -> None: if amount <= 0: raise ValueError(f"Сумма должна быть положительной: {amount}") if sender["account_id"] == recipient["account_id"]: raise ValueError(f"Перевод на тот же счёт: {sender['account_id']}") if amount > MAX_TX: raise PermissionError(f"Сумма {amount:,.0f} > лимит {MAX_TX:,.0f}") if sender["balance"] < amount: raise ArithmeticError( f"Недостаточно: запрос {amount:,.0f}, баланс {sender['balance']:,.0f}" ) sender = {"account_id": "UZ_A", "balance": 100_000.0} recipient = {"account_id": "UZ_B"} try: validate_payment(sender, recipient, 500_000.0) except (ValueError, PermissionError, ArithmeticError) as e: print(f"[{type(e).__name__}] {e}")
[ArithmeticError] Недостаточно: запрос 500,000, баланс 100,000

Контекстные менеджеры

Проблема: ресурс не закрылся

Каждый ресурс (файл, соединение, лок) нужно закрыть после работы. Если между открытием и закрытием произойдёт исключение, строка f.close() просто не выполнится.

f = open("payments.txt", "r") data = f.read() int("abc") # здесь упало ValueError f.close() # эта строка не выполнится

Это утечка ресурсов: файл занят, память не освобождена. На сервере с тысячами запросов такие утечки накапливаются и валят процесс.

Контекстные менеджеры

Что такое with

with говорит Python: «открой это, дай мне поработать, закрой это сам, даже если случится ошибка». То же самое короче и надёжнее, чем try/finally.

Через try/finally
f = open("payments.txt", "r") try: data = f.read() finally: f.close()
Через with
with open("payments.txt", "r") as f: data = f.read() # close() вызовется автоматически

Класс с методами __enter__ и __exit__ работает с with. __exit__ вызывается всегда, даже если внутри блока было исключение. В этом и есть гарантия освобождения ресурса.

Контекстные менеджеры

Как with работает внутри

Объект, переданный в with, обязан иметь два метода: __enter__ (вход в блок) и __exit__ (выход). Python вызывает их за вас.

with open("payments.txt") as f: | +-- 1. __enter__() открывает файл, возвращает f | +-- 2. выполняется тело блока with | | | +-- всё ок вызывается __exit__(), файл закрывается | +-- было исключение вызывается __exit__(), файл закрывается, | затем исключение летит дальше | +-- 3. ресурс гарантированно освобождён

Главная ценность with: __exit__() вызывается всегда. Даже если внутри блока было исключение, ресурс будет освобождён.

Контекстные менеджеры

Реальные примеры

Один и тот же синтаксис with работает для файлов, баз данных, блокировок, сокетов, временных директорий.

# Файлы with open("payments.txt", "r") as f: data = f.read() with open("report.txt", "w") as f: f.write("Отчёт по транзакциям") # База данных import psycopg2 with psycopg2.connect(database="payments") as conn: with conn.cursor() as cursor: cursor.execute("SELECT * FROM transactions") rows = cursor.fetchall() # Блокировка потока import threading lock = threading.Lock() with lock: # только один поток здесь одновременно balance += amount

Контекстные менеджеры

Реальный пример: транзакция в БД

При работе с базой данных with делает сразу две вещи: ведёт транзакцию (commit на успехе, rollback на ошибке) и закрывает соединение в конце.

import psycopg2 def transfer(from_id: str, to_id: str, amount: float) -> None: with psycopg2.connect(database="payments") as conn: with conn.cursor() as cur: cur.execute( "UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, from_id), ) cur.execute( "UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, to_id), ) # На выходе из with: # успех -> conn.commit(), cur.close(), conn.close() # ошибка -> conn.rollback(), cur.close(), conn.close()

В фоновой задаче Celery такая же конструкция гарантирует, что после выполнения задачи в БД не останется зависших транзакций и незакрытых соединений между задачами воркера.

Контекстные менеджеры

with и try/except вместе

with отвечает за ресурсы, try/except отвечает за ошибки. Часто их используют вместе.

try: with open("payments.txt", "r") as f: data = f.read() process(data) except FileNotFoundError as e: print(f"Файл не найден: {e}") except ValueError as e: print(f"Ошибка обработки: {e}") # Файл закрыт в любом случае. # Исключение поймано и обработано.

Файл закрывается автоматически, даже когда исключение поймано блоком try/except снаружи.

Контекстные менеджеры

DBTransaction: автоматический COMMIT или ROLLBACK

class DBTransaction: def __init__(self, session_id: str): self.session_id = session_id def __enter__(self) -> "DBTransaction": print(f"[BEGIN] Сессия {self.session_id}") return self def __exit__(self, exc_type, exc_val, exc_tb) -> bool: if exc_type is None: print(f"[COMMIT] Сессия {self.session_id}, данные сохранены") else: print(f"[ROLLBACK] {exc_type.__name__}: {exc_val}") return False # пробрасываем исключение наружу def execute(self, query: str) -> None: print(f" [SQL] {query}") # Успех: автоматический COMMIT with DBTransaction("SES_001") as tx: tx.execute("UPDATE accounts SET balance = balance - 350000 WHERE id = 'A'") tx.execute("UPDATE accounts SET balance = balance + 350000 WHERE id = 'B'") # Ошибка: автоматический ROLLBACK try: with DBTransaction("SES_002") as tx: tx.execute("UPDATE accounts SET balance = balance - 999999 WHERE id = 'A'") raise ValueError("CHECK constraint: balance < 0") except ValueError as e: print(f"[API] {e}")

Контекстные менеджеры

@contextmanager: менеджер через генератор

Не всегда нужен целый класс. Для простых случаев есть декоратор @contextmanager из contextlib: код до yield это __enter__, после yield это __exit__.

from contextlib import contextmanager import time @contextmanager def measure_time(operation: str): """Измеряет время выполнения""" start = time.perf_counter() print(f"[TIMER] Старт: {operation}") try: yield # здесь выполняется код внутри with finally: ms = (time.perf_counter() - start) * 1000 print(f"[TIMER] {operation}: {ms:.1f}мс") @contextmanager def redis_lock(resource_id: str): """Имитация распределённого лока""" key = f"lock:{resource_id}" print(f"[REDIS] Захват {key}") try: yield key finally: print(f"[REDIS] Освобождение {key}") with measure_time("payment"): with redis_lock("account:UZ_5521") as lock: print(f" Работаем (lock={lock})") time.sleep(0.03)

Порядок выхода обратен порядку входа. Внутренний лок снимается первым.

Контекстные менеджеры

Без with и с with

Без withС with
Закрытие ресурсаВручнуюАвтоматически
При ошибкеРесурс остаётся открытымВсё равно закрывается
ЧитаемостьБольше кодаЧище и короче
Утечки памятиВозможныИсключены

Новый раздел

Модуль logging

Логи: то, что остаётся после сбоя

Модуль logging

Что такое логирование

Логирование: запись событий программы во время её работы. Что произошло, когда, на каком сервере, в какой функции.

Аналогия: бортовой самописец самолёта. После любого инцидента инженер открывает запись и видит, что происходило в системе перед сбоем.

  • В продакшне нет дебаггера. Доступно только то, что попало в логи.
  • В микросервисах транзакция проходит через несколько сервисов: без логов не найти, где она застряла.
  • Аудит. Банк обязан хранить, кто и что делал в системе.

Модуль logging

Почему print() в продакшне не годится

ПроблемаПочему важно
Нет уровня важностиНе понимаем, это DEBUG или CRITICAL
Нет временной меткиКогда это случилось?
Нет файла и строкиОткуда это в коде?
Нельзя фильтроватьНе агрегируется в ELK Stack
Нельзя ротироватьФайл растёт бесконечно
УровеньКогда использовать
DEBUGДетальный трейс для отладки. В продакшне выключен
INFOСтандартные события: транзакция создана, запрос принят
WARNINGНештатно, но система работает: retry #2, медленный запрос
ERRORОдна операция упала, система продолжает работу
CRITICALСерьёзно: база недоступна, под угрозой весь сервис

Модуль logging

Настройка логгера

Три шага: получить логгер по имени, задать уровень, добавить обработчик с форматом.

import logging import sys logger = logging.getLogger("PaymentService") logger.setLevel(logging.DEBUG) handler = logging.StreamHandler(sys.stdout) handler.setFormatter(logging.Formatter( '%(asctime)s [%(levelname)s] %(message)s' )) logger.addHandler(handler)

В продакшне дополнительно добавляют RotatingFileHandler: пишет в файл и ротирует его по размеру, чтобы лог не съел диск.

Модуль logging

Использование: пять уровней

logger.debug("Старт обработки tx=TX_001") logger.info("TX обработана. Сумма: 500_000 UZS") logger.warning("Медленный запрос: 120мс") logger.error("TX отклонена: превышен лимит") logger.critical("База недоступна, очередь ретрая полна")
10:30:00 [DEBUG] Старт обработки tx=TX_001 10:30:00 [INFO] TX обработана. Сумма: 500_000 UZS 10:30:00 [WARNING] Медленный запрос: 120мс 10:30:00 [ERROR] TX отклонена: превышен лимит 10:30:00 [CRITICAL] База недоступна, очередь ретрая полна

Уровень выбирается под событие. В продакшне обычно фильтруют от INFO и выше: DEBUG слишком шумный.

Модуль logging

exc_info=True: добавляет traceback в лог

Без exc_info=True лог покажет только сообщение об ошибке. С exc_info=True добавит полный traceback. Это критично для дебага.

# Без exc_info logger.error(f"TX упала: {e}") # В логе: # "TX упала: Превышен лимит" # Нет файла, нет строки, нет стека вызовов. # С exc_info=True logger.error(f"TX упала: {e}", exc_info=True) # В логе: # ERROR - TX упала: Превышен лимит # Traceback (most recent call last): # File "payment.py", line 30, in process # raise ValueError(...) # ValueError: Превышен лимит: 100000000
Уровеньexc_info=True?
logger.error()Всегда
logger.critical()Всегда
logger.warning()Если предупреждение связано с исключением
logger.info()Не нужно
logger.debug()Не нужно

Модуль logging

Широкий except + обязательное логирование

Иногда нельзя предугадать все ошибки, тогда используют except Exception. Но это не оправдание молчать: ловишь всё, обязан записать всё.

import logging logger = logging.getLogger(__name__) # стандарт: один логгер на модуль def safe_check_fraud(tx: dict) -> dict: try: is_fraud = check_fraud(tx) return {"status": "BLOCKED" if is_fraud else "APPROVED"} except Exception as e: logger.error( f"Неожиданная ошибка антифрода: {type(e).__name__}: {e}", exc_info=True ) return {"status": "ERROR", "reason": "Fraud check failed"}
Часть сообщенияЧто даёт
"Неожиданная ошибка антифрода"Контекст: где именно упало
{type(e).__name__}Имя класса ошибки, для фильтрации в Sentry
{e}Текст ошибки, для понимания что случилось
exc_info=TrueПолный traceback, для понимания где в коде

Это называется graceful degradation: сервис не падает, но ошибка зафиксирована.

Модуль logging

Три уровня перехвата

# Идеально: конкретные исключения, своя реакция на каждое try: response = external_api.call() except TimeoutError: retry_later() except ValueError: return {"status": "INVALID_INPUT"} # Допустимо: широкий перехват с обязательным логированием try: is_fraud = check_fraud(tx) except Exception as e: logger.error( f"Неожиданная ошибка: {type(e).__name__}: {e}", exc_info=True ) return {"status": "ERROR"} # Запрещено: широкий перехват без логирования try: is_fraud = check_fraud(tx) except Exception: pass # ошибка исчезла, никто не узнает

Без лога ошибка тихо проглочена. Сервис продолжает «работать», бизнес-логика даёт неверный ответ, данные портятся. Это сокрытие улик, а не защита.

Итоги

Что освоили на лекции

КонцепцияКлючевой вывод
Чтение TracebackСнизу вверх: тип, место, путь
Иерархия исключенийЭто ООП: конкретные except сверху, общие снизу
try / except / else / finallyВ try минимум кода; else при успехе; finally всегда
Blanket exceptexcept Exception без логирования = тихая смерть
UnboundLocalErrorИнициализируй ресурсы None до try
raiseПереводим бизнес-правила на язык исключений
with / contextmanagerГарантированное освобождение ресурсов
logging5 уровней, exc_info=True, понятный формат сообщений

Финальная задача

SWIFT-процессор

Дано: список платежей. Часть с битыми полями, часть нарушает бизнес-правила, среди получателей есть санкционный. Задача: процессор не падает на любом входе, считает ошибки по трём категориям.

RAW_LEDGER = [ {"id": "SW_001", "from": "UZ_A", "to": "UZ_B", "amount": "350000"}, # OK {"id": "SW_002", "from": "UZ_C", "to": "UZ_C", "amount": "90000"}, # сам себе {"id": "SW_003", "from": "UZ_D", "amount": "120000"}, # нет 'to' {"id": "SW_004", "from": "UZ_E", "to": "UZ_F", "amount": "БИТЫЕ"}, # не число None, # не словарь {"id": "SW_005", "from": "UZ_G", "to": "UZ_H", "amount": "75000000"}, # превышен лимит {"id": "SW_006", "from": "UZ_I", "to": "SANCTION", "amount": "10000"}, # санкции ]

Финальная задача

Решение: валидатор

Три класса ошибок по категориям. Один валидатор: либо OK, либо raise нужного типа.

import logging logger = logging.getLogger("swift") MAX = 10_000_000 SANCTIONED = {"SANCTION"} class StructureError(Exception): pass # битые данные class BusinessError(Exception): pass # правила бизнеса class ComplianceError(Exception): pass # санкции def process_payment(p): if not isinstance(p, dict): raise StructureError(f"Не словарь: {type(p).__name__}") for field in ("from", "to", "amount"): if field not in p: raise StructureError(f"Нет поля: {field}") try: amount = float(p["amount"]) except ValueError: raise StructureError(f"Сумма не число: {p['amount']}") if p["from"] == p["to"]: raise BusinessError(f"Тот же счёт: {p['from']}") if amount > MAX: raise BusinessError(f"Лимит: {amount}") if p["to"] in SANCTIONED: raise ComplianceError(f"Санкции: {p['to']}")

Финальная задача

Решение: пакетная обработка

Один try/except с тремя ветками. Каждый тип ошибки идёт в свой счётчик и в свой уровень лога. Программа не падает ни на чём.

def process_batch(payments): report = {"ok": 0, "structure": 0, "business": 0, "compliance": 0} for p in payments: try: process_payment(p) report["ok"] += 1 except StructureError as e: report["structure"] += 1 logger.warning(f"[STRUCT] {e}") except BusinessError as e: report["business"] += 1 logger.warning(f"[BIZ] {e}") except ComplianceError as e: report["compliance"] += 1 logger.error(f"[COMP] {e}") return report print(process_batch(RAW_LEDGER))
{'ok': 1, 'structure': 3, 'business': 2, 'compliance': 1}

Материалы

Задачи

↓ Скачать

15 заданий и финальная задача «Платёжный процессор». Открывается в Jupyter, VS Code или Google Colab.

Python · Лекция 8 · Defensive Programming
1 / 53