Точки отказа в интеграциях: таймауты, ретраи и идемпотентность как единая система
Покажем, как спроектировать поведение при сбоях сети и внешних сервисов: где ретраить, где падать, как считать дедлайны и как не дублировать операции. Без теории ради теории — только рабочие схемы.
Содержание
Точки отказа в интеграциях: таймауты, ретраи и идемпотентность как единая система
Интеграции ломаются не из‑за «плохого кода», а из‑за реальности: сеть теряет пакеты, сервисы отвечают с задержками, очереди копятся, а внешние API иногда возвращают «успех», когда операции на самом деле не было (или наоборот). В результате перед разработчиком встаёт практический вопрос: как именно должен вести себя ваш сервис при сбоях — где ретраить, где падать, как считать дедлайны и как не допускать дублей.
В этой статье разберём проектирование поведения при сбоях как единой системы: таймауты → дедлайны → ретраи → идемпотентность. Будем опираться на рабочие схемы и примеры кода. Без теории ради теории и без расплывчатых «надо сделать правильно».
Важно: мы не обсуждаем «как поймать исключение». Мы обсуждаем архитектуру, где сбой становится управляемым событием.
1) Таймаут — не настройка, а контракт
1.1. Почему без таймаутов всё превращается в каскадный отказ
Таймауты нужны не чтобы «быстрее упасть», а чтобы не допустить каскадного отказа:
- ваша нить/воркер блокируется на внешнем вызове,
- пул соединений исчерпывается,
- очередь запросов растёт,
- в итоге ваш сервис перестаёт отвечать даже по своим внутренним операциям.
Поэтому таймаут — это ограничение ресурса (время занятости потока/воркера/коннекта), а не просто «ожидание ответа».
1.2. Три разных таймаута: connect, read и overall
В большинстве HTTP-клиентов таймауты делятся на:
- connect timeout — сколько ждать установления TCP/TLS-сессии;
- read/response timeout — сколько ждать ответа после подключения;
- overall timeout — максимальное время на всю операцию.
Практическая рекомендация:
- connect timeout задайте коротким (например, 1–3 секунды в типичных сетях);
- read/response — дольше (например, 5–20 секунд, в зависимости от SLA партнёра);
- overall — чтобы перехватить «всё вместе» (например, read + небольшие накладные расходы).
1.3. Таймауты не должны «переводить» ретраи в бесконечность
Очень частая ошибка: выставляют ретраи с backoff, но таймаут на запрос тоже высокий. Итог — один входящий запрос занимает ресурс десятки секунд, а при нагрузке сервис деградирует.
Логика должна быть единой:
- есть дедлайн (deadline) всего процесса;
- внутри него несколько попыток;
- каждая попытка ограничена таймаутом;
- backoff суммарно не выходит за дедлайн.
2) Дедлайны как механизм управления временем, а не «время ожидания»
2.1. Дедлайн — сверху вниз
В распределённых системах правильнее мыслить не «сколько ретраить», а «до какого момента в целом». Например:
- входящий HTTP-запрос от клиента имеет TTL;
- затем вы делаете несколько внешних вызовов;
- суммарная операция должна завершиться до дедлайна.
Если вы не передаёте дедлайны внутрь, то ретраи могут жить своей жизнью и обойти все ваши ожидания по latency.
2.2. Пример: дедлайн для всей операции
Допустим, ваш сервис должен обработать запрос клиента не дольше 10 секунд. Тогда вы:
- берёте
deadline = now + 10s, - для каждой попытки вычисляете
remaining = deadline - now, - ставите таймаут конкретного HTTP-вызова равным
min(config.request_timeout, remaining).
Так вы гарантируете, что ретраи не протащат вас за грань ожиданий.
2.3. Пример кода (Python/async) с дедлайном и ретраями
import asyncio
import time
import random
from typing import Callable, Awaitable
class TimeoutBudgetError(Exception):
pass
def add_jitter_sleep(base: float, attempt: int, jitter: float = 0.2) -> float:
# Экспоненциальная пауза с небольшим джиттером
sleep = base * (2 ** attempt)
sleep = sleep * (1 + random.uniform(-jitter, jitter))
return max(0.0, sleep)
async def retry_with_deadline(
*,
fn: Callable[[], Awaitable],
deadline_monotonic: float,
max_attempts: int,
per_attempt_timeout: float,
should_retry: Callable[[Exception], bool],
backoff_base: float = 0.2,
):
last_exc = None
for attempt in range(max_attempts):
now = time.monotonic()
remaining = deadline_monotonic - now
if remaining <= 0:
raise TimeoutBudgetError("Deadline exceeded before next attempt")
timeout = min(per_attempt_timeout, remaining)
try:
return await asyncio.wait_for(fn(), timeout=timeout)
except Exception as exc:
last_exc = exc
if not should_retry(exc) or attempt == max_attempts - 1:
raise
# Если джиттер/пауза съедает весь оставшийся дедлайн — прекращаем
sleep_for = add_jitter_sleep(backoff_base, attempt)
if sleep_for > (deadline_monotonic - time.monotonic()):
raise TimeoutBudgetError("Not enough time for backoff within deadline")
await asyncio.sleep(sleep_for)
raise last_exc
Ключевой момент: дедлайн — общий; каждая попытка и backoff вписаны в него.
3) Где ретраить, а где падать: правила точек отказа
3.1. Типы ошибок: что ретраить, а что нет
В интеграциях разные классы сбоев требуют разных стратегий:
Ретраить чаще всего:
- сетевые ошибки:
connection reset,temporary network failure, DNS временно недоступен; - таймауты (connect/read) — как минимум иногда (важно учесть ретраи в партнёрскую сторону);
- HTTP 502/503/504 от внешнего сервиса (обычно transient).
Почти никогда не ретраить без дополнительной логики:
- HTTP 400 (bad request) и 401/403 (auth/authorization) — это ошибка контракта;
- HTTP 409 (conflict) — иногда можно обработать идемпотентно, но «слепой» ретрай часто только усугубит;
- валидированные ошибки валидации домена — если данные не поменяются, ретраи не помогут.
Особый случай: 429 Too Many Requests
429 — это сигнал “притормози”. Ретрай возможен, но:
- нужно уважать
Retry-After(если есть), - использовать backoff и ограничение частоты,
- понимать, что вы можете усиливать нагрузку на партнёра.
3.2. Единый should_retry
Ниже пример функции, которая принимает исключение/контекст и решает, ретраить или падать.
import aiohttp
def should_retry_http_status(status: int) -> bool:
return status in (502, 503, 504)
def should_retry_exception(exc: Exception) -> bool:
if isinstance(exc, asyncio.TimeoutError):
return True
if isinstance(exc, aiohttp.ClientConnectionError):
return True
if isinstance(exc, aiohttp.ClientResponseError):
return should_retry_http_status(exc.status)
return False
Практика: вы не хотите, чтобы каждое место в коде принимало решение по‑своему. Лучше одна функция, один набор правил, один набор метрик.
3.3. «Где падать» — это тоже архитектурное решение
Есть два уровня отказа:
- внутреннее падение запроса (вернуть ошибку клиенту или отложить в очередь);
- отказ всей интеграции (например, circuit breaker с отключением внешнего вызова на время).
Падать «сразу» иногда лучше:
- если ошибка не transient и ретраи только тратят время;
- если вы упираетесь в дедлайн;
- если повтор создаёт риск дублей (пока нет идемпотентности).
4) Идемпотентность: чтобы ретраи не превращались в дубли
4.1. Почему ретраить без идемпотентности опасно
Классическая последовательность:
- вы отправили запрос в партнёра;
- партнёр обработал, но ваш сервис не получил ответ (таймаут по read);
- вы ретраите запрос;
- партнёр обработал второй раз → два списания, два заказа, два уведомления.
Отсюда главный тезис: идемпотентность — это ответ на “не могу доверять сети”.
4.2. Уровни идемпотентности
Есть несколько вариантов, иногда смешиваемых:
1) Идемпотентность на стороне партнёра (предпочтительно)
Если API поддерживает заголовок/параметр идемпотентности (например, Idempotency-Key), используйте его:
- каждый логический запрос имеет уникальный ключ;
- партнёр либо возвращает тот же результат, либо отклоняет дубль.
2) Идемпотентность на вашей стороне
Если партнёр не поддерживает идемпотентность, вы можете:
- сохранять “что отправляли” в своей БД;
- перед повторной отправкой проверять состояние операции по ключу;
- использовать outbox/saga/транзакционный лог.
3) Компенсации (saga/rollback)
Если операция необратима, иногда лучше проектировать компенсацию (например, «отменить списание»), но это сложнее и не всегда доступно.
5) Схема «событие → ключ → попытки → статус»: как собрать систему
Ниже рабочая схема, которая объединяет дедлайны, ретраи и идемпотентность. Мы рассмотрим вариант, когда идемпотентность делается на стороне вашего сервиса (из-за отсутствия гарантии партнёра).
5.1. Модель данных: таблица отправок (integration log)
Создайте таблицу, где фиксируется логическая операция:
operation_id(ваш ключ, UUID/ULID)partner/endpointpayload_hash(опционально)status(например:pending,sent,confirmed,failed)attemptslast_erroridempotency_key(если партнёр принимает ключ)created_at,updated_at
5.2. Ключевой принцип: “одна логическая операция — один ключ”
operation_id генерируется один раз на вход запроса клиента и проходит через все попытки. Если вы делаете ретраи из разных воркеров/процессов — ключ должен быть устойчивым.
5.3. Поток обработки
- В API-ручке создаёте запись
operation_idв БД со статусомpending. - Запускаете отправку (сразу или через очередь).
- В момент отправки формируете
Idempotency-Key = operation_id(или производный ключ). - Делаете вызов с таймаутом и дедлайном.
- При успешном ответе:
- помечаете
confirmed.
- помечаете
- При transient-ошибках:
- увеличиваете
attempts, - планируете следующую попытку, пока дедлайн не истёк.
- увеличиваете
- При non-retryable:
- помечаете
failedи возвращаете ошибку (или инициируете компенсацию).
- помечаете
5.4. Пример кода: отправка с сохранением статуса
Псевдо-реализация (без конкретной ORM):
import uuid
import asyncio
import time
# statuses: pending, confirmed, failed
# in real life: use database transactions and row-level locks if needed
async def process_integration_operation(db, http_client, request_payload, deadline_seconds: float):
operation_id = str(uuid.uuid4())
created_at = time.time()
db.insert_operation(
operation_id=operation_id,
partner="partnerA",
status="pending",
attempts=0,
idempotency_key=operation_id,
payload_hash=None,
created_at=created_at,
)
deadline = time.monotonic() + deadline_seconds
async def attempt():
op = db.get_operation(operation_id)
if op["status"] == "confirmed":
return # already done
# Важно: дедлайн + таймаут вписаны в overall deadline
# Таймаут на попытку меньше общего дедлайна.
timeout_for_attempt = 5.0
headers = {
"Idempotency-Key": op["idempotency_key"]
}
resp = await http_client.post(
"/orders",
json=request_payload,
headers=headers,
timeout=timeout_for_attempt, # per-attempt
)
resp.raise_for_status()
return resp.json()
async def should_retry(exc: Exception) -> bool:
return should_retry_exception(exc)
try:
result = await retry_with_deadline(
fn=attempt,
deadline_monotonic=deadline,
max_attempts=6,
per_attempt_timeout=5.0,
should_retry=should_retry,
backoff_base=0.2,
)
db.update_operation(operation_id, status="confirmed", last_error=None)
return result
except Exception as exc:
db.update_operation(
operation_id,
status="failed",
last_error=str(exc),
)
raise
Заметьте: мы делаем ключидейный retry, но при этом статус операции хранится. Даже если один воркер умер и другой продолжил — операция не должна повторять необратимые шаги.
6) Circuit Breaker и «отключение по симптомам»: когда ретраи вредят
6.1. Симптом: партнёр системно не доступен
Если внешний сервис стабильно возвращает 503/504 или таймаутится, ретраи превращаются в «усилитель» нагрузки. Вместо этого:
- вводят circuit breaker: пока есть признаки деградации — не отправлять запросы, отвечать быстрее ошибкой или ставить в очередь.
6.2. Как выбрать пороги
Типовые сигналы:
- доля ошибок за окно (например, >50% за 30–60 секунд),
- количество таймаутов,
- отсутствие успешных ответов.
Режимы:
- closed — нормальная работа;
- open — сразу отказывать или откладывать;
- half-open — пробовать восстановление ограниченным количеством запросов.
Ключевой принцип: circuit breaker должен быть единым для конкретного партнёра/эндпоинта, а не “на каждый запрос”.
7) Очереди и outbox: как выжить под нагрузкой и не нарушить дедлайн
7.1. Два типа интеграций
Различайте:
- синхронные (нужно вернуть результат клиенту прямо сейчас),
- асинхронные (можно подтвердить позже).
Если операция критична и должна быть доставлена надёжно, часто лучше:
- принять запрос от клиента,
- сохранить намерение в БД,
- отправить в очередь worker’у,
- клиенту вернуть
202 Accepted/статус ожидания.
Так вы не пытаетесь “удержать” внешнюю интеграцию в рамках latency веб‑запроса.
7.2. Outbox Pattern
Транзакционно фиксируете запись намерения + публикуете событие в очередь через outbox, чтобы событие не потерялось при падении процесса. Дальше worker отправляет во внешний сервис с ретраями и идемпотентностью.
8) Метрики и наблюдаемость: чтобы точки отказа были измеримыми
Без метрик вы не сможете отличить:
- “партнёр временно тормозит” от
- “у нас сломалась DNS/сертификаты” от
- “мы делаем слишком много попыток”.
Минимальный набор:
- latency внешнего вызова: p50/p95/p99,
- количество попыток на операцию,
- доля ретраев по причинам (timeout, 502/503/504, network),
- итоговая доля confirmed/failed,
- размер очередей (если есть асинхронка),
- состояние circuit breaker.
Отдельно: логируйте operation_id во всех попытках. Это ускоряет расследования в разы.
9) Типичные ошибки (и что с ними делать)
9.1. Одна ошибка — разные таймауты в разных местах
Если в одном месте connect timeout 1 сек, а в другом 10, вы получаете хаотичное поведение. Приведите параметры к конфигу на уровне клиента/партнёра.
9.2. Ретраить на 400/401 как будто это временно
Это не transient. Ретраи только сжигают ресурсы и создают “шум”, маскируя настоящие проблемы.
9.3. Идемпотентность “на словах”
Например, вы генерируете идемпотентный ключ каждый ретрай по‑новому. Тогда повтор будет считаться новым запросом. Ключ должен быть стабилен на логическую операцию.
9.4. Не учитывать дубль на уровне побочных эффектов
Даже если внешнее API идемпотентно, вы можете:
- отправить email дважды,
- начислить внутренний бонус дважды,
- записать событие в Kafka дважды.
Идемпотентность должна охватывать весь контур побочных эффектов, а не только HTTP-запрос.
Комментарии
Пока нет комментариев