Интеграции и API

Почему timeout внешнего API не означает, что операция не выполнилась

Интеграция отправляет запрос во внешний сервис:

POST /refunds

Через десять секунд HTTP-клиент возвращает:

ETIMEDOUT

На первый взгляд вывод кажется очевидным:

Запрос не выполнился. Нужно отправить его ещё раз.

Именно в этот момент во многих системах появляется двойной возврат, второй заказ, повторная выплата или два одинаковых документа.

Потому что timeout сообщает совсем не то, что кажется.

Он сообщает:

«Мы не получили подтверждение результата за отведённое время».

Но совершенно не обязательно:

«Внешняя система не выполнила операцию».

Между этими двумя утверждениями огромная архитектурная разница.

Представим реальный сценарий:

Наш сервис
    │
    │ POST /refund
    ▼
Payment API
    │
    ├── проверил запрос
    ├── создал refund
    ├── записал его в БД
    └── начал отправлять response
             │
             X
        connection lost

Для внешнего API:

refund = SUCCESS

Для нашего приложения:

timeout

Обе системы при этом говорят правду.

Проблема возникает, когда наше приложение интерпретирует:

timeout

как:

FAILED

и автоматически делает:

POST /refund

ещё раз.

Поэтому для надёжных интеграций нам часто недостаточно состояний:

SUCCESS
FAILED

Нужно третье:

UNKNOWN

или:

PENDING_CONFIRMATION

То есть:

Мы пока не знаем, произошёл ли внешний бизнес-эффект.

Именно с этого начинается корректная обработка сетевых timeout.


Где вообще может произойти timeout

Слово timeout выглядит как одна ошибка.

Но технически ситуация может возникнуть на очень разных этапах.

Рассмотрим обычный запрос:

Client
   ↓
DNS
   ↓
TCP/TLS
   ↓
Request
   ↓
Server processing
   ↓
Response

С точки зрения клиента разные сбои могут закончиться похожей ошибкой:

timeout

Хотя состояние сервера будет совершенно разным.


Сценарий 1. Запрос вообще не дошёл до сервера

Например:

Client
   │
   X
Network failure

Сервер ничего не получил.

Никакой операции не произошло.

Повтор запроса может быть безопасен.

Но клиент не всегда способен доказать именно этот сценарий.


Сценарий 2. Сервер получил только часть запроса

Соединение установилось, но оборвалось во время передачи.

Сервер мог:

отклонить запрос;
не начать обработку;
частично прочитать body.

Результат зависит от протокола, сервера и конкретной реализации.

Для клиента это всё ещё может выглядеть как отсутствие нормального response.


Сценарий 3. Запрос получен, операция выполняется

Например:

12:00:00
POST /refund

12:00:01
provider начал refund

12:00:10
наш HTTP timeout

Но provider продолжает работу.

Через:

12:00:12

refund успешно завершается.

Наш клиент уже получил exception.


Сценарий 4. Операция уже выполнена, но response потерялся

Это самый опасный случай.

POST /refund
      ↓
Refund created
      ↓
COMMIT
      ↓
200 response
      ↓
network failure

С точки зрения provider:

SUCCESS

С точки зрения клиента:

timeout

И никакой дополнительный анализ самого exception не способен превратить его в доказательство:

операция не произошла.

Поэтому timeout — это не бизнес-результат

В хорошем интеграционном слое мы различаем:

Transport result

и:

Business result.

Например:

Transport:
TIMEOUT

Business:
UNKNOWN

Это гораздо точнее:

Business:
FAILED

HTTP тоже проводит это различие

Текущая спецификация HTTP определяет методы вроде PUT, DELETE и безопасные методы как идемпотентные именно потому, что клиент может повторить их после коммуникационного сбоя, даже если первый запрос на самом деле успел выполниться. При этом автоматически повторять неидемпотентный запрос нельзя без дополнительных гарантий, что повтор не создаст второй эффект.

Это важная идея:

проблема retry существует именно потому, что после потери ответа клиент может не знать результат первого вызова.


Особенно опасен обычный POST

Представим:

POST /orders

body:

{
  "productId": 4812,
  "quantity": 1
}

Первый запрос дошёл.

Сервер создал:

Order #9001

Response потерялся.

Клиент делает retry:

POST /orders

Сервер воспринимает это как новую команду.

Создаёт:

Order #9002

Теперь один пользовательский клик создал два заказа.


Сам HTTP-метод не знает вашего бизнес-смысла

Для сервера два:

POST /orders

могут вполне законно означать:

