deшка
Урок 3.9

Исключения и политика плохих данных

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

В загрузке данных исключения это не «аварийная ситуация», а часть нормальной работы: источник недоступен, в строке не число, у записи нет обязательного поля. Вопрос не в том, как их ловить, а в том, какие ловить, где и что делать дальше. Самый дорогой баг в пайплайне это except Exception: pass, после которого витрина месяц заполняется нулями, и никто не знает.

try, except, else, finally

Четыре блока выполняются в строгом порядке:

try:
    value = int(raw)            # код, который может упасть
except ValueError as e:
    print("не число:", e)       # только если упало именно это
else:
    print("число:", value)      # только если try прошёл без исключения
finally:
    print("обработано")         # всегда, в любом случае

else часто кажется лишним, но у него есть смысл: код в else не защищён except. Если положить print("число:", value) внутрь try, то ValueError из самого print (представьте что-то сложнее печати) будет пойман тем же обработчиком и замаскирован под «не число». В try должно быть минимум кода: ровно та строка, от которой вы ждёте исключение.

finally выполняется всегда: после успешного try, после пойманного исключения, после непойманного исключения перед его пробросом наружу и даже после return внутри try. Это место для освобождения ресурсов: закрыть файл, вернуть соединение в пул, снять блокировку. Чаще всего вместо finally пишут with: менеджер контекста делает то же самое, но короче.

except можно писать несколько, проверяются сверху вниз, срабатывает первый подходящий. Поэтому частные исключения идут выше общих.

Иерархия и что ловить

Исключения это классы, и они наследуются. except ValueError ловит и ValueError, и его потомков (UnicodeDecodeError). except Exception ловит почти всё. except: без типа и except BaseException ловят вообще всё, включая KeyboardInterrupt и SystemExit, то есть ваш скрипт перестанет останавливаться по Ctrl+C.

Правило: ловить самое узкое исключение, которое вы умеете обработать. Если на ValueError при разборе строки у вас есть план (пропустить строку, записать в карантин), ловите ValueError. Если плана нет, не ловите: пусть упадёт с полным traceback, и вы узнаете об этом из алерта Airflow, а не через месяц из отчёта.

try:
    amount = int(row["amount"])
except KeyError:
    ...            # нет поля: другая проблема, чем «не число»
except ValueError:
    ...

except Exception as e: log(e) без raise это не обработка, а глушение. Такой код оправдан только на самом верхнем уровне, где вы логируете и завершаете процесс с ненулевым кодом.

Свои исключения

Своё исключение это класс, унаследованный от Exception. Нужно, когда вызывающий код должен отличать вашу ошибку от чужой:

class BadRecord(ValueError):
    def __init__(self, row_number, reason):
        super().__init__(f"строка {row_number}: {reason}")
        self.row_number = row_number
        self.reason = reason

Наследоваться стоит от того встроенного класса, который по смыслу ближе: ошибка данных это ValueError, отсутствие ресурса LookupError или FileNotFoundError. Тогда код, который ловит общий ValueError, продолжит работать, а код, которому важна причина, поймает BadRecord и прочитает row_number.

Когда исключение перехватывается и возбуждается другое, цепочку стоит сохранить: raise BadRecord(n, "не число") from e. В traceback будет и исходная ошибка, и ваша. raise без аргументов внутри except пробрасывает текущее исключение дальше, не трогая traceback: так делают, когда нужно залогировать и отпустить.

Политика плохих записей

У загрузки есть три стратегии при плохой записи, и выбирать нужно одну, заранее, явно.

Упасть. Любая плохая запись останавливает загрузку. Подходит, когда данные должны быть точными и плохая запись это сигнал о сломанном источнике. Самая простая стратегия, и по умолчанию стоит брать её.

Пропустить и посчитать. Плохие записи откладываются в карантин (отдельный файл или таблицу) с причиной, загрузка продолжается, в конце отчёт: «загружено 99 850, отклонено 150». Подходит для шумных источников, где отдельные записи ломаются регулярно, но важно, чтобы число отклонённых видели. Если отклонено больше порога (скажем, 1%), всё равно упасть.

