Когда два сервиса общаются в одном процессе, вызов метода либо возвращает результат, либо кидает исключение — третьего не дано. Как только между ними появляется сеть, добавляется третий исход, самый неприятный: вы не знаете, что произошло. Запрос мог не дойти, дойти и потеряться на обратном пути, или сервер честно всё сделал, но ответ застрял в дороге. Сеть — это не «медленный вызов метода», это принципиально другая история, и надёжный backend строится на признании этого факта.

Разберём по шагам, почему сеть ненадёжна, зачем на каждый вызов нужен таймаут, как безопасно повторять запросы и что делать, когда сосед явно упал. Всё это — не абстракция, а набор привычек, без которых сервис под нагрузкой рано или поздно «зависает» без единой ошибки в логах.

Сразу картина целиком: сосед завис, и от того, стоит ли на вызове таймаут, зависит, останется ли его сбой чужим.

сосед завис · 2 запроса в секунду · пул 8 потоков 0 1 2 3 4 5 6 с без таймаута — поток занят, пока сосед не ответит 8 потоков с таймаутом 1 с — поток отдаёт ошибку и уходит в пул 8 потоков занято 8 из 8 очередьзапросы 9 и 10 ждут потокзанято 2 из 8 через 4 с все 8 потоков заняты — сервис не отвечает, хотя сам здоровв каждый момент 6 потоков из 8 свободны — чужой сбой остался чужим

Сосед завис — и всё решает таймаут. Без него каждый запрос забирает поток навсегда: через 4 секунды заняты все 8 потоков, и сервис перестаёт отвечать, хотя сам здоров. С таймаутом в 1 секунду занято 2 потока из 8 — чужой сбой остаётся чужим.

Обязательно

Сеть ненадёжна по природе

У начинающих есть тихое допущение: «вызвал сервис — он ответит». На нём разбиваются целые системы. Ещё в 90-х инженеры сформулировали список «заблуждений о распределённых системах» — вещей, которые кажутся очевидно верными, но на деле ложны. Первые три самые важные:

  • Сеть надёжна. Нет. Кабель дёргают, коммутатор перезагружают, Wi-Fi моргает, пакеты теряются. Любой вызов может не дойти.
  • Задержка нулевая. Нет. Ответ из соседней стойки — миллисекунды, из другого дата-центра — десятки и сотни. Это не бесплатно.
  • Полоса бесконечна. Нет. Канал не резиновый; большой ответ или всплеск трафика упрутся в потолок.

Аналогия — телефонный звонок вместо разговора в одной комнате. В комнате собеседник вас слышит гарантированно. По телефону связь может оборваться на полуслове, и вы не знаете, услышал он вашу последнюю фразу или нет. Распределённая система живёт именно в режиме «телефонного звонка», и код должен быть к этому готов. Подробнее о самих каналах связи — в статьях про TCP и UDP и сетевые соединения.

Отдельный тяжёлый случай — network partition: связь между частями системы пропала, но каждая часть жива и думает, что права другая сторона умерла. Полностью это не «решается», к этому готовятся.

Таймаут на каждый сетевой вызов

Главное правило надёжности звучит скучно, но спасает больше всего систем: у каждого сетевого вызова должен быть таймаут. Всегда. По умолчанию многие HTTP-клиенты ждут ответа бесконечно — и это ловушка.

Представьте: ваш сервис дёрнул соседа, а тот завис. Без таймаута поток, обслуживающий запрос, встаёт и ждёт. Приходит второй такой запрос — встаёт второй поток. Пул потоков и пул соединений исчерпываются, и вот уже весь ваш сервис не отвечает — хотя сам он здоров, просто все его ресурсы заняты ожиданием мёртвого соседа. Это и есть то самое «оно зависло»: не упало с ошибкой, а тихо перестало отвечать.

Таймаут превращает бесконечное ожидание в честную, быструю ошибку, которую можно обработать. Полезно различать три штуки, которые часто зовут одним словом:

  • Connect timeout — сколько ждём установления соединения. Держат коротким: внутри одного дата-центра хватает сотен миллисекунд, до чужого сервиса через интернет закладывают секунду-другую.
  • Read/response timeout — сколько ждём очередной порции данных по уже установленному соединению. Тут легко обмануться: это предел на молчание, а не на весь ответ — каждый пришедший байт обнуляет отсчёт заново. Ответ, который течёт медленно, но без пауз, по этому таймауту не оборвётся никогда.
  • Дедлайн на операцию — предел на весь вызов целиком: установка соединения, ожидание, повторы и чтение ответа вместе. Только он и даёт настоящее обещание «дольше стольких секунд мы тут не задержимся», и сам собой не появляется — его задают отдельно (в стандартном HTTP-клиенте Java это HttpRequest.Builder.timeout(Duration), в gRPC — deadline на вызов).