Создать два разных заказа.

Поэтому система должна передать дополнительный смысл:

Это не новая операция. Это повтор предыдущей попытки.

Для этого используется idempotency key.


Что такое idempotency key

Представим пользователь инициировал одну логическую операцию:

Создать заказ

Мы создаём стабильный идентификатор:

order-create:customer-52:checkout-7d71...

или UUID, сохранённый вместе с локальной операцией.

Первый запрос:

POST /orders
Idempotency-Key: 7d71...

Если response потерялся, второй запрос отправляет тот же ключ:

POST /orders
Idempotency-Key: 7d71...

Теперь provider способен понять:

Этот request относится к уже известной логической операции.

И вместо создания второго объекта вернуть результат первого.


Именно так работают зрелые API

Stripe прямо рекомендует использовать idempotency key при создании или изменении объектов: после connection error клиент может повторить запрос с тем же ключом без создания второй операции. Stripe также проверяет параметры повторного запроса, чтобы один ключ случайно не использовали для другой операции.

PayPal использует похожую модель через PayPal-Request-Id: документация прямо разрешает с тем же идентификатором повторять запросы после network timeout и даже описывает случай, когда первоначальный refund фактически произошёл, хотя клиент получил ошибку.

То есть проблема не теоретическая.

Она настолько типична для распределённых систем, что крупные API делают idempotency частью публичного контракта.


Ключ должен принадлежать операции, а не попытке

Это критическая деталь.

Плохо:

attempt #1
Idempotency-Key = UUID-A

attempt #2
Idempotency-Key = UUID-B

Для provider это две разные операции.

Мы полностью потеряли смысл idempotency.

Правильно:

Logical operation:
refund payment #813

Idempotency-Key:
refund:813:full

Все retries используют:

refund:813:full

до тех пор, пока речь идёт о той же самой операции.


Новое намерение — новый ключ

Допустим первый refund:

1000 ₽

Через день пользователь отдельно хочет вернуть:

500 ₽

Это уже другая бизнес-операция.

Нельзя использовать старый idempotency key:

refund:813:full

Нужен новый.

Поэтому ключ должен кодировать или однозначно идентифицировать business intent.


Хорошая idempotency-модель проверяет ещё и payload

Представим первый запрос:

{
  "paymentId": 813,
  "amount": 1000
}

с ключом:

refund-operation-77

А второй:

{
  "paymentId": 813,
  "amount": 500
}

с тем же ключом.

Что делать?

Нельзя молча считать это retry.

Намерение изменилось.


Полезно сохранять request fingerprint

Например:

idempotency_key
request_hash
status
result

При повторе:

key тот же
+
hash тот же
→ legitimate retry

А:

key тот же
+
hash другой
→ conflict

Так случайная ошибка клиента не превращается в непредсказуемое изменение уже выполненной операции.


Но что делать, если внешний API вообще не поддерживает idempotency

Это значительно сложнее.

Представим partner API:

POST /create-invoice

не принимает:

Idempotency-Key

и не позволяет указать уникальный внешний ID.

Первый request timeout.

Теперь повтор:

может создать второй invoice.

Автоматический retry уже нельзя считать безопасным.


Первый вариант — использовать внешний business reference

Допустим API принимает:

{
  "invoiceNumber": "WEBRUTA-1842"
}

а provider гарантирует уникальность номера.

Теперь повторный request с:

WEBRUTA-1842

может вернуть:

duplicate invoice number

Вместо создания второй сущности.

Это тоже форма idempotency, хотя provider может так её не называть.


Второй вариант — перед retry выполнить reconciliation

После timeout не отправляем запрос снова сразу.

Сначала спрашиваем:

GET /invoices?externalReference=WEBRUTA-1842

Если invoice найден:

операция фактически произошла

и локальная система обновляет статус.

Если не найден:

возможно, можно повторить создание.

Но даже reconciliation может иметь race condition

Представим provider ещё обрабатывает первую операцию:

12:00:10
наш timeout

12:00:11
GET invoice
→ not found

12:00:12
retry POST

12:00:13
первая операция завершается

12:00:14
вторая операция завершается

Получили дубль.

Поэтому reconciliation намного надёжнее, если внешний сервис предоставляет:

уникальный client reference;
идемпотентный create;
status endpoint по request ID.

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


В таких случаях UNKNOWN — настоящее состояние

Очень соблазнительно оставить только:

PENDING
SUCCESS
FAILED

Но timeout после потенциально необратимой операции нельзя честно поместить ни в:

SUCCESS

ни в:

