8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
||
"index": 9,
|
||
"slug": "editorial-2027-10-practice-long-form-interview",
|
||
"title": "HTTP-таймауты: как разложить общий deadline на измеримые фазы",
|
||
"excerpt": "Запрос может завершиться таймаутом до ответа сервера или повторить запись после неясного результата. Разбираем общий deadline, фазы HTTP и безопасную проверку retry.",
|
||
"contentHtml": "<p>Клиент отправляет запрос с бюджетом 800 мс. Сервер отвечает примерно через 900 мс, поэтому пользователь получает ошибку, хотя обработчик мог успеть изменить данные. Если после этого автоматически повторить POST, операция может выполниться дважды. Цена ошибки — потерянный результат, двойное списание или создание лишней записи, а также дополнительная нагрузка на upstream.</p><p>Главный вопрос не в том, какое число поставить в поле <code>timeout</code>. Нужно понять, как один абсолютный deadline проходит через DNS, установление соединения, TLS, отправку запроса, ожидание первого байта, чтение тела и retry. Ни одна новая фаза или повторная попытка не получает отдельные 800 мс: они используют только остаток общего бюджета.</p><h2>Сначала зафиксируем контракт</h2><p>Deadline — это момент, после которого результат операции больше не подходит вызывающему коду. Таймаут фазы — верхняя граница отдельного перехода. Эти понятия связаны, но не взаимозаменяемы. Если дать DNS, connect, TLS и чтению по 800 мс каждому, цепочка может длиться несколько секунд. Если поставить каждой фазе слишком маленький лимит, рабочий запрос будет отклонён до исчерпания общего бюджета.</p><p>Храните не набор независимых секундомеров, а абсолютный момент окончания, рассчитанный на монотонных часах среды. Перед каждой фазой вычисляйте <code>remaining = deadline - now</code>. При нулевом или отрицательном остатке не начинайте следующий сетевой вызов. Тот же остаток передаётся в retry и в ожидание ответа от endpoint статуса. Настенные часы могут корректироваться синхронизацией, поэтому они не должны быть единственным источником для измерения длительности.</p><pre><code>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</code></pre><p>Это схема владения состоянием: вызывающий код владеет общим deadline, HTTP-адаптер — фазовыми сигналами и отменой, а API записи — способом узнать судьбу операции. Если один слой заводит новый полный таймер, он нарушает контракт верхнего слоя.</p><h2>Какие фазы действительно видны</h2><p>Полный путь HTTPS-запроса можно описать как разрешение имени, TCP-соединение, TLS handshake, запись сообщения и получение ответа. RFC 9110 описывает для схемы <code>https</code> последовательность от разрешения адреса и TCP до TLS и HTTP-запроса; RFC 8446 описывает handshake, после которого стороны получают ключевой материал для прикладных данных. Это протокольная последовательность, а не обещание конкретной библиотеки показать каждую фазу.</p><p>Пул соединений меняет картину. На тёплом соединении DNS, TCP и TLS могли произойти раньше, поэтому текущая операция начинается с записи запроса. В HTTP/2 несколько запросов используют одно соединение, и TLS нельзя честно приписать одному из них. Название измерения должно говорить, что именно наблюдалось: <code>connection_setup_ms</code>, <code>request_write_ms</code>, <code>ttfb_ms</code> или <code>response_read_ms</code>.</p><div class=\"table-scroll\"><table><caption>Фазы, наблюдаемый сигнал и граница вывода</caption><thead><tr><th scope=\"col\">Фаза</th><th scope=\"col\">Сигнал</th><th scope=\"col\">Симптом</th><th scope=\"col\">Что можно заключить</th><th scope=\"col\">Владелец проверки</th></tr></thead><tbody><tr><td>DNS</td><td>Время от lookup до адреса</td><td>Медленный первый запрос</td><td>Задержка возникла при разрешении, если lookup измерен отдельно</td><td>Клиент или resolver</td></tr><tr><td>TCP connect</td><td>Время до установления TCP</td><td>Нет ответа до TLS</td><td>Проблема на маршруте, в пуле или лимите connect; причина требует сетевой проверки</td><td>HTTP-адаптер и сеть</td></tr><tr><td>TLS handshake</td><td>Время до готовности защищённого канала</td><td>Соединение открывается, HTTP не начинается</td><td>Затронута установка TLS или проверка сертификата, если событие записано</td><td>Клиент и владелец TLS</td></tr><tr><td>Request write</td><td>Время передачи заголовков и тела</td><td>Зависает upload</td><td>Нужно проверить размер тела, backpressure и write timeout</td><td>Клиент и proxy</td></tr><tr><td>TTFB</td><td>От конца записи до первого байта</td><td>Клиент ждёт сервер</td><td>В бюджет попали очередь, proxy и обработка; это не доказательство медленного origin</td><td>Proxy и сервис</td></tr><tr><td>Read</td><td>Время от первого байта до полного тела</td><td>Статус уже получен, тело не пришло</td><td>Нужно проверить размер ответа, streaming и скорость downstream</td><td>Клиент и сервис</td></tr></tbody></table></div><p>Если инструмент сообщает только <code>total_duration</code>, нельзя восстановить DNS, TLS или TTFB делением общего числа. Такая реконструкция выглядит точной, но не является измерением. До instrumented-адаптера логируйте только известные границы: время старта, время отмены, наличие HTTP-статуса и размер полученного тела.</p><h2>Как распределить бюджет</h2><p>Распределение фаз — проектное решение, а не таблица из RFC. Сначала измерьте cold и warm соединения, затем выберите резерв для вариативности. Жёсткий лимит connect защищает upstream от зависших попыток, но не должен превращать редкий cold start в массовый отказ. Для долгого ответа важнее отделить ожидание первого байта от чтения тела, иначе команда будет увеличивать read timeout, пытаясь лечить медленную обработку.</p><p>Ниже учебный бюджет в 800 мс. Он показывает арифметику, а не рекомендуемые значения. Сумма потолков равна общему deadline; фактические фазы могут закончиться раньше и оставить место для безопасного действия. У каждой строки должен быть владелец, измерение и тест на границе.</p><div class=\"table-scroll\"><table><caption>Иллюстративный бюджет одной попытки на 800 мс</caption><thead><tr><th scope=\"col\">Участок</th><th scope=\"col\">Плановый предел</th><th scope=\"col\">Зачем выделен</th><th scope=\"col\">Если превышен</th></tr></thead><tbody><tr><td>DNS</td><td>80 мс</td><td>Не держать запрос из-за resolver</td><td>Отменить lookup и проверить кэш/резолвер</td></tr><tr><td>TCP + TLS</td><td>180 мс</td><td>Открыть защищённое соединение</td><td>Проверить сеть, pool и холодное соединение</td></tr><tr><td>Write</td><td>60 мс</td><td>Передать небольшой запрос</td><td>Проверить тело и backpressure</td></tr><tr><td>TTFB</td><td>300 мс</td><td>Дождаться решения upstream</td><td>Сопоставить trace, очередь и server time</td></tr><tr><td>Read</td><td>180 мс</td><td>Получить всё тело</td><td>Проверить streaming и размер payload</td></tr></tbody></table></div><p>Таблица полезна только как видимый контракт. На практике некоторые лимиты не складываются последовательно: тёплый pool пропускает DNS, TCP и TLS, а HTTP/2 делит соединение между запросами. Поэтому конфигурация должна хранить и общий deadline, и фазовые наблюдения, но не выдавать плановый предел за фактическое время.</p><h2>Retry начинается с семантики</h2><p>Таймаут не сообщает, был ли запрос принят сервером. Клиент мог закрыть сокет после отправки тела, а сервер продолжить обработчик. Для чтения это обычно означает повторную проверку состояния. Для записи сначала определите, можно ли повторить действие без второго эффекта.</p><p>RFC 9110 называет идемпотентными безопасные методы, а также PUT и DELETE: повтор одинакового запроса должен иметь тот же задуманный эффект, что и один запрос. Та же спецификация отдельно предупреждает, что клиенту не следует автоматически повторять неидемпотентный метод, если нет способа доказать идемпотентность или узнать, что исходный запрос не применился. Сам метод не делает прикладную операцию безопасной: сервер может отправлять лог или создавать побочный аудит при каждом обращении.</p><p>POST с ключом дедупликации может получить прикладную идемпотентность, но это контракт API, а не свойство HTTP. Ключ должен быть стабильным для одной операции, храниться на сервере достаточно долго и связываться с результатом. Если такого контракта нет, честный ответ после таймаута — <code>unknown-result</code>. Клиент показывает возможность проверить статус, а не угадывает, что запись не произошла.</p><div class=\"table-scroll\"><table><caption>Решение о повторе после неясного результата</caption><thead><tr><th scope=\"col\">Случай</th><th scope=\"col\">Что известно</th><th scope=\"col\">Следующий шаг</th><th scope=\"col\">Риск</th></tr></thead><tbody><tr><td>GET чтения</td><td>Нужно получить состояние ресурса</td><td>Повторить только в оставшемся бюджете или запросить актуальное состояние</td><td>Данные могли измениться между попытками</td></tr><tr><td>PUT/DELETE</td><td>Метод идемпотентен по HTTP-семантике, но побочные эффекты сервера отдельны</td><td>Повторить с тем же deadline после проверки API-контракта</td><td>Конкретный endpoint может иметь дополнительный эффект</td></tr><tr><td>POST с operation-id</td><td>Сервер умеет связать повторы с одной операцией</td><td>Повторить или прочитать статус по тому же идентификатору</td><td>Истёк срок хранения ключа или статус недоступен</td></tr><tr><td>POST без дедупликации</td><td>Неизвестно, применилось ли изменение</td><td>Не повторять вслепую; вернуть unknown-result и дать проверку</td><td>Дублирование записи или списания</td></tr><tr><td>Ответ 503/504</td><td>Сигнал от сервера или gateway, но не доказательство отсутствия записи</td><td>Проверить retry policy и состояние операции</td><td>Повтор может усилить перегрузку</td></tr></tbody></table></div><p>Коды 408 и 504 тоже нельзя превращать в универсальную команду «повторить». 408 означает, что сервер не получил полный запрос за время ожидания; 504 означает, что gateway или proxy не дождался нужного upstream. Оба кода описывают наблюдаемую точку в цепочке, но не устанавливают прикладную судьбу POST. Для этой границы нужны логи сервера, correlation-id и endpoint статуса.</p><h2>Самодостаточная проверка арифметики</h2><p>Перед подключением к реальному клиенту удобно проверить сам инвариант: сумма уже потраченных фаз не должна превысить общий бюджет, а следующий вызов стартует только при положительном остатке. Фрагмент ниже запускается обычным <code>node</code>, не импортирует локальные модули и не притворяется HTTP-адаптером.</p><pre><code>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' }</code></pre><p>Функция считает длительности, которые ей уже передали; она не измеряет сеть. В production-адаптере каждый вызов должен получать <code>remainingMs</code>, а при отмене должен завершаться и его дочерний lookup, socket или stream. Иначе арифметический тест будет зелёным, а сокет продолжит жить после ответа пользователю.</p><h2>Пошаговый runbook</h2><ol><li><strong>Опишите операцию.</strong> Запишите метод, endpoint, размер запроса, ожидаемый результат и последствия повторной записи. Для изменения состояния сразу заведите <code>operation-id</code> или зафиксируйте, почему его нет.</li><li><strong>Зафиксируйте границу.</strong> Выберите общий бюджет и монотонные часы. Запишите момент старта, deadline, момент отмены и результат: HTTP-статус, транспортная ошибка или <code>unknown-result</code>.</li><li><strong>Разметьте фазы.</strong> Соберите DNS, connect, TLS, write, TTFB и read, если клиент их действительно предоставляет. Не называйте реконструкцию измерением.</li><li><strong>Сравните cold и warm.</strong> Выполните тест без готового соединения и повторите его через pool. Отдельно проверьте HTTP/2, если он включён: setup соединения не принадлежит одному запросу.</li><li><strong>Проверьте остаток.</strong> Искусственно замедлите одну фазу и убедитесь, что следующая получает остаток, а не новый полный timeout. На нулевом остатке сетевой вызов не должен начинаться.</li><li><strong>Проверьте retry.</strong> Для GET/PUT/DELETE сопоставьте метод и контракт endpoint. Для POST создайте тест «ответ потерян после записи» и проверьте, что operation-id возвращает тот же результат, а повтор не создаёт второе изменение.</li><li><strong>Сопоставьте стороны.</strong> Сверьте client timestamps с proxy access log и server trace по correlation-id. Если клиент прервался на 800 мс, это не доказывает, что сервер остановился на 800 мс.</li><li><strong>Оформите владельца.</strong> В конфигурации рядом с каждым фазовым лимитом укажите owner, причину значения, тест границы и действие при деградации. Ревью должен менять контракт и проверку вместе, а не только число.</li></ol><h2>Ограничения и критерий готовности</h2><p>Единый deadline не делает любую цепочку быстрой. На результат влияют очереди, планировщик, proxy, размер тела, повторные TLS-соединения, multiplexing и отмена в конкретной библиотеке. Фазовые лимиты не заменяют rate limit, circuit breaker или контроль размера запроса. Их задача уже: не дать вложенной операции пережить смысловой бюджет вызывающего кода.</p><p>Отдельно проверьте границу между клиентом и сервером. HTTP-таймаут клиента может оставить серверный обработчик работающим. Если сервер умеет принимать deadline в заголовке или контексте, согласуйте его с клиентским значением и оставьте запас на ответ. Не копируйте это поведение без документации вашего proxy и сервиса.</p><p>Решение готово к эксплуатации, когда тест показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, отмена останавливает вложенное чтение, а логи позволяют связать клиентский отказ с proxy и server trace. Для записи нужен ещё один проверяемый факт: по operation-id можно узнать, был ли первый вызов принят. Если этой проверки нет, успешный HTTP-статус и отсутствие ошибки сети не должны превращаться в универсальный вывод о retry.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110 — HTTP Semantics</a> — описывает путь для <code>https</code>, свойства методов, идемпотентность и значения 408/504; спецификация не выбирает таймауты конкретной библиотеки.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc8446.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3</a> — описывает TLS 1.3 handshake и момент, когда стороны получают ключевой материал для прикладных данных; спецификация не задаёт application deadline или retry policy.</li></ul><figure><img src=\"/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg\" alt=\"Схема: общий deadline проходит через DNS, TCP, TLS, запись запроса и чтение ответа; каждая фаза использует только оставшийся бюджет.\" loading=\"lazy\" /><figcaption>Диагноз должен опираться на сигнал конкретной фазы и семантику операции, а не только на слово «timeout».</figcaption></figure>"
|
||
}
|