2 lines
15 KiB
JSON
2 lines
15 KiB
JSON
{"index":9,"slug":"editorial-2027-10-practice-long-form-interview","title":"HTTP-таймауты: как разложить общий deadline на измеримые фазы","excerpt":"Запрос может завершиться таймаутом до ответа сервера или повторить запись после неясного результата. Разбираем deadline, фазы HTTP и проверку retry без догадок.","contentHtml":"<p>Запрос к API иногда отвечает за 900 мс, а клиент прекращает ждать через 800 мс. Пользователь видит ошибку, хотя сервер мог завершить операцию. Хуже случай с записью: клиент повторяет POST, не зная, успел ли первый запрос попасть в обработчик. Цена ошибки — потерянный результат, двойное изменение состояния и лишняя нагрузка на upstream.</p><p>Симптом не говорит, где потрачено время. Один общий timeout смешивает DNS, TCP, TLS, отправку тела, ожидание первого байта и чтение ответа. Повторная попытка получает новый полный бюджет и скрывает исходную причину. Тезис статьи прост: задайте один абсолютный deadline для операции, разложите его на наблюдаемые фазы и разрешайте retry только после проверки семантики метода.</p><h2>Deadline — граница операции</h2><p>Общий deadline отвечает на вопрос: до какого момента результат имеет смысл для вызывающего кода. Фазовый timeout отвечает на другой вопрос: сколько можно ждать конкретный переход. Если дать DNS, TCP, TLS и чтению по 800 мс каждому, запрос может жить несколько секунд. Если ограничить connect 50 мс, холодное разрешение имени превратит нормальный запрос в ложный отказ.</p><p>Храните абсолютный момент окончания или остаток бюджета. Перед каждой фазой вычисляйте <code>remaining = deadline - now</code>. Если остатка нет, завершайте операцию до следующего сетевого вызова. Новый независимый таймер после истечения deadline нарушает контракт вызывающего кода.</p><h2>Механизм: что измерять</h2><p>DNS показывает время разрешения имени. TCP connect — установление соединения. TLS handshake — защищённый обмен и проверку сертификата. Request write — передачу заголовков и тела. TTFB показывает ожидание первого байта, а read — получение остального ответа. Эти интервалы отвечают на разные вопросы, поэтому один счётчик total duration не заменяет их.</p><div class=\"table-scroll\"><table><caption>Фазы HTTP-запроса и сигналы</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>Разрешение имени</td><td>Медленный первый запрос</td><td>Проверить resolver, кэш и лимит DNS</td></tr><tr><td>TCP connect</td><td>Открытие соединения</td><td>Отказ до TLS</td><td>Проверить маршрут, pool и connect timeout</td></tr><tr><td>TLS handshake</td><td>Защищённый обмен</td><td>Соединение есть, ответа нет</td><td>Проверить цепочку, crypto и TLS budget</td></tr><tr><td>Request write</td><td>Отправка тела</td><td>Зависает upload</td><td>Проверить размер, backpressure и write timeout</td></tr><tr><td>TTFB/read</td><td>Ответ и тело</td><td>Клиент ждёт сервер</td><td>Сопоставить server time, read timeout и payload</td></tr></tbody></table></div><h2>Симптом → причина → проверка → действие</h2><table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Timeout без статуса HTTP</td><td>Истёк deadline до первого байта или клиент закрыл сокет</td><td>Сравнить timestamps DNS, connect, TLS, TTFB и server log</td><td>Разделить фазовые интервалы и не называть отказ HTTP-статусом</td></tr><tr><td>Первый запрос медленный, следующие быстрые</td><td>Cold DNS, TCP или TLS скрыты pool</td><td>Сравнить cold и warm соединения</td><td>Измерять установление соединения отдельно от чтения</td></tr><tr><td>POST повторяет изменение</td><td>Retry запущен без доказанной идемпотентности</td><td>Проверить метод, operation-id и результат первого вызова</td><td>Добавить ключ дедупликации или сначала читать статус операции</td></tr><tr><td>Каждый retry ждёт полный timeout</td><td>Попытки не делят общий deadline</td><td>Посчитать абсолютный deadline всего запроса</td><td>Передавать остаток бюджета, а не запускать новый полный таймер</td></tr><tr><td>В логах только total duration</td><td>Клиент не экспортирует фазы</td><td>Проверить API instrumentation и события pool</td><td>Фиксировать известную границу и не выдавать реконструкцию за факт</td></tr></tbody></table><h2>Повторная попытка не лечит неизвестный результат</h2><p>Timeout не доказывает, что сервер ничего не сделал. Для GET повтор обычно допустим, если ресурс читает актуальное состояние и клиент принимает возможное изменение данных между попытками. Для POST, PUT и DELETE решение зависит от семантики операции. Нужны идемпотентность, ключ дедупликации или endpoint для проверки статуса. Одного сетевого флага retry недостаточно.</p><p>Три попытки по 800 мс дают до 2,4 секунды ожидания без учёта задержек и одновременно утроенную нагрузку на upstream. Общий deadline должен охватывать все попытки. Если остатка мало, новая попытка не должна начинать работу. Для записи безопаснее вернуть состояние «результат неизвестен» и проверить operation-id, чем отправлять запрос повторно наугад.</p><figure><img src=\"/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg\" alt=\"Карта HTTP-запроса: общий deadline проходит через DNS, TCP, TLS и чтение; каждая фаза получает только оставшееся время и собственный сигнал.\" loading=\"lazy\" /><figcaption>Общий бюджет проходит через фазы запроса. Диагноз должен ссылаться на сигнал конкретной фазы, а не только на слово timeout.</figcaption></figure><h2>Учебный пример без сети</h2><p>Функция ниже получает общий бюджет и длительности уже измеренных фаз. Она возвращает сумму, остаток и причину. Это учебный пример: он не открывает socket, не моделирует отмену запроса и не заменяет таймеры HTTP-клиента. Его граница — проверка арифметического инварианта отдельно от сети.</p><pre><code>import { allocateTimeoutBudget } from './upgrade-2027-10.mjs';\n\nconst within = allocateTimeoutBudget({\n totalMs: 800,\n dnsMs: 42,\n tlsMs: 88,\n requestMs: 510,\n});\nconst late = allocateTimeoutBudget({\n totalMs: 800,\n dnsMs: 120,\n tlsMs: 210,\n requestMs: 560,\n});\n\nconsole.log(within.ok, within.remainingMs);\nconsole.log(late.ok, late.reason, late.remainingMs);\n// true 160\n// false deadline-exceeded 0</code></pre><p>В первом вызове потрачено 640 мс, поэтому остаётся 160 мс. Во втором сумма равна 890 мс: функция возвращает <code>deadline-exceeded</code> и нулевой остаток. Реальный адаптер должен дополнительно отменять вложенные DNS, socket и чтение, иначе завершение функции не остановит работу сокета.</p><h2>Порядок проверки</h2><ol><li>Запишите абсолютный deadline и момент, когда клиент прекратил ожидание. Не начинайте диагностику с увеличения числа.</li><li>Добавьте DNS, TCP, TLS, write, TTFB и read в одну структурированную запись. Одинаковые имена фаз важнее названий полей конкретной библиотеки.</li><li>Проверьте, передаётся ли остаток бюджета на следующую фазу и между retry. Новый таймер не должен начинаться после истечения deadline.</li><li>Сопоставьте фазу отказа с HTTP-методом. Для записи проверьте идемпотентность, operation-id и способ узнать результат первого вызова.</li><li>Разделите connect, read и общий deadline в конфигурации. У каждого параметра должен быть владелец и тест на граничное значение.</li><li>Сравните cold и warm соединение. Pool может скрыть TLS в одном случае и показать его в другом.</li><li>Повторите проверку через реальный proxy и downstream. Локальная функция не доказывает поведение всей цепочки.</li></ol><h2>Что измерение не доказывает</h2><p>Большой TTFB не доказывает, что сервер медленный: время могло уйти на proxy, очередь или повторный TLS. Малый TTFB не гарантирует быструю загрузку всего тела. Клиентский timeout не равен HTTP-статусу: клиент может закрыть соединение, а сервер продолжить обработчик. Метрики фаз нужно связывать с серверным временем и размером ответа, но не подменять ими друг друга.</p><p>Если библиотека отдаёт только total duration, реконструированные интервалы нельзя называть фактами. Выберите instrumented adapter или события connection pool. До этого фиксируйте только известную границу: «клиент прекратил ждать через N мс». Неполное, но честное измерение лучше точного на вид вымысла.</p><h2>Ограничения и критерий готовности</h2><p>Подход не учитывает jitter часов, системные очереди, HTTP/2 multiplexing и детали конкретного proxy. Учебные числа не являются нормативными значениями для вашей сети. RFC описывает протокол, но не выбирает таймауты, retry policy или параметры клиента. Для боевой системы нужен адаптер с отменой всех вложенных операций и отдельная проверка безопасности повторов.</p><p>Материал готов к применению, если интеграционный тест для выбранного endpoint показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, а просроченная операция останавливает вложенное чтение. Для записи дополнительно нужен проверяемый operation-id: после timeout система может узнать, был ли первый вызов принят. Если хотя бы одна граница не наблюдается, результат следует считать недоказанным, а не успешным.</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> — официальная спецификация семантики HTTP; не задаёт таймауты конкретной библиотеки.</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; не определяет retry и application deadline.</li></ul>"}
|