FAILED.

Мы просто не знаем.


Поэтому state machine может выглядеть так

CREATED
   ↓
SENDING
   ↓
┌───────────────┬───────────────┐
│               │               │
▼               ▼               ▼
SUCCEEDED     FAILED          UNKNOWN
                                │
                                ▼
                         RECONCILIATION
                           /          \
                          /            \
                         ▼              ▼
                    SUCCEEDED        RETRY

Это немного сложнее.

Но зато модель соответствует реальности.


UNKNOWN особенно важен для денег и других необратимых действий

Например:

capture payment;
refund;
payout;
issue fiscal receipt;
create shipment;
activate external subscription.

Слепой retry может стоить денег.

Вместо него правильнее:

timeout
↓
PENDING_CONFIRMATION
↓
проверить provider
↓
только после этого решить,
нужен ли retry.

Пользователю тоже не всегда нужно показывать «Ошибка»

Представим покупатель нажал:

Оплатить.

После 10 секунд provider request timeout.

Если интерфейс покажет:

Оплата не выполнена.
Попробуйте снова.

пользователь нажмёт кнопку второй раз.

Хотя первая оплата могла уже пройти.


Более честный UX

Например:

Проверяем статус платежа…

или:

Платёж обрабатывается.
Не повторяйте оплату —
мы автоматически проверим результат.

Система тем временем выполняет reconciliation.

Это не просто хороший интерфейс.

Это защита от дублей на уровне пользовательского поведения.


Локальная БД должна помнить операцию до внешнего запроса

Плохая архитектура:

HTTP handler
↓
POST provider
↓
provider timeout
↓
ничего не записали

Теперь система даже не имеет локального идентификатора операции, которую нужно расследовать.


Лучше сначала создать локальный attempt

Например:

payment_attempt

id = 991
order_id = 1842
status = PENDING
idempotency_key = payment:1842:attempt:1

Только после commit:

Worker
↓
external provider

Теперь даже после crash или timeout у нас остаётся запись:

Что пытались сделать?
Когда?
С каким ключом?
Сколько раз?
Какой был последний результат?

Это делает интеграцию наблюдаемой

Можно увидеть:

Operation:
REFUND

Local ID:
991

Provider:
ExamplePay

Idempotency key:
refund:813:full

Status:
PENDING_CONFIRMATION

Attempts:
2

Last transport error:
ETIMEDOUT

Support уже понимает ситуацию.

Вместо:

Клиент говорит, деньги списались, а у нас ошибка.

Request ID provider тоже нужно сохранять

Если provider вернул:

Request-Id

или собственный transaction ID, его полезно сохранить.

Stripe, например, выдаёт уникальный request identifier для API-запросов, который можно использовать при диагностике конкретного вызова.

В production это значительно упрощает расследование:

наш operation_id
↕
наш idempotency_key
↕
provider request_id
↕
provider transaction_id

Таймауты должны быть осознанными

Ещё одна распространённая ошибка:

timeout = 30 sec

потому что:

Так было в библиотеке.

На самом деле полезно различать несколько timeout.

Например:

connect timeout

Сколько ждём установления соединения.

request/read timeout

Сколько ждём response.

business deadline

Сколько вообще готовы ждать завершения операции.

Это не всегда одно и то же.


Слишком большой timeout тоже вреден

Представим API обычно отвечает:

p99 = 800 ms

а клиент ждёт:

60 sec.

Если provider начинает зависать, workers быстро накапливают:

сотни висящих requests;

занимая:

connections;
memory;
worker slots.

Timeout нужен не только пользователю.

Он ограничивает потребление ресурсов при деградации зависимости.


Слишком маленький timeout может породить ложную неопределённость

Provider стабильно завершает тяжёлую операцию:

за 6–8 секунд.

Мы установили:

timeout = 2 sec.

Теперь почти каждый успешный вызов выглядит клиенту как failure.

Дальше начинаются:

retry;
reconciliation;
duplicate protection.

которые сами создают нагрузку.

Timeout должен соответствовать реальной latency операции и SLA.


Retry не должен начинаться мгновенно

После timeout внешний сервис может всё ещё обрабатывать первую попытку.

Если тут же отправить вторую:

Request #1 still processing
+
Request #2 starts

даже хороший provider может получить две конкурентные операции, если idempotency не предусмотрена.


Нужен backoff

Например:

attempt 1
↓
timeout
↓
reconciliation / wait
↓
30 sec
↓
attempt 2

Следующая попытка:

2 min

затем:

10 min

в зависимости от бизнес-операции.