Исправить. Подставить значение по умолчанию. Опасно: ноль вместо суммы попадёт в отчёт и будет выглядеть как настоящее значение. Допустимо только для полей, где умолчание осмысленно и зафиксировано в документации витрины.

Код для второй стратегии:

def load_rows(lines):
    good, rejected = [], []
    for number, line in enumerate(lines, start=1):
        try:
            good.append(parse(line))
        except (ValueError, KeyError) as e:
            rejected.append((number, line, str(e)))
    return good, rejected

Обработчик стоит вокруг одной записи, а не вокруг всего цикла: иначе первая плохая строка прервёт обработку всех остальных. И ловятся только те исключения, которые означают «плохая запись». MemoryError или OSError при чтении это не плохая запись, они должны пройти наверх.

Где ставить обработчик

Исключение летит вверх по стеку вызовов, пока его кто-нибудь не поймает. Место обработчика это место, где известно, что делать дальше. Функция parse(line) не знает, можно ли пропустить строку: это решает загрузка, которая её вызвала. Поэтому parse возбуждает исключение, а load_rows его ловит.

Отсюда два следствия. Низкоуровневые функции не ловят исключений, которые не умеют обработать, и не возвращают None вместо ошибки: None потеряется, исключение нет. Верхний уровень скрипта ловит всё, что дошло, логирует с traceback (logging.exception) и завершает процесс с ненулевым кодом, чтобы оркестратор увидел падение.

Исключение как управление потоком это нормально для Python. StopIteration в итераторах, KeyError в dict, FileNotFoundError вместо os.path.exists перед открытием: проверять заранее и потом делать хуже, чем сделать и поймать, потому что между проверкой и действием мир может измениться.

Как отвечать на собеседовании

Что такое try, except, else, finally? try содержит код, который может упасть; except обрабатывает указанные типы исключений; else выполняется, только если исключения не было, и не защищён обработчиком; finally выполняется всегда, даже при return или непойманном исключении, и нужен для освобождения ресурсов.

Какое исключение ловить? Самое узкое, которое вы умеете обработать. except Exception допустим только на верхнем уровне с логированием и завершением; голый except: ловит и Ctrl+C.

Зачем свои исключения? Чтобы вызывающий код отличал вашу ошибку от чужих и получал из неё данные (номер строки, причину). Наследовать от ближайшего по смыслу встроенного класса, цепочку сохранять через raise ... from.

Как обрабатывать плохие записи в загрузке? Выбрать политику заранее: упасть, пропустить и посчитать с порогом, или исправить умолчанием там, где это задокументировано. Обработчик вокруг одной записи, ловятся только ошибки данных, число отклонённых всегда видно.

Практика

python 3.12 · песочницарешено 0 / 3

Класс BadRecord уже объявлен: наследник ValueError с полями row_number и reason. Напишите parse_amount(raw, row_number): строка с суммой в копейках, пробелы по краям убираются. Пустая строка это BadRecord с причиной "пустая сумма"; строка не из цифр это BadRecord с причиной "не число". Для корректной строки верните int. Если на вход пришёл не str, не маскируйте это под BadRecord: пусть падает обычное исключение.

оболочка, не редактируется
class BadRecord(ValueError):
    def __init__(self, row_number, reason):
        super().__init__(f"строка {row_number}: {reason}")
        self.row_number = row_number
        self.reason = reason
Ctrl+Enter
ТЕСТЫ
ВводВаш выводОжидаемый вывод
print(parse_amount(" 1200 ", 1))—1200
try: parse_amount("12x", 7) except BadRecord as e: print(e, "|", e.row_number, e.reason)—строка 7: не число | 7 не число
try: parse_amount(" ", 3) except ValueError as e: print(type(e).__name__, e.reason)—BadRecord пустая сумма
скрытый тест 4
решённые задания сохранятся в прогресс после входа
Войти, чтобы отмечатьдальше →
3.10 →Файлы и форматы: CSV, JSON, JSON Lines, Parquet