Сообщить об ошибке.

Правильная обработка и регистрация исключений

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

  • Получение заказа из базы;
  • Прослушивание новых событий;
  • Печать чека;
  • Отправка квитанций в налоговую.

Если не обрабатывать возможные ошибки, то в любой момент может все сломаться. У такого сервиса, в объекте заказа, может отсутствовать важная информация, или может не работать связь с налоговой, что бы синхронизировать квитанцию, или, может быть недоступен сервер баз данных, или, например, в принтере закончилась бумага.

Необходимо правильно и активно реагировать на любую ситуацию, чтобы минимизировать ошибки при обработке новых заказов.

Ниже приведен пример класса OrderService такого микросервиса, который хотя и будет работать, но он очень плохо и неэффективно спроектирован:

class OrderService:
    """Класс микросервиса"""
    def emit(self, order_id: str) -> dict:
        try:
            # Получение заказа из базы
            order_status = status_service.get_order_status(order_id)
        except Exception as e:
            logger.exception(
                f"Заказ {order_id} не найден в БД "
                f"Исключение: {e}."
            )
            raise e

        # Прослушивание событий заказа
        (is_order_locked_in_emission,
         seconds_in_emission) = status_service.is_order_locked_in_emission(order_id)
        if is_order_locked_in_emission:
            logger.info(
                "Эмиссия была заблокирована после долгого ожидания!"
                f"Время, проведенное в этом состоянии: {seconds_in_emission} сек. "
                f"Заказ: {order_id}, order_status: {order_status.value}"
            )

        elif order_status == OrderStatus.EMISSION_IN_PROGRESS:
            logger.info("Прерывание эмиссии, т.к. она уже выполняется!")
            return {"order_id": order_id, "order_status": order_status.value}

        elif order_status == OrderStatus.EMISSION_SUCCESSFUL:
            logger.info(
                "Прерывание эмиссии, потому что она уже произошла! "
                f"Заказ: {order_id}, "
                f"order_status: {order_status.value}"
            )
            return {"order_id": order_id, "order_status": order_status.value}

        try:
            # Печать чека
            receipt_note = receipt_service.create(order_id)
        except Exception as e:
            logger.exception(
                "Печать чека не удалась! "
                f"Заказ: {order_id}, "
                f"Исключение: {e}"
            )
            raise e

        try:
            # Передача чека в налоговою
            broker.emit_receipt_note(receipt_note)
        except Exception as e:
            logger.exception(
                "Передача чека не удалась! "
                f"Заказ: {order_id}, "
                f"Исключение: {e}"
            )
            raise e

        order_status = status_service.get_order_status(order_id)
        return {"order_id": order_id, "order_status": order_status.value}

Сосредоточимся на чрезмерных действиях в классе OrderService, которые делают его чем-то вроде антипаттерна Blob, а позже рассмотрим правильный повторный вызов исключений + правильное ведение журнала исключений.

Смотря на класс OrderService, кажется, что он слишком много делает. Начинающие программисты могут возразить, что этот сервис делает только то, что должен делать (т. е. все шаги, связанные с генерацией квитанций), но он выполняет гораздо больше работы.

Класс OrderService, по большому счету, фокусируется на отслеживании/создании ошибок (например, база данных, печать, статус заказа), а не на том, что он должен делать (например, получение, проверка статуса, создание, отправка) и как на все это реагировать в случае сбоев.

Если этот класс нужно будет повторно использовать (скажем, покупателю нужна еще одна копия квитанции о заказе), то придется копировать большую часть кода. Хотя OrderService работает нормально, его сложно поддерживать, и неясно, как один шаг коррелирует с другим из-за повторяющихся блоков исключений между шагами, которые, к тому же отвлекают внимание от бизнес-логики.

Первое улучшение: делаем исключения конкретными.

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

В примере представлено только то, что модифицируется:

try:
    order_status = status_service.get_order_status(order_id)
except Exception as e:
    logger.exception(...)
    # конкретизируем исключение при повторном вызове 
    raise OrderNotFound(order_id) from e

...

try:
    ...
except Exception as e:
    logger.exception(...)
    # конкретизируем исключение при повторном вызове
    raise ReceiptGenerationFailed(order_id) from e

try:
    broker.emit_receipt_note(receipt_note)
except Exception as e:
    logger.exception(...)
    # конкретизируем исключение при повторном вызове
    raise ReceiptEmissionFailed(order_id) from e

Обратите внимание на использование from e, который является правильным способом вызова исключения из другого и сохраняет полную трассировку стека.

Второе улучшение: Каждый класс должен заниматься своими делом.

Чтобы "не учить" класс OrderService тому, что может пойти не так, вынесем обработку ошибок в классы, которые отвечают за каждый конкретный шаг логики.