И jitter

Если внешний provider упал и:

20 000 jobs

получили timeout одновременно, одинаковый retry через:

60 sec

создаст новый пик точно через минуту.

Лучше распределить повторные попытки.


Но retry policy начинается не с backoff

Она начинается с ответа на вопрос:

Безопасно ли вообще повторять эту операцию?

Есть большая разница между:

GET /customer/52

и:

POST /payout

GET обычно можно повторить

Получить данные ещё раз, как правило, безопасно.

Если response потерялся:

retry GET.

PUT часто проектируется естественно идемпотентно

Например:

PUT /profile/52
{
  "timezone": "Europe/Berlin"
}

Повторное применение того же состояния не должно создавать второй профиль.

HTTP именно поэтому относит PUT к идемпотентным методам по семантике.


Но DELETE тоже требует понимания конкретного API

По HTTP-семантике DELETE идемпотентен в отношении запрошенного эффекта.

Но response на второй запрос может отличаться:

первый → 204
второй → 404

Это нормально.

Идемпотентность означает не:

Каждый ответ одинаков.

А:

Повтор не создаёт дополнительного требуемого эффекта.

POST нужно рассматривать особенно внимательно

POST часто означает:

создай новую сущность;
запусти действие;
спиши деньги.

То есть повтор потенциально создаёт новый эффект.

Автоматический retry допустим, только если у API есть отдельный idempotency contract или мы сами можем доказать безопасность повторения.


Даже HTTP 500 не всегда означает «ничего не произошло»

Это ещё одна важная ошибка мышления.

Разработчик получает:

500 Internal Server Error

и думает:

Сервер упал до выполнения операции.

Не обязательно.

Внутри provider может произойти:

business change
↓
COMMIT
↓
ошибка формирования response
↓
500

PayPal прямо документирует сценарий, когда запрос вернул 500, хотя refund уже был выполнен; повтор с тем же PayPal-Request-Id позволяет не создать второй возврат.

Поэтому для mutating API даже:

5xx

иногда нужно воспринимать как:

result uncertain

до получения дополнительных гарантий конкретного provider.


А вот validation error обычно намного определённее

Например:

400 Bad Request

provider явно сообщает:

amount must be positive

Операция не должна была начаться.

Здесь повторить тот же запрос бессмысленно.

Нужно исправлять payload.


Но нельзя строить универсальную логику только по status code

Разные provider имеют разные контракты.

Для одного:

409

означает:

duplicate operation

для другого:

resource conflict.

Для одного 500 безопасно retry с idempotency key.

Для другого документация может требовать сначала проверить статус.

Поэтому integration adapter должен знать семантику конкретного API.


Именно adapter должен переводить внешний мир в наши состояния

Например provider отвечает:

network timeout

Adapter возвращает:

UNKNOWN

Provider:

400 invalid amount

Adapter:

PERMANENT_FAILURE

Provider:

429

Adapter:

RETRY_LATER

Provider:

200

Adapter:

SUCCESS

Business layer не должен разбираться в случайных provider-specific кодах повсюду.


Хорошая интеграция имеет собственную state machine

Например:

CREATED
↓
REQUESTED
↓
┌────────────┬────────────┬────────────────────┐
│            │            │                    │
▼            ▼            ▼                    ▼
SUCCESS   REJECTED   RETRYABLE_ERROR     UNKNOWN
                                             │
                                             ▼
                                      RECONCILING
                                         /       \
                                        /         \
                                       ▼           ▼
                                  SUCCESS       RETRY

Здесь:

REJECTED

означает:

Мы знаем, что операция не выполнена.

А:

UNKNOWN

означает:

Мы этого не знаем.

Эту разницу нельзя терять.


Reconciliation — обязательная часть серьёзной интеграции

Допустим payment capture timeout.

Background job после этого может:

подождать;

затем:

GET payment state

по provider ID или business reference.

Если:

CAPTURED

локально ставим:

SUCCESS.

Если:

NOT_CAPTURED

и provider гарантирует, что предыдущая операция закончена:

можно retry.

Если состояние всё ещё:

PROCESSING

продолжаем ждать.


Webhook может стать вторым каналом подтверждения

Например наш synchronous call timeout.

Но через две секунды provider отправляет:

payment.succeeded

webhook.

Теперь локальная запись:

PENDING_CONFIRMATION

может перейти:

SUCCEEDED.

Поэтому response и webhook не должны конкурировать

Они являются двумя источниками информации об одном внешнем бизнес-факте.

Можно построить state transition так, чтобы:

HTTP success

и:

webhook success

оба приводили одну локальную операцию к:

SUCCEEDED

без создания второго эффекта.


Webhook тоже может прийти повторно

Поэтому переход снова должен быть идемпотентным.

Например:

UPDATE payment_attempts
SET status = 'SUCCEEDED'
WHERE id = $1
  AND status <> 'SUCCEEDED';

или более строгая state machine.


Не нужно создавать новый локальный платёж из webhook каждый раз

Иначе:

HTTP response
→ Payment #1

и:

Webhook
→ Payment #2

Одна внешняя операция становится двумя локальными.


Нужен provider operation ID

Например:

provider_payment_id

с:

UNIQUE

constraint.

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


Database constraints — важная последняя защита

Допустим webhook и reconciliation происходят одновременно.

Application-level проверки могут столкнуться в race:

Process A:
не найдено

Process B:
не найдено

оба создают запись.

Если есть:

UNIQUE(provider, provider_operation_id)

один победит.

Другой увидит conflict и сможет прочитать существующий результат.


Это особенно важно для заказов

Например idempotency key:

checkout-7d71

может иметь:

UNIQUE(customer_id, checkout_id)

Теперь повтор запроса на создание заказа:

не создаёт второй order.

Idempotency должна начинаться на вашей стороне

Даже если внешний provider прекрасно поддерживает idempotency, пользователь может дважды отправить запрос в ваше API.

Например:

двойной клик;
browser retry;
mobile reconnect;
service worker retry.

Если каждый вызов backend создаёт новый:

provider idempotency key

внешний provider честно создаст две операции.


Значит нужен стабильный client intent ID

Например frontend создаёт:

checkout_id

один раз.

Все retries:

POST /checkout
checkout_id = 7d71

используют тот же идентификатор.

Backend:

find existing operation

или:

create once.

А уже её idempotency key используется при вызове provider.


Получается цепочка идемпотентности

User action
    ↓
Client operation ID
    ↓
Backend operation
    ↓
Provider idempotency key
    ↓
External operation

Одна логическая команда сохраняет идентичность через все слои.

Это намного надёжнее, чем пытаться бороться с дублями только в самом конце.


Что сохранять в базе

Например интеграционная операция может иметь поля:

id
operation_type
business_entity_id
idempotency_key
request_hash

provider
provider_operation_id
provider_request_id

status
attempts

last_transport_error
last_http_status
last_provider_error

created_at
last_attempt_at
confirmed_at

Если был timeout:

status =
PENDING_CONFIRMATION

а не:

FAILED.

Логи должны использовать тот же correlation ID

Например:

operation_id = op_991

проходит через:

HTTP request
background job
provider adapter
webhook
reconciliation

Тогда расследование выглядит:

op_991

вместо попытки вручную сопоставить пять timestamps.


Очень полезно сохранять попытки отдельно

Например таблица:

integration_attempts

содержит:

attempt 1
12:00:00
timeout

attempt 2
12:01:00
provider duplicate/result returned

А сама операция:

SUCCEEDED.

Теперь мы различаем:

результат бизнес-операции

и:

историю транспортных попыток.

Это фундаментально разные вещи.


Ошибка попытки не означает ошибку операции

Именно этот принцип часто теряется.

Например:

Attempt #1:
FAILED due timeout

Operation:
SUCCESS

Это совершенно корректное состояние.


Поэтому метрики тоже должны быть разделены

Можно иметь:

API request error rate:
3%

и одновременно:

business operation success rate:
99.99%

Потому что часть transport errors восстановилась через retry/reconciliation.

Если считать каждую сетевую ошибку бизнес-провалом, monitoring создаёт неверную картину продукта.


Но timeout rate всё равно важен

Резкий рост:

0.2%
→
8%

может означать проблемы:

сеть;
provider;
DNS;
connection pool;
наш timeout threshold.

Даже если idempotency спасает от дублей, деградацию нужно расследовать.


Нужно измерять unknown duration

Очень полезная метрика:

time in PENDING_CONFIRMATION

Если обычно:

5 sec

а сегодня:

20 min,

reconciliation или provider работает плохо.


Автоматический retry должен иметь лимит

Даже idempotent operation нельзя отправлять бесконечно.

Если provider недоступен два часа, бесконечные повторы создадут:

нагрузку;
логи;
очередь;
rate limiting.

Нужны:

max attempts;
backoff;
deadline.

После чего:

MANUAL_REVIEW

или terminal state.


Не все операции должны становиться FAILED после лимита

