Интеграция отправляет запрос во внешний сервис:
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:12refund успешно завершается.
Наш клиент уже получил 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:
FAILEDHTTP тоже проводит это различие
Текущая спецификация HTTP определяет методы вроде PUT, DELETE и безопасные методы как идемпотентные именно потому, что клиент может повторить их после коммуникационного сбоя, даже если первый запрос на самом деле успел выполниться. При этом автоматически повторять неидемпотентный запрос нельзя без дополнительных гарантий, что повтор не создаст второй эффект.
Это важная идея:
проблема retry существует именно потому, что после потери ответа клиент может не знать результат первого вызова.
Особенно опасен обычный POST
Представим:
POST /ordersbody:
{
"productId": 4812,
"quantity": 1
}Первый запрос дошёл.
Сервер создал:
Order #9001Response потерялся.
Клиент делает 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:
ETIMEDOUTSupport уже понимает ситуацию.
Вместо:
Клиент говорит, деньги списались, а у нас ошибка.
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 /payoutGET обычно можно повторить
Получить данные ещё раз, как правило, безопасно.
Если 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
↓
500PayPal прямо документирует сценарий, когда запрос вернул 500, хотя refund уже был выполнен; повтор с тем же PayPal-Request-Id позволяет не создать второй возврат.
Поэтому для mutating API даже:
5xxиногда нужно воспринимать как:
result uncertainдо получения дополнительных гарантий конкретного provider.
А вот validation error обычно намного определённее
Например:
400 Bad Requestprovider явно сообщает:
amount must be positiveОперация не должна была начаться.
Здесь повторить тот же запрос бессмысленно.
Нужно исправлять payload.
Но нельзя строить универсальную логику только по status code
Разные provider имеют разные контракты.
Для одного:
409означает:
duplicate operationдля другого:
resource conflict.Для одного 500 безопасно retry с idempotency key.
Для другого документация может требовать сначала проверить статус.
Поэтому integration adapter должен знать семантику конкретного API.
Именно adapter должен переводить внешний мир в наши состояния
Например provider отвечает:
network timeoutAdapter возвращает:
UNKNOWNProvider:
400 invalid amountAdapter:
PERMANENT_FAILUREProvider:
429Adapter:
RETRY_LATERProvider:
200Adapter:
SUCCESSBusiness 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.succeededwebhook.
Теперь локальная запись:
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с:
UNIQUEconstraint.
Теперь два канала подтверждения физически не могут зарегистрировать один внешний платёж как два разных.
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 reviewWebhook способен завершить reconciliation раньше
Если provider сам прислал:
payment.succeededscheduled status check становится больше не нужен.
Он может при следующем запуске увидеть:
already SUCCEEDEDи завершиться no-op.
Снова помогает idempotency.
Именно поэтому интеграционная state machine должна быть общей
HTTP callback, webhook и reconciliation не должны создавать три независимых истины.
Они должны обновлять одну локальную operation.
Пример полного сценария оплаты
Пользователь нажимает:
ОплатитьBackend создаёт:
PaymentAttempt #991
status = CREATED
idempotency_key = pay:order:1842:v1Background worker отправляет:
POST providerс тем же ключом.
Provider принимает payment.
Наш connection timeout происходит после commit.
Локально:
PaymentAttempt #991
status = PENDING_CONFIRMATIONЧерез пять секунд reconciliation:
GET provider paymentполучает:
SUCCEEDED.Локально:
PaymentAttempt #991
→ SUCCEEDEDOrder:
WAITING_PAYMENT
→ PAIDWebhook приходит ещё через секунду.
Находит:
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_SHIPMENTResponse потерялся.
Повтор создаёт вторую отправку.
Или бухгалтерская система
CREATE_INVOICETimeout.
Через минуту появляются два счёта.
Или AI job у внешнего provider
START_GENERATIONПервый процесс уже запущен и стоит денег.
Retry создаёт второй generation job.
Или создание виртуальной машины
Именно поэтому AWS много лет продвигает caller-provided client token для idempotent API: клиент может безопасно повторить запрос после неопределённой ошибки, не создавая второй ресурс. AWS отдельно описывает ситуацию, когда клиент получает timeout, хотя ресурс на стороне сервиса уже успел запуститься.
Одна и та же проблема возникает во множестве совершенно разных систем.
Главное изменение мышления
Новички часто моделируют внешний запрос так:
CALL
↓
SUCCESS / ERRORProduction-модель выглядит иначе:
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-инцидентов.
Он становится обычным, предусмотренным состоянием распределённой системы.