{ "index": 9, "slug": "editorial-2027-10-practice-long-form-interview", "title": "HTTP-таймауты: как разложить общий deadline на измеримые фазы", "excerpt": "Запрос может завершиться таймаутом до ответа сервера или повторить запись после неясного результата. Разбираем общий deadline, фазы HTTP и безопасную проверку retry.", "contentHtml": "
Клиент отправляет запрос с бюджетом 800 мс. Сервер отвечает примерно через 900 мс, поэтому пользователь получает ошибку, хотя обработчик мог успеть изменить данные. Если после этого автоматически повторить POST, операция может выполниться дважды. Цена ошибки — потерянный результат, двойное списание или создание лишней записи, а также дополнительная нагрузка на upstream.
Главный вопрос не в том, какое число поставить в поле timeout. Нужно понять, как один абсолютный deadline проходит через DNS, установление соединения, TLS, отправку запроса, ожидание первого байта, чтение тела и retry. Ни одна новая фаза или повторная попытка не получает отдельные 800 мс: они используют только остаток общего бюджета.
Deadline — это момент, после которого результат операции больше не подходит вызывающему коду. Таймаут фазы — верхняя граница отдельного перехода. Эти понятия связаны, но не взаимозаменяемы. Если дать DNS, connect, TLS и чтению по 800 мс каждому, цепочка может длиться несколько секунд. Если поставить каждой фазе слишком маленький лимит, рабочий запрос будет отклонён до исчерпания общего бюджета.
Храните не набор независимых секундомеров, а абсолютный момент окончания, рассчитанный на монотонных часах среды. Перед каждой фазой вычисляйте remaining = deadline - now. При нулевом или отрицательном остатке не начинайте следующий сетевой вызов. Тот же остаток передаётся в retry и в ожидание ответа от endpoint статуса. Настенные часы могут корректироваться синхронизацией, поэтому они не должны быть единственным источником для измерения длительности.
START -> deadline = monotonicNow() + totalBudget -> remaining <= 0? -> STOP: deadline-exceeded -> DNS -> TCP connect + TLS -> request write -> TTFB -> read -> response? -> check status/semantics -> retry only with the same deadline -> otherwise timeout or unknown-resultЭто схема владения состоянием: вызывающий код владеет общим deadline, HTTP-адаптер — фазовыми сигналами и отменой, а API записи — способом узнать судьбу операции. Если один слой заводит новый полный таймер, он нарушает контракт верхнего слоя.
Полный путь HTTPS-запроса можно описать как разрешение имени, TCP-соединение, TLS handshake, запись сообщения и получение ответа. RFC 9110 описывает для схемы https последовательность от разрешения адреса и TCP до TLS и HTTP-запроса; RFC 8446 описывает handshake, после которого стороны получают ключевой материал для прикладных данных. Это протокольная последовательность, а не обещание конкретной библиотеки показать каждую фазу.
Пул соединений меняет картину. На тёплом соединении DNS, TCP и TLS могли произойти раньше, поэтому текущая операция начинается с записи запроса. В HTTP/2 несколько запросов используют одно соединение, и TLS нельзя честно приписать одному из них. Название измерения должно говорить, что именно наблюдалось: connection_setup_ms, request_write_ms, ttfb_ms или response_read_ms.
| Фаза | Сигнал | Симптом | Что можно заключить | Владелец проверки |
|---|---|---|---|---|
| DNS | Время от lookup до адреса | Медленный первый запрос | Задержка возникла при разрешении, если lookup измерен отдельно | Клиент или resolver |
| TCP connect | Время до установления TCP | Нет ответа до TLS | Проблема на маршруте, в пуле или лимите connect; причина требует сетевой проверки | HTTP-адаптер и сеть |
| TLS handshake | Время до готовности защищённого канала | Соединение открывается, HTTP не начинается | Затронута установка TLS или проверка сертификата, если событие записано | Клиент и владелец TLS |
| Request write | Время передачи заголовков и тела | Зависает upload | Нужно проверить размер тела, backpressure и write timeout | Клиент и proxy |
| TTFB | От конца записи до первого байта | Клиент ждёт сервер | В бюджет попали очередь, proxy и обработка; это не доказательство медленного origin | Proxy и сервис |
| Read | Время от первого байта до полного тела | Статус уже получен, тело не пришло | Нужно проверить размер ответа, streaming и скорость downstream | Клиент и сервис |
Если инструмент сообщает только total_duration, нельзя восстановить DNS, TLS или TTFB делением общего числа. Такая реконструкция выглядит точной, но не является измерением. До instrumented-адаптера логируйте только известные границы: время старта, время отмены, наличие HTTP-статуса и размер полученного тела.
Распределение фаз — проектное решение, а не таблица из RFC. Сначала измерьте cold и warm соединения, затем выберите резерв для вариативности. Жёсткий лимит connect защищает upstream от зависших попыток, но не должен превращать редкий cold start в массовый отказ. Для долгого ответа важнее отделить ожидание первого байта от чтения тела, иначе команда будет увеличивать read timeout, пытаясь лечить медленную обработку.
Ниже учебный бюджет в 800 мс. Он показывает арифметику, а не рекомендуемые значения. Сумма потолков равна общему deadline; фактические фазы могут закончиться раньше и оставить место для безопасного действия. У каждой строки должен быть владелец, измерение и тест на границе.
| Участок | Плановый предел | Зачем выделен | Если превышен |
|---|---|---|---|
| DNS | 80 мс | Не держать запрос из-за resolver | Отменить lookup и проверить кэш/резолвер |
| TCP + TLS | 180 мс | Открыть защищённое соединение | Проверить сеть, pool и холодное соединение |
| Write | 60 мс | Передать небольшой запрос | Проверить тело и backpressure |
| TTFB | 300 мс | Дождаться решения upstream | Сопоставить trace, очередь и server time |
| Read | 180 мс | Получить всё тело | Проверить streaming и размер payload |
Таблица полезна только как видимый контракт. На практике некоторые лимиты не складываются последовательно: тёплый pool пропускает DNS, TCP и TLS, а HTTP/2 делит соединение между запросами. Поэтому конфигурация должна хранить и общий deadline, и фазовые наблюдения, но не выдавать плановый предел за фактическое время.
Таймаут не сообщает, был ли запрос принят сервером. Клиент мог закрыть сокет после отправки тела, а сервер продолжить обработчик. Для чтения это обычно означает повторную проверку состояния. Для записи сначала определите, можно ли повторить действие без второго эффекта.
RFC 9110 называет идемпотентными безопасные методы, а также PUT и DELETE: повтор одинакового запроса должен иметь тот же задуманный эффект, что и один запрос. Та же спецификация отдельно предупреждает, что клиенту не следует автоматически повторять неидемпотентный метод, если нет способа доказать идемпотентность или узнать, что исходный запрос не применился. Сам метод не делает прикладную операцию безопасной: сервер может отправлять лог или создавать побочный аудит при каждом обращении.
POST с ключом дедупликации может получить прикладную идемпотентность, но это контракт API, а не свойство HTTP. Ключ должен быть стабильным для одной операции, храниться на сервере достаточно долго и связываться с результатом. Если такого контракта нет, честный ответ после таймаута — unknown-result. Клиент показывает возможность проверить статус, а не угадывает, что запись не произошла.
| Случай | Что известно | Следующий шаг | Риск |
|---|---|---|---|
| GET чтения | Нужно получить состояние ресурса | Повторить только в оставшемся бюджете или запросить актуальное состояние | Данные могли измениться между попытками |
| PUT/DELETE | Метод идемпотентен по HTTP-семантике, но побочные эффекты сервера отдельны | Повторить с тем же deadline после проверки API-контракта | Конкретный endpoint может иметь дополнительный эффект |
| POST с operation-id | Сервер умеет связать повторы с одной операцией | Повторить или прочитать статус по тому же идентификатору | Истёк срок хранения ключа или статус недоступен |
| POST без дедупликации | Неизвестно, применилось ли изменение | Не повторять вслепую; вернуть unknown-result и дать проверку | Дублирование записи или списания |
| Ответ 503/504 | Сигнал от сервера или gateway, но не доказательство отсутствия записи | Проверить retry policy и состояние операции | Повтор может усилить перегрузку |
Коды 408 и 504 тоже нельзя превращать в универсальную команду «повторить». 408 означает, что сервер не получил полный запрос за время ожидания; 504 означает, что gateway или proxy не дождался нужного upstream. Оба кода описывают наблюдаемую точку в цепочке, но не устанавливают прикладную судьбу POST. Для этой границы нужны логи сервера, correlation-id и endpoint статуса.
Перед подключением к реальному клиенту удобно проверить сам инвариант: сумма уже потраченных фаз не должна превысить общий бюджет, а следующий вызов стартует только при положительном остатке. Фрагмент ниже запускается обычным node, не импортирует локальные модули и не притворяется HTTP-адаптером.
function evaluateAttempt({ totalMs, phases }) {
if (!Number.isFinite(totalMs) || totalMs <= 0) throw new RangeError('totalMs must be positive');
const elapsedMs = phases.reduce((sum, phase) => {
if (!Number.isFinite(phase.ms) || phase.ms < 0) throw new RangeError('phase duration must be non-negative');
return sum + phase.ms;
}, 0);
const remainingMs = Math.max(0, totalMs - elapsedMs);
return { ok: elapsedMs <= totalMs, elapsedMs, remainingMs, canStartNextPhase: remainingMs > 0, reason: elapsedMs > totalMs ? 'deadline-exceeded' : null };
}
const within = evaluateAttempt({ totalMs: 800, phases: [{ ms: 42 }, { ms: 88 }, { ms: 310 }, { ms: 200 }] });
const late = evaluateAttempt({ totalMs: 800, phases: [{ ms: 120 }, { ms: 210 }, { ms: 380 }, { ms: 180 }] });
console.log(within);
console.log(late);
// { ok: true, elapsedMs: 640, remainingMs: 160, canStartNextPhase: true, reason: null }
// { ok: false, elapsedMs: 890, remainingMs: 0, canStartNextPhase: false, reason: 'deadline-exceeded' }Функция считает длительности, которые ей уже передали; она не измеряет сеть. В production-адаптере каждый вызов должен получать remainingMs, а при отмене должен завершаться и его дочерний lookup, socket или stream. Иначе арифметический тест будет зелёным, а сокет продолжит жить после ответа пользователю.
operation-id или зафиксируйте, почему его нет.unknown-result.Единый deadline не делает любую цепочку быстрой. На результат влияют очереди, планировщик, proxy, размер тела, повторные TLS-соединения, multiplexing и отмена в конкретной библиотеке. Фазовые лимиты не заменяют rate limit, circuit breaker или контроль размера запроса. Их задача уже: не дать вложенной операции пережить смысловой бюджет вызывающего кода.
Отдельно проверьте границу между клиентом и сервером. HTTP-таймаут клиента может оставить серверный обработчик работающим. Если сервер умеет принимать deadline в заголовке или контексте, согласуйте его с клиентским значением и оставьте запас на ответ. Не копируйте это поведение без документации вашего proxy и сервиса.
Решение готово к эксплуатации, когда тест показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, отмена останавливает вложенное чтение, а логи позволяют связать клиентский отказ с proxy и server trace. Для записи нужен ещё один проверяемый факт: по operation-id можно узнать, был ли первый вызов принят. Если этой проверки нет, успешный HTTP-статус и отсутствие ошибки сети не должны превращаться в универсальный вывод о retry.
https, свойства методов, идемпотентность и значения 408/504; спецификация не выбирает таймауты конкретной библиотеки.