Если мы всё ещё не знаем результат финансовой операции:

FAILED

может быть ложью.

Правильнее:

UNKNOWN_REQUIRES_REVIEW

до ручной или автоматической сверки.


Это особенно важно в бухгалтерии и платежах

Фраза:

У нас payment FAILED.

должна означать:

Мы уверены, что деньги не были успешно списаны.

Если на самом деле:

Мы просто не получили response,

состояние названо неправильно.


Что делать, если provider не даёт никакого способа проверить результат

Это один из самых неприятных классов интеграций.

Нет:

idempotency;
external reference;
status lookup;
webhook;
transaction search.

Есть только:

POST /do-something

и timeout.


Здесь нужно честно признать ограничение

Невозможно математически определить:

операция произошла

или:

операция не произошла

если удалённая система не оставляет доступного наблюдаемого идентификатора результата.


Значит автоматический retry может быть запрещён

Например:

timeout
↓
UNKNOWN
↓
manual verification

Это менее красиво.

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


Иногда можно изменить интеграционный контракт

Если это ваш собственный API или партнёр готов доработать интеграцию, полезно добавить:

client_request_id;
external_reference;
status endpoint;
idempotent create.

Одна такая возможность может радикально повысить надёжность всей интеграции.


Как проектировать собственный API

Представим WebRuta предоставляет:

POST /api/projects

клиентам.

Хороший API может принимать:

Idempotency-Key: ...

Сервер хранит:

key;
request hash;
status;
response.

Первый request

key not found
↓
create PROCESSING record
↓
execute business transaction
↓
store result
↓
return response

Повторный request

Если:

same key
same payload
COMPLETED

возвращаем сохранённый результат.


Если операция ещё выполняется

same key
status = PROCESSING

можно вернуть:

409 / 202

или другой задокументированный статус, не запуская вторую execution.


Если key тот же, а payload другой

Возвращаем:

IDEMPOTENCY_CONFLICT.

Не угадываем намерение клиента.


Важно создавать idempotency record атомарно

Если два одинаковых запроса приходят одновременно:

Request A
Request B

оба не должны увидеть:

key absent

и оба запустить бизнес-операцию.

Помогает:

UNIQUE(idempotency_key)

и корректная transaction.


Idempotency record тоже имеет lifecycle

Например:

PROCESSING
COMPLETED
FAILED_VALIDATION

А если process погиб после захвата key, но до завершения?

Нужно определить recovery.

Иначе ключ навсегда останется:

PROCESSING.

Это очень похоже на background jobs

И это не случайность.

И background jobs, и idempotency, и внешний API решают одну фундаментальную задачу распределённых систем:

Что делать, если мы потеряли подтверждение между двумя независимыми состояниями?

Плохой паттерн: retry в нескольких слоях одновременно

Представим:

HTTP library:
3 retries

Service layer:
3 retries

Background job:
5 retries

В худшем случае одна операция может породить:

3 × 3 × 5 = 45

попыток.

Если provider уже перегружен, мы усиливаем сбой.


Retry должен иметь владельца

Например:

HTTP client
→ один request, без скрытых mutating retries

Integration job
→ управляет retry/backoff/reconciliation

Так поведение предсказуемо.


Особенно опасны автоматические SDK retries, о которых команда не знает

Некоторые библиотеки умеют повторять запросы самостоятельно.

Если поверх этого background job также делает retry, фактическое число вызовов может значительно отличаться от ожидаемого.

Поэтому retry policy внешнего SDK должна быть частью architecture review.


Время request timeout и время business timeout — разные вещи

Например refund API request timeout:

10 sec.

Но сама операция может оставаться:

PENDING

у provider:

5 min.

Нельзя через десять секунд считать:

refund failed.

Request завершился неопределённо.

Business operation ещё может жить.


Поэтому provider state важнее connection state

Мы хотим узнать:

refund.status

а не:

последний HTTP request завершился успешно?

Это разные вопросы.


Reconciliation может быть фоновой job

Например после timeout создаётся:

CHECK_PAYMENT_STATUS

с:

available_at = now + 10 sec.

Она проверяет provider.

Если:

SUCCESS

завершает локальную operation.

Если:

PROCESSING

планирует следующую проверку.

Если provider подтвердил failure:

FAILED.

Это разгружает пользовательский request

Пользователь получает:

Статус уточняется.

а не ждёт минуту в открытом HTTP-соединении.


Но reconciliation тоже должен закончиться

Нельзя проверять:

каждые 10 секунд
вечно.

Нужны:

