
При работе с внешними интеграциями мы часто реализуем базовую реакцию на ошибки. В большинстве случаев достаточно ограничиться
response.raise_for_status()
, а детальную обработку оставить на потом.Нередко мы не управляем ошибками. Не знаем в действительности ни как поведет себя внешняя система, ни какие типы этих ошибок следует от нее ожидать. В самом деле, бывает непросто учесть все возможные крайние случаи и обеспечить соответствующее ответное действие.
Что делать, когда сервер возвращает ошибку 503? А если превышен лимит запросов? А, допустим, истекло время ожидания и тому подобное? Мы неизбежно получаем длинный список исключений и обработчиков, которые необходимо реализовать, задокументировать и протестировать. Однако ситуацию можно улучшить…
Мы можем не ограничиваться генерацией одного и того же исключения независимо от обстоятельств. Я обнаружил, что естественно выделить два основных типа возможных ошибок. Первый из них — это временные, которые сами исчезают в дальнейшем, если повторять операцию достаточное количество раз. Второй — постоянные, когда никаким образом не заставить стороннюю систему выполнить запрос, сколько бы попыток ни предпринималось. Разделение всех ошибок на эти две группы значительно упрощает их обработку.
Реакция на постоянные ошибки — немедленное прерывание операции. Можно либо уведомить пользователя (если тот сделал или ввел что‑то недопустимое), либо зарегистрировать ее внутри системы и вывести общее сообщение, что произошла непредвиденная ситуация, которая никогда не должна была случиться. Решение о том, как поступить — за вызывающей стороной. Прерывание операции и уведомление — единственное, что разумно сделать в подобных обстоятельствах.
Временная ошибка означает, что попытку следует повторить. При использовании системы выполнения задач, например Celery, воспроизведение действия обычно устанавливается с экспоненциальной задержкой и ограничением на количество попыток. Скажем, можно настроить предпринимать усилие еще пять раз, а в шестой уже рассматривать временную ошибку как постоянную. О таких случаях, скорее всего, стоит уведомлять разработчиков.
Как правило, при интеграции требуется определить только основной тип ошибки — постоянная или временная — и позволить вызывающей стороне решать, что делать с полученным результатом. В некоторых случаях разумно избавить пользователя от созерцания множества временных ошибок, а вместо этого самостоятельно повторять запросы к сторонним системам.
Принцип разбиения ошибок на постоянные и временные использует большинство сетевых систем: протоколы предусматривают различные коды, а клиенты реализуют соответствующую логику.
Например, при отправке электронного письма принимающий SMTP-сервер может вернуть ошибку из диапазона 4xx (временная) или 5xx (постоянная). Примечательно, что в HTTP коды работают наоборот: 4xx зарезервированы для ошибок клиента (постоянные), а 5xx — для ошибок сервера (временные).

Пример интеграции
Рассмотрим пример, основанный на реальном сценарии системы отправки электронных писем. Он похож на тот, что описывался в статье о Interface-mock-live, однако на этот раз сосредоточимся на другом паттерне.
Сначала определим три типа исключений:
class AppError(Exception):
pass
class TemporaryAppError(AppError):
pass
class PermanentAppError(AppError):
pass
Затем внимательно изучим спецификацию API, чтобы понять и возможные неудачи, и как на них реагировать. Ошибки соединения обычно считаются временными, так же как и ошибка 429 (слишком много запросов). Что касается остальных, то 5xx следует обрабатывать как временные (что‑то на удаленной стороне), а 4xx — как постоянные (проблема с запросом).
Результат может выглядеть так:
def send_email(from_: str, to: str, subject: str, text: str) -> None:
try:
response = requests.post(
f"https://api.mailgun.net/v3/{MAILGUN_DOMAIN_NAME}/messages",
auth=("api", MAILGUN_API_KEY),
data={
"from": from_,
"to": to,
"subject": subject,
"text": text,
},
)
except requests.RequestException as error:
raise TemporaryAppError(str(error)) from error
# В оригинале ниже было resp. Исправил на response, как определяется
# выше в блоке try — прим. переводчика:
if response.status_code == 429 or response.status_code >= 500:
raise TemporaryAppError(response.content)
# Аналогичное исправление: response вместо resp — прим. переводчика:
if response.status_code >= 400:
raise PermanentAppError(response.content)
Как всегда, абстрагирование ошибок — хорошая практика. Контракт функции указывает, что генерироваться должны только
PermanentAppError()
или TemporaryAppError()
, а о деталях реализации или других исключениях вызывающей стороне беспокоиться не нужно.Можно пойти дальше и предложить вызывающей стороне задачу, которая автоматически переназначает себя при возникновении временной ошибки. Пример с использованием Celery:
@app.task(autoretry_for=(TemporaryAppError,), retry_backoff=True)
def send_email(from_: str, to: str, subject: str, text: str) -> None:
# Код функции тот же, что и выше
...
В конце концов, код наверняка будет дорабатываться по мере обнаружения новых случаев, специфичных для приложения или сторонней системы, с которой производится интеграция. Например, в какой‑то момент придет понимание, что требуется особый тип ошибки, если исчерпана месячная квота. Тогда в дополнение к обычным действиям при временных ошибках может потребоваться своевременное оповещение администраторов.
Не нужно стесняться добавлять новые типы исключений для обработки каких‑то особых случаев, но… только после того, как станет ясно, что это действительно необходимо.
mastaa_x
Норм база, но все
requests.RequestException
под одну гребенкуTemporaryAppError
— эт не слишком оптимистично? dns-еррор тоже просто ретраим, не глядя?