Значения выбирают по реальному поведению зависимости, а не «на глаз побольше, чтобы не срабатывал». Таймаут, выставленный в 60 секунд «на всякий случай», не защищает — он просто откладывает катастрофу.

Ретраи — только для идемпотентных операций

Раз вызов может не дойти, логично его повторить. Ретраи — мощный инструмент, но у него острый край.

Повторять можно только идемпотентные операции. Идемпотентность — это когда повтор операции даёт тот же результат, что и один вызов. Прочитать заказ (GET) можно хоть десять раз — ничего не изменится. А вот «списать деньги» повторять вслепую нельзя: если первый запрос на самом деле дошёл и деньги списались, а ответ потерялся, ретрай спишет второй раз.

Второй острый край — retry storm (лавина повторов). Сосед притормозил, все клиенты дружно словили таймаут и все дружно повторили запрос — нагрузка удвоилась. Сосед лёг окончательно, клиенты повторяют ещё агрессивнее, и повторы добивают уже упавший сервис, не давая ему подняться. Чтобы этого избежать, повторяют не сразу и не в лоб, а с exponential backoff (растущая пауза) и jitter (случайный разброс, чтобы клиенты не били в унисон):

attempt = 0
while attempt < MAX_ATTEMPTS:
    try:
        return call()                     # один сетевой вызов с таймаутом
    except RetriableError:
        attempt += 1
        if attempt == MAX_ATTEMPTS:
            raise                          # сдаёмся, отдаём ошибку наверх
        base = MIN_DELAY * (2 ** (attempt - 1))  # 0.2s, 0.4s, 0.8s, ...
        sleep(base + random(0, base))      # backoff + jitter

Три правила разумного ретрая: конечное число попыток, растущая пауза с jitter, и повтор только тех ошибок, которые имеет смысл повторять. Повторяют таймаут, обрыв соединения, 503, а заодно 502 и 504 — их отдаёт посредник на пути, который сам не достучался до сервиса или не дождался ответа. Не повторяют 400 и 404: второй раз выйдет ровно то же самое. Отдельно стоит 429 («слишком часто») — его повторять как раз нужно, но не по своему графику: сервер обычно сам говорит, когда возвращаться, заголовком Retry-After, и это указание важнее любой вашей формулы с удвоением паузы.

Идемпотентность и ключи идемпотентности

С идемпотентными операциями всё просто — их можно повторять. Проблема с теми, что меняют состояние и «не должны» повторяться: платёж, создание заказа, отправка письма. Как безопасно повторить перевод денег, если непонятно, дошёл первый запрос или нет?

Решение — ключ идемпотентности. Клиент генерирует уникальный идентификатор операции (например, UUID) и прикладывает его к запросу, обычно в заголовке. Сервер запоминает обработанные ключи. Приходит запрос с новым ключом — выполняем и записываем результат. Приходит повтор с тем же ключом — не выполняем заново, а возвращаем сохранённый результат первого раза.

Аналогия — номерок в гардеробе. Отдали пальто, получили жетон №17. Придёте с жетоном №17 ещё раз — вам не выдадут второе пальто, вам вернут то же самое. Ключ идемпотентности делает «списать деньги» безопасным для повтора: сколько бы раз клиент ни повторил запрос с одним ключом, деньги спишутся один раз.

Именно так устроены платёжные API: клиент присылает Idempotency-Key, и повторная отправка того же платежа — при обрыве, таймауте, ретрае — не создаёт второй платёж.

Circuit breaker: перестать долбить упавшего

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

Здесь помогает circuit breaker — «предохранитель», по аналогии с электрическим. Бессмысленно долбить упавшего соседа и ждать таймаут на каждом запросе, и у breaker три состояния ровно под это. Пока всё нормально, он замкнут (closed): запросы идут насквозь, а он считает ошибки. Ошибок стало слишком много, и он размыкается (open): на время сразу отклоняет запросы к этой зависимости, не тратя таймаут, и ваш сервис быстро отдаёт ошибку или запасной ответ вместо того, чтобы висеть. Спустя паузу он полуоткрывается (half-open) и пропускает пробный запрос: прошёл — замыкается, трафик восстанавливается; снова ошибка — опять размыкается.