deadline;
backoff;
manual escalation.

Например:

0–1 min:
часто

1–10 min:
реже

после 30 min:
manual review

Webhook способен завершить reconciliation раньше

Если provider сам прислал:

payment.succeeded

scheduled status check становится больше не нужен.

Он может при следующем запуске увидеть:

already SUCCEEDED

и завершиться no-op.

Снова помогает idempotency.


Именно поэтому интеграционная state machine должна быть общей

HTTP callback, webhook и reconciliation не должны создавать три независимых истины.

Они должны обновлять одну локальную operation.


Пример полного сценария оплаты

Пользователь нажимает:

Оплатить

Backend создаёт:

PaymentAttempt #991
status = CREATED
idempotency_key = pay:order:1842:v1

Background worker отправляет:

POST provider

с тем же ключом.


Provider принимает payment.

Наш connection timeout происходит после commit.

Локально:

PaymentAttempt #991
status = PENDING_CONFIRMATION

Через пять секунд reconciliation:

GET provider payment

получает:

SUCCEEDED.

Локально:

PaymentAttempt #991
→ SUCCEEDED

Order:

WAITING_PAYMENT
→ PAID

Webhook приходит ещё через секунду.

Находит:

provider_payment_id уже обработан.

Ничего повторно не делает.


А теперь неправильный вариант

POST payment
↓
timeout
↓
status = FAILED
↓
пользователь снова нажимает «Оплатить»
↓
new payment

Первая операция тоже успела выполниться.

Получаем две оплаты.

Разница между этими архитектурами — всего одна мысль:

timeout не был принят за доказательство failure.


Тестировать нужно именно неопределённые сценарии

Happy path:

request
↓
200

проверяет слишком мало.

Настоящий integration test должен уметь искусственно создать:

provider выполняет операцию
↓
response теряется.

Тест №1. Request вообще не дошёл

Ожидаем корректный retry.


Тест №2. Provider выполнил операцию, response потерялся

Ожидаем:

не создаётся второй effect;

система либо повторяет с тем же idempotency key, либо выполняет reconciliation.


Тест №3. Provider долго PROCESSING

Ожидаем:

PENDING_CONFIRMATION

без ложного failure.


Тест №4. Webhook приходит раньше HTTP-response

Такое тоже возможно.

Локальная state machine должна корректно принять событие.


Тест №5. HTTP success и webhook приходят одновременно

Database constraint/state transition должны не позволить создать два локальных объекта.


Тест №6. Retry с тем же key, но другим payload

Ожидаем:

conflict.

Тест №7. Worker погиб после provider success

После восстановления job не должна создавать второй внешний effect.


Тест №8. Provider timeout длится час

Ожидаем:

backoff;
ограниченное число запросов;
alert;

а не request storm.


Очень полезен fault injection

В test adapter можно специально смоделировать:

execute provider operation
↓
drop response

Это намного ценнее простого:

throw new TimeoutError()

до выполнения операции.

Потому что самый опасный timeout происходит после внешнего side effect.


Логи тоже должны различать outcome

Плохо:

ERROR payment failed: timeout

Мы не знаем, что именно failed.

Лучше:

WARN provider request timed out

operation_id=991
business_state=PENDING_CONFIRMATION
transport_state=TIMEOUT

Теперь лог не врёт.


То же самое в админ-панели

Не:

Платёж: ошибка

а:

Платёж:
статус уточняется

Последняя попытка:
timeout

Проверка provider:
запланирована

Для поддержки это огромная разница.


Нельзя путать техническую ошибку и бизнес-отказ

Например карта отклонена:

business failure.

Provider ясно сказал:

DECLINED.

Повторить тот же payment автоматически обычно не нужно.


Timeout:

technical uncertainty.

Provider не сказал:

DECLINED.

Мы просто не получили результат.

Эти события должны проходить через разные ветви state machine.


Практический checklist интеграции с внешним API

Перед production мы бы проверили:

  • определено, какие операции являются mutating;
  • для каждого mutating вызова известно, безопасен ли retry;
  • timeout не преобразуется автоматически в business FAILED;
  • существует состояние UNKNOWN/PENDING_CONFIRMATION там, где результат может быть неопределён;
  • provider idempotency key используется, если API его поддерживает;
  • один logical operation использует один и тот же key во всех retry;
  • изменение payload под тем же key запрещено;
  • локальный operation record создаётся до внешнего вызова;
  • сохраняются provider operation ID и request ID, если доступны;
  • duplicate provider ID защищён database constraint;
  • HTTP-response, webhook и reconciliation обновляют одну локальную state machine;
  • webhook обработка идемпотентна;
  • retry имеет backoff и jitter;
  • mutating retries не спрятаны одновременно в нескольких уровнях SDK;
  • timeout соответствует реальной latency provider;
  • 5xx интерпретируется по контракту конкретного API, а не универсально;
  • при отсутствии безопасного retry используется reconciliation или manual review;
  • необратимая операция без idempotency никогда не повторяется вслепую;
  • UI не предлагает пользователю немедленно повторить действие, пока результат первой операции неизвестен;
  • метрики различают transport failures и business failures;
  • тесты моделируют потерю response после успешного выполнения операции.

