Таймаут по умолчанию — это отсутствие таймаута
Незаданный таймаут не «разумный», а чаще всего бесконечный. Разбор того, как один медленный сосед выедает пул соединений и утаскивает за собой сервис.
Коротко. Дефицитный ресурс при отказе — не процессор и не память, а место в пуле. Запрос без таймаута занимает его столько, сколько захочет чужой сервис.
Проверьте прямо сейчас, чему равен таймаут у вашего HTTP-клиента. Не тот, который вы задали, — тот, который стоит, если не задать.
У многих клиентов ответ будет «бесконечность». Не большое число. Бесконечность.
Почему это не абстрактная проблема
Сервис держит пул соединений с базой. Скажем, двадцать. Не «мало» — столько, сколько имеет смысл при восьми ядрах.
Обработчик берёт соединение, делает запрос к внешнему API, потом пишет результат в базу. Соединение занято всё это время: и пока идёт запрос наружу, и пока обрабатывается ответ.
Внешний API начинает отвечать за тридцать секунд вместо двухсот миллисекунд. Не отказывает — просто тормозит.
Двадцать одновременных запросов занимают все двадцать соединений на тридцать секунд каждое. Двадцать первый встаёт в очередь. Дальше очередь растёт быстрее, чем разбирается.
Через минуту сервис не отвечает ни на что. Не только на запросы, которые ходят во внешний API, — вообще ни на что, потому что пул исчерпан. Проверка живости тоже не отвечает, и оркестратор начинает перезапускать здоровые экземпляры.
Внешний сервис при этом жив. Он просто медленный.
Это и есть цена отсутствия таймаута: один медленный сосед забирает весь сервис целиком.
Три ошибки, которые я вижу чаще всего
Один таймаут на всё. «Ставим тридцать секунд везде» — уже лучше бесконечности, но плохо. Тридцать секунд для запроса, который обычно идёт двести миллисекунд, — это не защита, это отложенная катастрофа на тридцать секунд.
Таймаут выводится из наблюдаемого поведения зависимости, а не из круглого числа. Разумная отправная точка — верхний перцентиль плюс запас. Если девяносто девятый перцентиль — четыреста миллисекунд, таймаут в секунду осмыслен, а в тридцать — нет.
Таймаут только на соединение. У HTTP-клиентов их обычно несколько: на установку соединения, на чтение, на весь запрос целиком. Задать первый и забыть про остальные — частая история. Соединение установилось за десять миллисекунд, а потом сервер отдаёт ответ по байту в минуту, и клиент терпеливо ждёт.
Нужен предельный таймаут на всю операцию. Он один по-настоящему ограничивает.
Таймаут больше, чем у вызывающего. Если ваш API обещает ответ за две секунды, а внутри ждёт внешний сервис пять — эти лишние три секунды никому не нужны. Вызывающий уже ушёл по своему таймауту. Вы держите соединение и делаете работу, результат которой некому принять.
Отсюда правило: таймаут вниз по стеку должен быть меньше, чем таймаут сверху, с запасом на вашу собственную работу. Бюджет времени тратится сверху вниз, а не наоборот.
Отдельно про базу
Про HTTP помнят чаще. Про базу — почти никогда.
statement_timeout в PostgreSQL по умолчанию не ограничен. Один запрос
с плохим планом на большой таблице выполняется час, держит соединение
и, если он в транзакции, мешает очистке старых версий строк во всей базе.
Ставить его стоит на уровне роли или соединения, а не в каждом запросе.
И отдельно — idle_in_transaction_session_timeout: транзакция,
открытая приложением и забытая, блокирует очистку так же надёжно.
Бюджет времени, а не таймаут на вызов
Более общий способ смотреть на это, чем отдельные числа для каждой зависимости.
Запрос приходит с бюджетом: сколько всего у нас есть времени. Каждый следующий вызов получает не свой фиксированный таймаут, а остаток бюджета минус запас на собственную работу. Потратили восемьсот миллисекунд из двух секунд — на всё остальное осталось одна двести.
Так работает контекст с крайним сроком в Go и CompletionStage
с таймаутом в Java; в других языках это делается руками, и сделать
стоит.
Почему это лучше фиксированных чисел: сумма отдельных таймаутов почти никогда не сходится с обещанием наверху. Три вызова по секунде каждый — это три секунды, а вызывающий ждёт две. С бюджетом такой арифметики не возникает, потому что он один и убывает.
Побочный эффект, который важнее основного: бюджет заставляет явно ответить на вопрос «сколько мы вообще обещаем». Обычно на него не отвечали никогда.
Что делать с истёкшим таймаутом
Здесь ошибаются на последнем шаге. Таймаут истёк — и это трактуют как «операция не выполнилась».
Неправда. Таймаут значит: мы не знаем, чем всё кончилось. Запрос мог дойти, выполниться и не успеть вернуть ответ. Повтор в этом случае создаёт второй эффект.
Отсюда связка, которую надо держать вместе: таймаут без идемпотентности на изменяющих операциях — это способ получить дубликаты вместо зависаний. Обмен, возможно, выгодный, но его надо совершать осознанно.
Когда можно не думать. Скрипт, запускаемый вручную, вполне может ждать сколько угодно: у него нет пула, нет соседей и нет вызывающего, который ушёл. Всё написанное — про сервис, который обслуживает параллельные запросы.