DarkRiDDeR15 мин

HTTP-таймауты: как разложить общий deadline на измеримые фазы

HTTPНадёжность

Проблема выглядит просто: запрос к API иногда отвечает за 900 мс, а клиент прекращает ждать через 800 мс. Цена ошибки — пользователь получает «сервис недоступен», хотя сервер мог завершить операцию; либо клиент повторяет запись, не зная, успел ли первый запрос попасть в обработчик. Один общий timeout не объясняет, где потрачено время.

Причина — смешать connect timeout, TLS handshake, отправку тела, ожидание первого байта и чтение ответа в одну настройку. При этом разные библиотеки называют поля по-разному, а повторная попытка получает новый полный бюджет. Решение начинается с единого deadline запроса и наблюдаемого разложения этого бюджета по фазам.

Deadline — это граница операции, а не число для каждой фазы

Общий deadline отвечает на вопрос: до какого момента результат операции имеет смысл для вызывающего кода. Фазовые таймауты отвечают на другой вопрос: сколько можно ждать конкретный переход. Если каждому этапу дать по 800 мс, цепочка из DNS, TCP, TLS и чтения незаметно превращается в несколько секунд. Если выставить connect в 50 мс для сети с редким холодным DNS, ошибка будет ложной.

Хорошая модель хранит абсолютный момент окончания или остаток бюджета, а не только независимые таймеры. Перед началом каждой фазы вычисляется remaining = deadline - now. Если остатка нет, библиотека должна завершить операцию до сетевого вызова. Это предотвращает ситуацию, в которой чтение получает ещё 300 мс после того, как общий deadline уже истёк.

Фазы HTTP-запроса и сигналы
ФазаЧто измеряемТипичный симптомДействие
DNSвремя разрешения именипервый запрос медленныйкэш, resolver и лимит DNS
TCP connectустановление соединенияошибка до TLSмаршрут, pool, connect timeout
TLS handshakeобмен и проверка сертификатаconnect есть, ответа нетцепочка, crypto, TLS budget
Request writeотправка заголовков и телаupload зависаетразмер тела, backpressure
TTFB/readожидание ответа и чтениесервер медленныйserver time, read timeout, payload

Почему повторная попытка опасна

Timeout не сообщает, что сервер ничего не сделал. Для GET повтор часто безопаснее при корректной семантике ресурса, но для POST это зависит от идемпотентности операции и ключа дедупликации. Если клиент запускает повтор до окончания исходного запроса, два обработчика могут изменить состояние. Поэтому deadline должен попадать в журнал вместе с методом, идентификатором операции и фазой остановки.

Вторая ловушка — retry с тем же самым полным бюджетом. При трёх попытках по 800 мс пользователь ждёт 2,4 секунды плюс задержки, а upstream получает утроенную нагрузку. Бюджет можно делить между попытками, но сначала нужно доказать, что повтор допустим. Для записи полезнее короткий результат «неизвестно, завершилось ли» и проверка статуса операции, чем агрессивная отправка.

Карта HTTP-запроса: общий deadline проходит через DNS, TCP, TLS и чтение; каждая фаза получает только оставшееся время и собственный сигнал.
Схема отделяет бюджет операции от отдельных измерений. Ошибка фазы не должна превращаться в общий диагноз без соответствующего сигнала.

Runnable-пример: проверяем бюджет без сети

Функция ниже получает общий бюджет и длительности уже измеренных фаз. Она возвращает сумму, остаток и причину. Это учебный пример: он не запускает socket и не заменяет таймеры HTTP-клиента, зато показывает инвариант, который удобно тестировать независимо от сети. Ожидаемый результат для превышения — deadline-exceeded и нулевой остаток.

import { allocateTimeoutBudget } from './upgrade-2027-10.mjs';

const within = allocateTimeoutBudget({
  totalMs: 800,
  dnsMs: 42,
  tlsMs: 88,
  requestMs: 510,
});
const late = allocateTimeoutBudget({
  totalMs: 800,
  dnsMs: 120,
  tlsMs: 210,
  requestMs: 560,
});

console.log(within.ok, within.remainingMs);
console.log(late.ok, late.reason, late.remainingMs);
// true 160
// false deadline-exceeded 0

Порядок диагностики

  1. Запишите общий deadline и отдельно отметьте, в какой момент клиент завершил ожидание. Не начинайте с увеличения числа.
  2. Добавьте интервалы DNS, TCP, TLS, отправки и чтения в одну запись запроса. Одинаковые имена фаз важнее конкретной библиотеки.
  3. Проверьте, передаётся ли остаток бюджета на следующую фазу. Новый таймер не должен начинаться после истечения абсолютного deadline.
  4. Сопоставьте фазу отказа с методом. Для записи проверьте идемпотентность и возможность узнать результат по идентификатору операции.
  5. Разделите connect/read timeout и deadline в конфигурации. Каждый параметр должен иметь владельца и тест на граничное значение.
  6. Проверьте cold и warm соединение отдельно: pool может скрыть TLS в одном случае и показать его в другом.

Что измерение не доказывает

Большой TTFB не доказывает, что сервер «медленный»: время могло уйти на proxy, очередь или повторный TLS. Малый TTFB не гарантирует быструю загрузку всего тела. Сетевой timeout также не равен HTTP-статусу: клиент может закрыть соединение, а сервер продолжить обработчик. Поэтому метрики фаз нужно связывать с серверным временем и размером ответа, но не подменять ими друг друга.

Точное разделение требует, чтобы библиотека действительно сообщала фазы. Если она отдаёт только total duration, не называйте реконструированные интервалы фактами. Можно начать с instrumented adapter или событий connection pool, а пока фиксировать только известную границу: «клиент прекратил ждать через N мс». Это менее эффектно, но безопаснее для решения.

Ограничения и следующий шаг

Функция не учитывает jitter часов, системные очереди, HTTP/2 multiplexing и работу proxy. В боевом клиенте deadline должен отменять все вложенные операции, иначе сокет продолжит жить после ответа вызывающему коду. Учебные числа не являются нормативными значениями для конкретной сети.

Следующий шаг — выбрать один endpoint, измерить пять фаз на холодном и тёплом соединении и построить таблицу бюджета для успешного и просроченного запроса. Только после этого меняйте timeout или retry. Для записи добавьте отдельную проверку идемпотентности и способ узнать, был ли первый запрос принят.

Проверяемые источники

  • RFC 9110 — HTTP Semantics — IETF, июнь 2022 года, RFC 9110, Standards Track. Применение: Определяет границы HTTP-запроса, ответа и ошибки обмена, на которые опирается разбор задержки. Граница: Не измеряет DNS, TLS или работу конкретного клиента; эти интервалы нужно наблюдать отдельно.
  • RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3 — IETF, август 2018 года, RFC 8446, Standards Track. Применение: Описывает handshake TLS 1.3 и позволяет считать установление защищённого соединения отдельной фазой. Граница: Не задаёт таймауты приложения, повторные попытки и настройки конкретной TLS-библиотеки.