Сомнительно, чтобы у вашего бэкенда все было в рамках одного процесса: нужно читать конфигурацию, хранить данные о клиентах, писать логи и метрики о состоянии вашего ПО.

Если вы работаете над сетевым приложением — все еще сложнее: ваша база данных может находиться очень далеко от работающего кода.

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

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

В сообщении в блоге мы рассмотрим некоторые распространенные сбои, которые можно решить, повторив попытку. Основные идеи описаны с помощью Python, но для понимания не требуется опыта работы с языком.

Сбои, когда повторная попытка может помочь

Retry помогает в тех случаях, когда наш код действует как клиент какого-то другого бэкенда. Это может быть оболочка для клиента базы данных, клиент для HTTP-сервера и т. д. Как потребитель мы ожидаем (иногда без каких-либо причин), что проблема, мешающая успешной обработке, может быть исправлена ​​кем-то другим в ближайшее время.

Я могу назвать две категории проблем, когда я ищу это:

  • ошибки при передаче данных
  • сбои обработки из-за проблем с загрузкой

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

  • DNSError - доменное имя не может быть преобразовано в IP. Это не всегда означает, что система недоступна. Это может произойти, когда ваш пункт назначения перераспределяется.
  • ConnectionError - нам не удалось успешно установить соединение. Соединение может быть нестабильным на сетевом уровне в момент запроса.
  • Timeout - сервер не может отправить байт по истечении указанного таймаута, но соединение ранее было установлено. Некоторые предыдущие запросы могут быть даже успешно обработаны с использованием экземпляра вашего HTTPClient. Ошибка может возникнуть, когда инфраструктура вашего места назначения уменьшилась, а конкретный сервер, который вы использовали, сейчас не существует. Но сервис доступен. Вам нужно воссоздать HTTPClient и попытаться установить новое соединение.

Некоторые распространенные проблемы с базой данных из той же группы:

  • OperationalError - ошибки возникают, когда соединение с хранилищем потеряно или не может быть установлено в данный момент. Я думаю, что в таких случаях стоит пересоздать экземпляр клиента и попытаться снова подключиться. Вы можете увидеть примеры кодов ошибок, возвращаемых PostgreSQL:
Class 08 — Connection Exception
08000	connection_exception
08003	connection_does_not_exist
08006	connection_failure
08001	sqlclient_unable_to_establish_sqlconnection
08004	sqlserver_rejected_establishment_of_sqlconnection
  • ProtocolError — это пример из Redis. Исключение возникает, когда сервер Redis получает последовательность байтов, которая транслируется в бессмысленную операцию. Поскольку вы тестируете свое программное обеспечение перед его развертыванием, маловероятно, что ошибка возникает из-за плохо написанного кода. Давайте обвиним наш транспортный уровень :).

Размышляя о 2-й категории сбоев, также известных как проблемы с загрузкой, я хотел бы просмотреть следующие ответы от HTTP-сервера:

  • 408 Request Timeout возвращается, когда сервер потратил больше времени на обработку вашего запроса, чем он был готов ждать. Возможная причина: ресурс перегружен большим количеством входящих запросов. Ожидание и повторная попытка после некоторой задержки могут быть хорошей стратегией, чтобы в конечном итоге завершить обработку данных на вашей стороне.
  • 429 Too Many Requests означает, что вы отправили больше запросов, чем позволяет вам сервер в течение некоторого периода времени. Этот метод, который используется сервером, также известен как ограничение скорости. Хорошо, что сервер ДОЛЖЕН возвращать заголовок Retry-After, который дает рекомендацию, как долго вам нужно ждать, прежде чем делать следующий запрос.
  • 500 Internal Server Error. Это самая печально известная ошибка HTTP-сервера. Разнообразие причин ошибки зависит только от добросовестности разработчиков. Для всех неперехваченных исключений там возвращается ответ. У меня нет твердого мнения, что мы должны постоянно повторять такие ошибки. Для каждой службы, которую вы используете, вы должны узнать, в чем причина ответа.

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

  • 503 Service Unavailable - служба в настоящее время не может обработать запрос из-за временной перегрузки. Вы можете ожидать, что это будет облегчено после некоторой задержки. Сервер МОЖЕТ отправить заголовок Retry-After, как это было указано для кода состояния 429 Too Many Requests.
  • 504 Gateway Timeout похоже на 408 Request Timeout, но означает, что соединение с вашим HTTP-клиентом было закрыто обратным прокси-сервером, который стоит перед сервером.

Но, будем надеяться, мы не живем в мире, где все передается по протоколу HTTP. Я хочу поделиться своим опытом повторной обработки ошибок базы данных:

  • OperationalError. Да, мы уже встречались с этим парнем в блоге. Как для PostgreSQL, так и для MySQL он дополнительно покрывает сбои, которые не находятся под контролем инженера-программиста. Примеры: при обработке произошла ошибка выделения памяти, или транзакция не может быть обработана. Я рекомендую повторить их.
  • IntegrityError - это сложно. Его можно поднять, когда нарушается ограничение внешнего ключа, например, когда вы пытаетесь вставить Record A, который зависит от Record B. И Record B может быть еще не добавлен из-за асинхронного характера вашей системы. В этом случае я бы повторил попытку. С другой стороны, исключение также возникает, когда ваша попытка добавить запись приводит к дублированию уникального ключа. Вряд ли мы захотим повторить попытку в этот раз. Вы можете спросить меня, как различать такие случаи и повторять попытку, когда это необходимо. Надеюсь, ваша СУБД вернет код ошибки. И ваш драйвер SQL уже знает, как сопоставить их между классами исключений. Вот пример для MySQL:
