{ "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 и обработка; это не доказательство медленного originProxy и сервис
ReadВремя от первого байта до полного телаСтатус уже получен, тело не пришлоНужно проверить размер ответа, streaming и скорость downstreamКлиент и сервис

Если инструмент сообщает только total_duration, нельзя восстановить DNS, TLS или TTFB делением общего числа. Такая реконструкция выглядит точной, но не является измерением. До instrumented-адаптера логируйте только известные границы: время старта, время отмены, наличие HTTP-статуса и размер полученного тела.

Как распределить бюджет

Распределение фаз — проектное решение, а не таблица из RFC. Сначала измерьте cold и warm соединения, затем выберите резерв для вариативности. Жёсткий лимит connect защищает upstream от зависших попыток, но не должен превращать редкий cold start в массовый отказ. Для долгого ответа важнее отделить ожидание первого байта от чтения тела, иначе команда будет увеличивать read timeout, пытаясь лечить медленную обработку.

Ниже учебный бюджет в 800 мс. Он показывает арифметику, а не рекомендуемые значения. Сумма потолков равна общему deadline; фактические фазы могут закончиться раньше и оставить место для безопасного действия. У каждой строки должен быть владелец, измерение и тест на границе.

Иллюстративный бюджет одной попытки на 800 мс
УчастокПлановый пределЗачем выделенЕсли превышен
DNS80 мсНе держать запрос из-за resolverОтменить lookup и проверить кэш/резолвер
TCP + TLS180 мсОткрыть защищённое соединениеПроверить сеть, pool и холодное соединение
Write60 мсПередать небольшой запросПроверить тело и backpressure
TTFB300 мсДождаться решения upstreamСопоставить trace, очередь и server time
Read180 мсПолучить всё телоПроверить streaming и размер payload

Таблица полезна только как видимый контракт. На практике некоторые лимиты не складываются последовательно: тёплый pool пропускает DNS, TCP и TLS, а HTTP/2 делит соединение между запросами. Поэтому конфигурация должна хранить и общий deadline, и фазовые наблюдения, но не выдавать плановый предел за фактическое время.

Retry начинается с семантики

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

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. Иначе арифметический тест будет зелёным, а сокет продолжит жить после ответа пользователю.

Пошаговый runbook

  1. Опишите операцию. Запишите метод, endpoint, размер запроса, ожидаемый результат и последствия повторной записи. Для изменения состояния сразу заведите operation-id или зафиксируйте, почему его нет.
  2. Зафиксируйте границу. Выберите общий бюджет и монотонные часы. Запишите момент старта, deadline, момент отмены и результат: HTTP-статус, транспортная ошибка или unknown-result.
  3. Разметьте фазы. Соберите DNS, connect, TLS, write, TTFB и read, если клиент их действительно предоставляет. Не называйте реконструкцию измерением.
  4. Сравните cold и warm. Выполните тест без готового соединения и повторите его через pool. Отдельно проверьте HTTP/2, если он включён: setup соединения не принадлежит одному запросу.
  5. Проверьте остаток. Искусственно замедлите одну фазу и убедитесь, что следующая получает остаток, а не новый полный timeout. На нулевом остатке сетевой вызов не должен начинаться.
  6. Проверьте retry. Для GET/PUT/DELETE сопоставьте метод и контракт endpoint. Для POST создайте тест «ответ потерян после записи» и проверьте, что operation-id возвращает тот же результат, а повтор не создаёт второе изменение.
  7. Сопоставьте стороны. Сверьте client timestamps с proxy access log и server trace по correlation-id. Если клиент прервался на 800 мс, это не доказывает, что сервер остановился на 800 мс.
  8. Оформите владельца. В конфигурации рядом с каждым фазовым лимитом укажите owner, причину значения, тест границы и действие при деградации. Ревью должен менять контракт и проверку вместе, а не только число.

Ограничения и критерий готовности

Единый deadline не делает любую цепочку быстрой. На результат влияют очереди, планировщик, proxy, размер тела, повторные TLS-соединения, multiplexing и отмена в конкретной библиотеке. Фазовые лимиты не заменяют rate limit, circuit breaker или контроль размера запроса. Их задача уже: не дать вложенной операции пережить смысловой бюджет вызывающего кода.

Отдельно проверьте границу между клиентом и сервером. HTTP-таймаут клиента может оставить серверный обработчик работающим. Если сервер умеет принимать deadline в заголовке или контексте, согласуйте его с клиентским значением и оставьте запас на ответ. Не копируйте это поведение без документации вашего proxy и сервиса.

Решение готово к эксплуатации, когда тест показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, отмена останавливает вложенное чтение, а логи позволяют связать клиентский отказ с proxy и server trace. Для записи нужен ещё один проверяемый факт: по operation-id можно узнать, был ли первый вызов принят. Если этой проверки нет, успешный HTTP-статус и отсутствие ошибки сети не должны превращаться в универсальный вывод о retry.

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

\"Схема:
Диагноз должен опираться на сигнал конкретной фазы и семантику операции, а не только на слово «timeout».
" }