Смысл в том, чтобы дать упавшему соседу передышку и не превратить его сбой в свой собственный. Circuit breaker и грамотные ретраи — две стороны одной медали и обычно работают в паре.

замкнут разомкнут полуоткрыт ошибок выше порога пауза истекла пробный прошёл

Три состояния предохранителя: замкнут (closed) пропускает и считает ошибки, разомкнут (open) отказывает сразу, полуоткрыт (half-open) пропускает один пробный запрос и по нему решает, вернуться в работу или снова разомкнуться.

Перегородка: свой предел на каждого получателя

Предохранитель отключает сломанного партнёра, но до его срабатывания медленный партнёр успевает съесть общие ресурсы: потоки заняты ожиданием, и сервис не отвечает даже там, где этот партнёр не нужен. Закрывает это перегородка — отдельный предел одновременных вызовов на каждого получателя.

Работает она просто: к платёжному шлюзу разрешено, скажем, двадцать одновременных вызовов, к службе доставки — десять; двадцать первый вызов к шлюзу не встаёт в общую очередь, а сразу получает отказ. Сервис при этом продолжает обслуживать всё остальное.

Величину берут из арифметики «запросов в секунду × время ответа»: десять вызовов в секунду при ответе за 200 мс — это два одновременных, и предел в пять уже с запасом. Слишком большой предел означает, что перегородки нет; слишком маленький — отказы на нормальной нагрузке.

Два места, где перегородка нужна почти всегда: исходящие вызовы к каждому внешнему партнёру и входящие долгие операции (выгрузки, отчёты), которые иначе занимают все потоки. И обязательная метрика: сколько отказов дала перегородка — без неё непонятно, мала она или велика.

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

Что показать, когда ответа нет

Предохранитель «сразу отдаёт ошибку» — это половина ответа. Вторая половина: что видит пользователь. Четыре варианта, в порядке предпочтения.

Запасной ответ из кэша. Последнее известное значение с отметкой «данные на 12:05». Годится для витрин, курсов валют, списков — то есть везде, где немного устаревшие данные лучше пустоты.

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

Деградация функции. Рекомендации не пришли — показываем популярное. Поиск недоступен — показываем каталог с простым фильтром. Функция работает хуже, но работает.

Честная ошибка с возможностью повторить. Когда ни кэша, ни альтернативы нет: понятное сообщение и кнопка «попробовать снова», а не пустой экран или бесконечный крутящийся индикатор.

Чего делать нельзя: показывать ошибку всей страницы из-за одного недоступного блока и молча подставлять нули вместо неизвестных значений (нуль в цене или в остатке хуже, чем «неизвестно»).

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

Повторы перемножаются по цепочке. Сервис А повторяет три раза вызов к Б, Б повторяет три раза вызов к В — и одна попытка пользователя превращается в девять запросов к В. Три уровня — двадцать семь. Именно так лежащий нижний сервис добивают его же клиенты: он начал отвечать медленно, все начали повторять, нагрузка выросла в разы, и он лёг окончательно.

Три правила против этого.

Повторять на одном уровне. Обычно — на самом внешнем, ближе к пользователю, или наоборот только на самом нижнем; но не на всех сразу. Остальные уровни отдают ошибку наверх без повторов.

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

Повтор не должен проходить сквозь предохранитель. Если предохранитель разомкнут, повторять бессмысленно: отказ надо отдать сразу.

Плюс общее: повторы всегда с нарастающей паузой и разбросом — иначе клиенты, отступившие синхронно, вернутся одновременно и повторят обвал.

Ключ идемпотентности со стороны получателя

Отправитель прислал ключ — теперь получателю надо что-то с ним делать, и это отдельная работа.

Дедупликация. Ключ хранят в таблице с уникальным индексом и записывают в той же транзакции, что и саму операцию. Тогда повтор нарушает уникальность и не создаёт второй заказ. Важно: не «проверил и записал» двумя запросами (между ними пролезет параллельный повтор), а вставка с обработкой конфликта.

Что возвращать на повтор. Сохранённый ответ первой операции — с тем же кодом и тем же телом. Поэтому вместе с ключом хранят и ответ (или достаточно данных, чтобы его собрать). Отвечать «уже обработано» другим кодом — значит заставить клиента писать отдельную ветку обработки.

Повтор с другими данными. Тот же ключ, но другое тело запроса — это ошибка клиента, а не повтор. Вместе с ключом хранят отпечаток запроса и на расхождение отвечают конфликтом: так ловится ошибка «переиспользовали ключ».

Срок жизни ключа. Хранить вечно не нужно, а слишком мало — опасно: повтор может прийти позже, чем вы думаете (клиент вернулся из офлайна, сообщение застряло в очереди). Обычная величина — сутки, для платежей больше; удаление старых ключей — отдельное фоновое задание.

Доставка «хотя бы один раз». Это фон всей темы: любая очередь, любой вебхук и любой повтор означают, что сообщение придёт дважды. Поэтому получатель обязан быть идемпотентным по своей природе, а не полагаться на то, что отправитель не повторит.

Бюджет таймаутов по цепочке вызовов

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

Если у внешнего вызова таймаут 2 секунды, а внутренний сервис в цепочке ждёт своего соседа 5 секунд, то клиент снаружи уже сдался и ушёл, а внутри цепочка всё ещё трудится над ответом, который никому не нужен — и держит ресурсы. Это называется бюджет таймаутов: у всей операции есть общий лимит времени, и каждый следующий вызов вглубь получает остаток от него, а не свой собственный с потолка.

Практическое правило: чем глубже вызов в цепочке, тем короче его таймаут. В цифрах: шлюз даёт операции 2 секунды; сервис заказов, потратив 200 мс на своё, отдаёт оплате остаток в 1,8 с; оплата, у которой на банк уходит до секунды, ставит банку 1,5 с и оставляет себе 300 мс на ответ. Любой таймаут в цепочке длиннее остатка бесполезен. Внешний слой — самый терпеливый, внутренние — всё менее, чтобы к моменту, когда клиент теряет надежду, вся цепочка уже успела аккуратно свернуться. Ретраи внутри цепочки тоже едят бюджет: три попытки по секунде — это уже три секунды, которые нужно уместить в общий лимит.

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

шлюз → заказы 2000 мс заказы → оплата 1800 мс оплата → банк 1500 мс запас на ответ 300 мс

Общий срок в две секунды режется по цепочке: каждый следующий вызов получает остаток, банку достаётся полторы секунды, а последние триста миллисекунд держат на свой ответ.

Где это применяется

Всё перечисленное — не теория, а ежедневный инструментарий backend-разработчика. Как только сервис ходит по сети (а он всегда ходит — в базу, в очередь, к соседнему сервису, во внешний API), эти привычки определяют, переживёт ли он чужой сбой или ляжет вместе с ним. Инженерные правила — таймауты, retry, circuit breaker, bulkhead — собраны в стандарте устойчивость к сбоям; чтобы вообще увидеть, что вызов тормозит или breaker разомкнулся, нужна наблюдаемость.

Где спотыкаются начинающие:

  • Забывают таймаут и удивляются, почему здоровый сервис «завис». Клиент по умолчанию часто ждёт бесконечно — это первое, что нужно проверить.
  • Повторяют неидемпотентные операции без ключа идемпотентности — и получают двойные платежи и дубли заказов.
  • Ретраят в лоб, без backoff и jitter, устраивая упавшему соседу retry storm вместо передышки.
  • Ставят один огромный таймаут «чтобы точно хватило» — это не защита, а отложенная деградация: ресурсы всё равно висят, просто дольше.
  • Ретраят 400/404 — ошибки, которые при повторе дадут ровно то же самое. Повторять стоит только преходящие сбои.
Дополнительно: при первом чтении можно пропустить

Глубже: обратное давление: 429, Retry-After и что делать обеим сторонамрасширенное

Повторы и предохранитель выше защищают вызывающего от лежащего соседа. Есть и другая сторона: сосед жив, но просит притормозить. Если вызывающий этого не понимает, он превращает «сосед перегружен» в «сосед лежит», и предохранитель срабатывает уже по его вине.

Принимающая сторона говорит об этом кодом 429 Too Many Requests и заголовком Retry-After: 30 (секунды или дата), а часто ещё и заголовками остатка лимита, RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, чтобы клиент видел стену до того, как в неё врезался. 503 с Retry-After означает то же самое, но про сервер целиком, а не про конкретного клиента. Это не ошибка вызова, а инструкция.

Вызывающий делает три вещи. Не повторяет раньше Retry-After: обычный backoff здесь не подходит, сервер уже сказал, сколько ждать. Считает 429 отдельно от 5xx в метриках и в предохранителе, потому что это не сбой соседа, а превышение своей квоты, и размыкать цепь по нему неверно. И ограничивает себя сам, до того как получил отказ: у клиента к внешнему API стоит свой лимитер с известной квотой (Bucket4j, семафор на число одновременных вызовов), а запросы сверх квоты ждут в очереди или отбрасываются у себя, где это дешевле.