# from pymysql.err
_map_error(IntegrityError, ER.DUP_ENTRY, ER.NO_REFERENCED_ROW,
           ER.NO_REFERENCED_ROW_2, ER.ROW_IS_REFERENCED,
           ER.ROW_IS_REFERENCED_2, ER.CANNOT_ADD_FOREIGN,
           ER.BAD_NULL_ERROR)
# from pymysql.constants.ER
BAD_NULL_ERROR = 1048
DUP_ENTRY = 1062
NO_REFERENCED_ROW = 1216
ROW_IS_REFERENCED = 1217
ROW_IS_REFERENCED_2 = 1451
NO_REFERENCED_ROW_2 = 1452
CANNOT_ADD_FOREIGN = 1215

Зная эту информацию, вы можете сделать так, чтобы ваше программное обеспечение не повторяло попытки IntegrityError только тогда, когда оно DUP_ENTRY, и повторяло попытки в других разумных случаях. Использованная литература:

Наивная реализация декоратора повторов

Я могу думать о повторных попытках только для операций ввода-вывода. Начиная с 2014 года мы стараемся создавать производственное программное обеспечение, которое выполняет такие операции асинхронно. Twisted и asyncio всегда были нашими друзьями.

В Python есть выразительная концепция декораторов: синтаксис для добавления новой функциональности к существующим функциям с использованием возможностей Программирования высокого порядка.

Например, у нас есть функция fetch для выполнения асинхронного HTTP-запроса и загрузки страницы для python.org:

# Example is taken from http://aiohttp.readthedocs.io/en/stable/#getting-started
import aiohttp
import asyncio
async def fetch(session, url):
    async with session.get(url) as response:
        return await response.text()
# Client code, provided for reference
async def main():
    async with aiohttp.ClientSession() as session:
        html = await fetch(session, 'http://python.org')
        print(html)
loop = asyncio.get_event_loop()
loop.run_until_complete(main())

Функция fetch работает нормально, но может быть недостаточно надежной. Он не защищен от всех ошибок HTTP, перечисленных выше. Но мы можем сделать его лучше:

@retry(aiohttp.DisconnectedError, aiohttp.ClientError,
       aiohttp.HttpProcessingError)
async def fetch(session, url):
    async with session.get(url) as response:
        return await response.text()

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

import logging
from functools import wraps
log = logging.getLogger(__name__)
def retry(*exceptions, retries=3, cooldown=1, verbose=True):
    """Decorate an async function to execute it a few times before giving up.
    Hopes that problem is resolved by another side shortly.
    Args:
        exceptions (Tuple[Exception]) : The exceptions expected during function execution
        retries (int): Number of retries of function execution.
        cooldown (int): Seconds to wait before retry.
        verbose (bool): Specifies if we should log about not successful attempts.
    """
    def wrap(func):
        @wraps(func)
        async def inner(*args, **kwargs):
            retries_count = 0
            while True:
                try:
                    result = await func(*args, **kwargs)
                except exceptions as err:
                    retries_count += 1
                    message = "Exception during {} execution. " \
                              "{} of {} retries attempted".
                              format(func, retries_count, retries)
                    if retries_count > retries:
                        verbose and log.exception(message)
                        raise RetryExhaustedError(
                            func.__qualname__, args, kwargs) from err
                    else:
                        verbose and log.warning(message)
                    if cooldown:
                        await asyncio.sleep(cooldown)
                else:
                    return result
        return inner
    return wrap

Как видите, основная идея состоит в том, чтобы перехватывать ожидаемые исключения до тех пор, пока мы не достигнем предела количества retries. Между каждым выполнением мы ждем cooldown секунд. Также мы пишем логи о каждой неудачной попытке, если хотим быть подробными.

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

Решения производственного уровня

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

  • типы исключений для повторной попытки
  • количество попыток
  • время между попытками
  • подробность для регистрации неудачных попыток

Иногда этого достаточно. Но я знаю случаи, когда нужно больше возможностей. Выберите те, которые выглядят сексуально для вас из списка возможных возможностей:

  • повторить синхронные функции
  • остановка после некоторого тайм-аута, независимо от количества попыток
  • ждать случайное время в пределах некоторых границ между попытками
  • экспоненциальная отсрочка ожидания между попытками
  • указание дополнительных атрибутов для повторных попыток исключений, таких как целочисленные коды ошибок (вы помните пример с IntegrityError, верно?)
  • предоставление крючков до и после попыток
  • повторная попытка не на исключениях, а на некоторых значениях, которые удовлетворяют предикату
  • использование некоторого примитива синхронизации для ограничения количества текущих запросов к некоторому бэкэнду
  • динамическое чтение конфигурации для логики повторных попыток из какого-либо другого источника, например, из Feature Flags as a Service
  • повторять бесконечно (для меня это звучит безумно, но кто знает о вашем случае)

Если вы ищете библиотеку повторных попыток для добавления в свой продукт на основе Python, вас могут заинтересовать следующие сторонние проекты:

Не используете Python на своем бэкэнде? По крайней мере, JavaScript, Go и Java имеют открытые реализации помощников повторных попыток.

Резюме

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

Бэкенд-программирование — это создание оболочки вызовов программного обеспечения, созданного кем-то другим.

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

Как вы решаете, когда повторить попытку?

Что вы используете для обеспечения такой функциональности?