Если эти свойства реализованы, интеграция начинает корректно работать не только при стабильной сети, но и в тех ситуациях, ради которых вообще требуется production-архитектура.


Когда можно просто retry

После всей этой статьи может показаться, что каждый сетевой запрос требует сложной state machine.

Нет.

Например обычный read:

GET /catalog/4812

после timeout чаще всего можно безопасно повторить.

То же относится к действительно идемпотентным операциям с ясным контрактом.

Сложность нужна там, где повтор способен породить новый необратимый бизнес-эффект.


Самый полезный вопрос перед retry

Не:

Какая ошибка пришла?

А:

Что произойдёт, если первая попытка на самом деле уже выполнилась?

Если ответ:

ничего плохого

retry, вероятно, безопасен.

Если:

спишутся деньги ещё раз;
создастся второй заказ;
уйдёт второй payout;

сначала нужна idempotency или reconciliation.


И второй вопрос

Можем ли мы доказать, что первая попытка не выполнилась?

Если нет, состояние:

UNKNOWN

честнее:

FAILED.

Эта модель работает далеко не только с платежами

Например интеграция с CRM.

Мы отправляем:

CREATE_LEAD

Получаем timeout.

Без idempotency retry создаёт:

Lead #1
Lead #2

Или логистика

CREATE_SHIPMENT

Response потерялся.

Повтор создаёт вторую отправку.


Или бухгалтерская система

CREATE_INVOICE

Timeout.

Через минуту появляются два счёта.


Или AI job у внешнего provider

START_GENERATION

Первый процесс уже запущен и стоит денег.

Retry создаёт второй generation job.


Или создание виртуальной машины

Именно поэтому AWS много лет продвигает caller-provided client token для idempotent API: клиент может безопасно повторить запрос после неопределённой ошибки, не создавая второй ресурс. AWS отдельно описывает ситуацию, когда клиент получает timeout, хотя ресурс на стороне сервиса уже успел запуститься.

Одна и та же проблема возникает во множестве совершенно разных систем.


Главное изменение мышления

Новички часто моделируют внешний запрос так:

CALL
↓
SUCCESS / ERROR

Production-модель выглядит иначе:

INTENT
  ↓
ATTEMPT
  ↓
┌───────────────┬─────────────────┬─────────────────┐
│               │                 │                 │
▼               ▼                 ▼                 ▼
SUCCESS      REJECTED        RETRYABLE         UNKNOWN
                                  │                 │
                                  ▼                 ▼
                                RETRY         RECONCILIATION

Потому что сеть способна потерять информацию именно в тот момент, когда внешний бизнес-эффект уже произошёл.


Вместо вывода

Timeout внешнего API не означает:

Операция не выполнилась.

Он означает только:

Клиент не получил завершённый ответ в ожидаемое время.

За это время удалённая система могла:

не получить запрос;
получить его и ещё обрабатывать;
выполнить операцию;
зафиксировать результат;
отправить response, который потерялся в сети.

Именно поэтому blind retry опасен для любых операций с побочными эффектами.

Хорошая интеграция вместо этого сохраняет идентичность бизнес-команды:

User intent
    ↓
Local operation ID
    ↓
Idempotency key
    ↓
Provider operation

А после неопределённого ответа не врёт себе:

FAILED.

Она говорит:

PENDING_CONFIRMATION.

После чего использует:

повтор с тем же idempotency key;
status lookup;
webhook;
reconciliation;
manual review

в зависимости от возможностей внешнего API.

В этом и заключается ключевое правило надёжных интеграций:

отсутствие подтверждения — это ещё не подтверждение отсутствия результата.

Если система умеет сохранять эту неопределённость и затем разрешать её безопасным способом, timeout перестаёт быть причиной двойных заказов, повторных выплат и других труднообъяснимых production-инцидентов.

Он становится обычным, предусмотренным состоянием распределённой системы.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →