Проблема выглядит просто: запрос к API иногда отвечает за 900 мс, а клиент прекращает ждать через 800 мс. Цена ошибки — пользователь получает «сервис недоступен», хотя сервер мог завершить операцию; либо клиент повторяет запись, не зная, успел ли первый запрос попасть в обработчик. Один общий timeout не объясняет, где потрачено время.
Причина — смешать connect timeout, TLS handshake, отправку тела, ожидание первого байта и чтение ответа в одну настройку. При этом разные библиотеки называют поля по-разному, а повторная попытка получает новый полный бюджет. Решение начинается с единого deadline запроса и наблюдаемого разложения этого бюджета по фазам.
Deadline — это граница операции, а не число для каждой фазы
Общий deadline отвечает на вопрос: до какого момента результат операции имеет смысл для вызывающего кода. Фазовые таймауты отвечают на другой вопрос: сколько можно ждать конкретный переход. Если каждому этапу дать по 800 мс, цепочка из DNS, TCP, TLS и чтения незаметно превращается в несколько секунд. Если выставить connect в 50 мс для сети с редким холодным DNS, ошибка будет ложной.
Хорошая модель хранит абсолютный момент окончания или остаток бюджета, а не только независимые таймеры. Перед началом каждой фазы вычисляется remaining = deadline - now. Если остатка нет, библиотека должна завершить операцию до сетевого вызова. Это предотвращает ситуацию, в которой чтение получает ещё 300 мс после того, как общий deadline уже истёк.
| Фаза | Что измеряем | Типичный симптом | Действие |
|---|---|---|---|
| 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 получает утроенную нагрузку. Бюджет можно делить между попытками, но сначала нужно доказать, что повтор допустим. Для записи полезнее короткий результат «неизвестно, завершилось ли» и проверка статуса операции, чем агрессивная отправка.
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Порядок диагностики
- Запишите общий deadline и отдельно отметьте, в какой момент клиент завершил ожидание. Не начинайте с увеличения числа.
- Добавьте интервалы DNS, TCP, TLS, отправки и чтения в одну запись запроса. Одинаковые имена фаз важнее конкретной библиотеки.
- Проверьте, передаётся ли остаток бюджета на следующую фазу. Новый таймер не должен начинаться после истечения абсолютного deadline.
- Сопоставьте фазу отказа с методом. Для записи проверьте идемпотентность и возможность узнать результат по идентификатору операции.
- Разделите connect/read timeout и deadline в конфигурации. Каждый параметр должен иметь владельца и тест на граничное значение.
- Проверьте 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-библиотеки.