# Вынесем исключения из класса `OrderService`
class StatusService:
    """Проверка статуса заказа"""
    def get_order_status(order_id):
        try:
            ...
        except Exception as e:
            raise OrderNotFound(order_id) from e

class ReceiptService:
    """Печать чека заказа"""
    def create(order_id):
        try:
            ...
        except Exception as e:
            raise ReceiptGenerationFailed(order_id) from e

class Broker:
    """Передача чека заказа"""
    def emit_receipt_note(receipt_note):
        try:
            ...
        except Exception as e:
            raise ReceiptEmissionFailed(order_id) from e

Теперь классы StatusService, ReceiptService и Broker сами будут сообщать, если произойдет исключение!

class OrderService:
    """Класс микросервиса"""
    def emit(self, order_id: str) -> dict:
        try:
            order_status = status_service.get_order_status(order_id)

            (is_order_locked_in_emission,
             seconds_in_emission) = status_service.is_order_locked_in_emission(order_id)
            if is_order_locked_in_emission:
                logger.info(
                    "Эмиссия была заблокирована после долгого ожидания!"
                    f"Время, проведенное в этом состоянии: {seconds_in_emission} сек. "
                    f"Заказ: {order_id}, order_status: {order_status.value}"
                )

            elif order_status == OrderStatus.EMISSION_IN_PROGRESS:
                logger.info("Прерывание эмиссии, т.к. она уже выполняется!")
                return {"order_id": order_id, "order_status": order_status.value}

            elif order_status == OrderStatus.EMISSION_SUCCESSFUL:
                logger.info(
                    "Прерывание эмиссии, потому что она уже произошла! "
                    f"Заказ: {order_id}, "
                    f"order_status: {order_status.value}"
                )
                return {"order_id": order_id, "order_status": order_status.value}

            receipt_note = receipt_service.create(order_id)
            broker.emit_receipt_note(receipt_note)
            order_status = status_service.get_order_status(order_id)
        # теперь классы сами сообщают об исключениях
        except OrderNotFound as e:
            logger.exception(
                f"Заказ {order_id} не найден в БД "
                f"Исключение: {e}."
            )
            raise
        except ReceiptGenerationFailed as e:
            logger.exception(
                "Печать чека не удалась! "
                f"Заказ: {order_id}, "
                f"Исключение: {e}"
            )
            raise
        except ReceiptEmissionFailed as e:
            logger.exception(
                "Передача чека не удалась! "
                f"Заказ: {order_id}, "
                f"Исключение: {e}"
            )
            raise
        else:
            return {"order_id": order_id, "order_status": order_status.value}

Теперь есть один блок try/except, в котором располагается вся бизнес-логика. Сгруппированы блоки except с конкретными исключениями, которые дают понимание ситуации "когда и что произойдет" , и, наконец, есть блок else, описывающий, что произойдет, если все пройдет успешно.

Обратите внимание, что сохранена инструкция raise без повторного объявления объекта исключения e. Это не опечатка, а правильный способ повторного вызова текущего исключения.

Третье улучшение: улучшенная регистрация.

Вместо того, чтобы выводить пользовательское сообщение в блоках исключений, исключения должны делать это сами - в конце концов, они уже конкретные. Это напоминает принцип Tell-Don’t-Ask, который помогает помнить, что объектно-ориентированное программирование предназначено для связки данных и функций для их обработки.

### Создаем классы исключений
class OrderCreationException(Exception):
    pass

class OrderNotFound(OrderCreationException):
    def __init__(self, order_id):
        self.order_id = order_id
        super().__init__(f"Заказ {order_id} не найден в БД.")

class ReceiptGenerationFailed(OrderCreationException):
    def __init__(self, order_id):
        self.order_id = order_id
        super().__init__(f"Печать чека не удалась! Заказ: {order_id} ")

class ReceiptEmissionFailed(OrderCreationException):
    def __init__(self, order_id):
        self.order_id = order_id
        super().__init__(f"Передача чека не удалась! Заказ: {order_id} ")

class OrderService:
    """Класс микросервиса"""
    def emit(self, order_id: str) -> dict:
        try:
            ...
        except OrderNotFound:
            logger.exception("Ошибка базы данных")
            raise
        except ReceiptGenerationFailed:
            logger.exception("Проблема с генерацией квитанции")
            raise
        except ReceiptEmissionFailed:
            logger.exception("Проблема с отправкой квитанции")
            raise
        else:
            return {"order_id": order_id, "order_status": order_status.value}

Обратите внимание, что рекомендуемый способ логирования исключений, точно такой же, как показано выше: logger.exception('ЛЮБОЕ СООБЩЕНИЕ'). Не нужно больше передавать объект исключения e, так как он передается неявно. Кроме того, пользовательское сообщение, которое определяется внутри каждого класса исключения с помощью order_id, также будет отображаться в журналах, следовательно выполнено требование Don’t repeat yourself.

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

Пример вывода сообщения об исключении:

Невозможно отправить квитанцию
Traceback (most recent call last):
  File "/path/test.py", line 19, in <module>
    tryme()
  File "/path/test.py", line 14, in tryme
    raise ReceiptEmissionFailed(order_id)
ReceiptEmissionFailed: Передача чека не удалась! Заказ: 10

Окончательное улучшение.

Класс OrderService больше похож на координацию вызовов реальных сервисов бизнес-логики, что лучше подходит в качестве шаблона проектирования Facade, который позволяет скрыть сложность системы путём сведения всех возможных внешних вызовов к одному объекту, делегирующему их соответствующим объектам системы.

Кроме того, можно заметить, что он запрашивает у status_service данные, чтобы что-то с ними делать, что ломает принцип Tell-Don’t-Ask.

Перейдем к упрощению:

# Переименовано, для соответствия тому, что есть на самом деле
class OrderFacade:
    def emit(self, order_id: str) -> dict:
        try:
            # ПРИМЕЧАНИЕ: регистрация информации по-прежнему происходит внутри
            status_service.ensure_order_unlocked(order_id)
            receipt_note = receipt_service.create(order_id)
            broker.emit_receipt_note(receipt_note)
            order_status = status_service.get_order_status(order_id)
        except OrderAlreadyInProgress as e:
            # Новый блок
            logger.info("Прерывание запроса на эмиссию, т.к. он уже выполняется!")
            return {"order_id": order_id, "order_status": e.order_status.value}
        except OrderAlreadyEmitted as e:
            # Новый блок
            logger.info(f"Прерывание эмиссии, т.к. это уже произошло! {e}")
            return {"order_id": order_id, "order_status": e.order_status.value}
        except OrderNotFound:
            logger.exception("Ошибка базы данных")
            raise
        except ReceiptGenerationFailed:
            logger.exception("Проблема с генерацией квитанции")
            raise
        except ReceiptEmissionFailed:
            logger.exception("Проблема с отправкой квитанции")
            raise
        else:
            return {"order_id": order_id, "order_status": order_status.value}

В примере выше, код, отвечающий за прослушивание событий заказа был вынесен в отдельный метод .sure_order_unlocked(), принадлежащей классу StatusService, который теперь отвечает за генерацию/регистрацию исключений.

Теперь видно что происходит, когда все идет хорошо, и как крайние случаи могут привести к разным результатам.

Обратите внимание, что для блоков исключений OrderAlreadyInProgress и OrderAlreadyEmitted в журнале выводится объект исключения e. В данном случае, это сделано потому, что в них не используется уровень журналирования log.exception, следовательно, сообщение об исключении не появятся.

Какие выводы нужно сделать из этого материала?

Всегда классифицируйте пользовательские исключения с помощью базового класса и расширяйте все конкретные исключения из него. Это во-первых полезно, а во вторых можно повторно использовать логику для связанного кода.

Исключения - это объекты, которые несут с собой информацию. Не стесняйтесь добавлять пользовательские атрибуты, которые могут помочь понять, что происходит. Не позволяйте бизнес-коду теряться в большом количестве блоков try/except и сообщений.

# базовой исключение для категории
class OrderCreationException(Exception):
    pass

# Конкретная ошибка с пользовательским сообщением.
class OrderNotFound(OrderCreationException):
    def __init__(self, order_id):
        self.order_id = order_id  # custom property
        super().__init__(
            f"Заказ №{order_id} в БД не найден"
        )

Эффективный захват и повторный вызов исключений.

Еще одна вещь, которую люди часто делают неправильно - это захват и создание исключений.

Вызов текущего исключения после захвата:

try:
    ...
except CustomException as ex:
    # делаем что-то (например, ведение журнала)
    raise

Вызов другого исключения:

Это особенно актуально, так как сохраняет всю трассировку стека и помогает команде делать отладку.

try:
    ...
except CustomException as ex:
    raise MyNewException() from ex

Эффективная регистрация исключений.

Еще один совет, который убережет от лишнего кода - это использование метода Logger.exception().

Не нужно регистрировать объект исключения. Метод Logger.exception() предназначен для использования "как есть" внутри блоков except. Он уже обрабатывает трассировку стека с информацией о выполнении и отображает, какое исключение его вызвало, с его сообщением, установленным на уровень ERROR!

try:
    ...
except CustomException:
    logger.exception("custom message")