Принимающий делает зеркальное. Лимит на клиента, а не общий: один разошедшийся клиент не должен съесть квоту остальных, поэтому ключ лимитера это идентификатор клиента или токен, а не адрес. Отказ дешёвый: 429 отдают до аутентификации в базе и до бизнес-логики, иначе защита сама съедает то, что защищала. Очередь ограничена: если запросы копятся в очереди потоков или в буфере, лучше отказать сразу, чем принять и не выполнить; ограниченная очередь и отказ при переполнении и есть обратное давление, и в асинхронных потоках (реактивные клиенты, Kafka-потребители) оно встроено в протокол как запрос «дай мне N следующих». И деградация вместо отказа там, где можно: отдать ответ без рекомендаций, без картинок, из кэша.

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

Глубже: вебхуки: доставка наружу с повторами, подписью и очередьюрасширенное

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

Доставка через очередь, не из запроса. Событие «заказ оплачен» произошло внутри транзакции, а получатель может отвечать пять секунд или не отвечать вовсе. Поэтому вебхук никогда не отправляют прямо из обработчика: запись о событии кладут в таблицу исходящих (outbox) той же транзакцией, а отдельный отправитель читает её и стучится наружу. Получатель обязан ответить 2xx быстро и обработать потом, у себя, и вы это пишете в документацию.

Повторы с растущей паузой и пределом. Не ответил или ответил 5xx: повтор через минуту, пять, полчаса, час, до суток, а затем событие помечается недоставленным, и клиенту доступен журнал с ручным повтором. Повтор означает дубли, поэтому у каждого события есть идентификатор в теле и заголовке, и получатель дедуплицирует по нему; это ключ идемпотентности из основной части, только теперь вы его выдаёте.

Подпись. Адрес получателя публичный, и любой может прислать туда «заказ оплачен». Тело подписывают HMAC с общим секретом, выданным при регистрации адреса, и кладут в заголовок вместе с отметкой времени: X-Signature: t=1780617600,v1=5257a86.... Получатель считает HMAC от того же тела и времени, сравнивает и отбрасывает старые отметки, чтобы перехваченный запрос нельзя было повторить. Секрет можно поменять, поэтому предусматривают два действующих секрета на время ротации.

Предел на получателя. Один получатель, который отвечает по десять секунд, не должен задерживать остальных: у отправителя очередь и таймаут (секунды, не минуты) на каждого получателя отдельно, число одновременных доставок на адрес ограничено, а получатель, который не отвечает сутки, отключается с уведомлением. Порядок доставки не обещают: события могут прийти не по порядку, и в теле есть версия или время, по которому получатель разберётся.

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

Коротко

  • Сеть ненадёжна всегда: у каждого вызова таймаут, у операции дедлайн, и чем глубже вызов в цепочке, тем короче срок.
  • Повторяют только идемпотентное и только преходящие сбои, с растущей паузой и разбросом; неидемпотентное закрывают ключом идемпотентности.
  • Предохранитель перестаёт стучаться в лежащего соседа и даёт ему передышку; 429 считают отдельно и по нему цепь не размыкают.
  • 429 и Retry-After это инструкция, а не ошибка: ждать столько, сколько сказано, ограничивать себя до отказа; принимающий отказывает дёшево, по клиенту и с ограниченной очередью.
  • Вебхук отправляют из outbox отдельным отправителем с повторами и пределом на получателя, подписывают HMAC с отметкой времени, дедуплицируют по идентификатору события, адрес проверяют на внутренние диапазоны.
  • Перегородка — свой предел одновременных вызовов на каждого получателя (и на долгие входящие операции): двадцать первый вызов получает отказ сразу, а не занимает поток.
  • Отказ надо чем-то закрыть: значение из кэша с отметкой времени, частичный ответ, деградация функции и только потом честная ошибка; нули вместо неизвестных значений — хуже пустоты.
  • Повторы перемножаются по цепочке (три уровня по три попытки — двадцать семь запросов): повторяют на одном уровне, ограничивают долей от потока и не повторяют через разомкнутый предохранитель.
  • Получатель дедуплицирует по ключу вставкой в той же транзакции, возвращает на повтор сохранённый ответ, ловит повтор с другими данными по отпечатку и чистит ключи по сроку.

Что почитать дальше