1. Зачем нужны таймауты и где они работают
Когда пишешь сервер впервые, кажется, что запросы приходят быстро, клиенты адекватные, интернет честный, а кот не уронит роутер. Реальность любит другой жанр: «клиент отправляет заголовки по одному байту в секунду», «клиент читает ответ так медленно, будто у него модем 1998 года», «соединение держат открытым просто потому что могут». Таймауты сервера — это способ сказать: «давай либо работаем, либо расходимся без драм».
Есть ещё одна важная причина: ресурсы ограничены. Каждый открытый сокет, каждое зависшее соединение и каждая «подвисшая запись ответа» — это память, файловые дескрипторы и время. Когда таких клиентов много, сервер начинает страдать, даже если ваша бизнес‑логика идеальна. Поэтому таймауты — не «оптимизация», а базовая гигиена живого сервиса.
Кстати, context.Context и дедлайны как идея нужны именно для того, чтобы запросы можно было прекращать, не копя «хвосты» и не выедая ресурсы процессу. Если запрос нельзя остановить, он может создавать очередь и съедать память.
Где именно работают эти таймауты
Важно не просто «знать названия» таймаутов, а понимать, какой участок жизни соединения они ограничивают. Тогда вы перестанете путать ReadHeaderTimeout и ReadTimeout, а IdleTimeout не будете ставить «наугад» как температуру духовки.
Упрощённый жизненный цикл HTTP‑соединения можно представить так:
flowchart TD
A[Клиент подключился] --> B[Шлёт заголовки]
B --> C["Шлёт body (не всегда)"]
C --> D[Сервер обрабатывает]
D --> E[Сервер пишет ответ]
E --> F[Соединение простаивает keep-alive]
Сверху накладываются ограничения:
- ReadHeaderTimeout ограничивает участок B (заголовки).
- ReadTimeout ограничивает B + C (весь входящий запрос целиком, включая body).
- WriteTimeout ограничивает участок E (запись ответа клиенту).
- IdleTimeout ограничивает участок F (простой keep‑alive соединения).
Для закрепления — табличка:
| Поле http.Server | Что ограничивает | Почему это важно |
|---|---|---|
|
время на чтение заголовков запроса | защита от «медленных заголовков» и удержания соединений |
|
время на чтение всего запроса (заголовки + body) | защита от «медленного body» и вечной загрузки |
|
время на запись ответа клиенту | защита от клиентов, которые читают ответ слишком медленно |
|
сколько держим keep-alive соединение без активности | чтобы не хранить тысячи «пустых» соединений |
2. Основные таймауты http.Server
ReadHeaderTimeout: защита от медленных заголовков
Когда клиент начинает запрос, он сначала присылает заголовки: метод, путь, Host, Content-Type, возможно Authorization и так далее. Если заголовки приходят медленно, сервер вынужден держать соединение и ждать, хотя реальной работы ещё не началось. Это типичная атака/злоупотребление класса slowloris (название звучит как милый зверёк, но милое там только название).
ReadHeaderTimeout — это способ сказать: «на заголовки даю N секунд; не успел — до свидания». Заголовки обычно маленькие и должны приходить быстро, поэтому этот таймаут часто делают сравнительно небольшим.
Пример: зададим таймауты константами, чтобы не размазывать «магические числа» по коду:
package main
import "time"
const (
readHeaderTimeout = 5 * time.Second
)
Теперь применим их при создании http.Server:
package main
import (
"net/http"
"time"
)
func newServer(mux http.Handler) *http.Server {
return &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
}
Здесь важная мысль: ReadHeaderTimeout — это не про вашу бизнес‑логику. Это «охранник на входе», который не пускает в здание тех, кто слишком долго мнётся у двери.
ReadTimeout: ограничение на чтение всего запроса
После заголовков у запроса может быть тело (body). Например, в приложении задач это будет POST /api/v1/tasks с JSON. Если клиент будет присылать body медленно, сервер снова будет держать соединение и ждать, прежде чем вообще перейдёт к обработчику по‑настоящему.
ReadTimeout — это общий таймаут на весь входящий запрос. Его удобно воспринимать как «максимально допустимое время на то, чтобы клиент передал нам всё, что хотел передать».
Практический нюанс: чем больше вы разрешаете body (например, загрузка файла), тем более «доброго» ReadTimeout требует сценарий. Но для JSON‑API, где body обычно маленький, ReadTimeout можно держать умеренным.
Пример типовой настройки для небольшого JSON API:
package main
import (
"net/http"
"time"
)
func newServer(mux http.Handler) *http.Server {
return &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
}
}
Заметьте: даже если ваш handler потом будет долго думать, ReadTimeout — это не про «думать». Это про «получить входные данные». Поэтому он работает до того, как сервер уверенно вошёл в вашу бизнес‑логику.
Ещё один важный момент: ReadTimeout не отменяет идею ограничивать размер тела запроса (например, через MaxBytesReader). Просто здесь мы держим фокус на таймаутах сервера как на временных границах, а не на ограничениях по размеру.
WriteTimeout: защита от медленного чтения ответа
Теперь представим обратную проблему: сервер всё посчитал, сформировал ответ… и начинает его писать в сеть. Но клиент читает ответ медленно. Или сеть плохая. Или клиент «завис», но соединение не закрывает. Если сервер будет пытаться писать бесконечно, он снова держит ресурс (соединение), и таких медленных клиентов может стать много.
WriteTimeout — это максимальное время, которое сервер готов тратить на запись ответа. Новички часто удивляются: «Но ведь я просто делаю json.NewEncoder(w).Encode(...) — разве это может зависнуть?» Может. Запись в сеть — это I/O, а I/O любит сюрпризы.
Мини‑пример обработчика, который работает долго (искусственно), чтобы почувствовать смысл таймаута:
package main
import (
"net/http"
"time"
)
func slowHandler(w http.ResponseWriter, r *http.Request) {
time.Sleep(20 * time.Second)
_, _ = w.Write([]byte("ok\n"))
}
Если WriteTimeout меньше 20 секунд, то запись ответа может быть прервана. Это не значит, что WriteTimeout «убивает» вашу бизнес‑логику сразу на Sleep. Он скорее гарантирует: «когда дело дойдёт до записи, у записи есть предел».
Практическая мысль: WriteTimeout выбирают с учётом того, сколько в норме могут длиться ваши запросы. Если у вас есть эндпоинты, которые честно работают 30 секунд (например, тяжёлая генерация отчёта), WriteTimeout в 5 секунд будет ломать нормальный сценарий.
IdleTimeout: сколько держим keep‑alive без активности
HTTP (особенно HTTP/1.1) любит переиспользовать соединение: клиент сделал запрос, получил ответ, но соединение не закрывается, а остаётся idle в ожидании следующего запроса. Это полезно: меньше накладных расходов на установку TCP‑соединения. Но есть обратная сторона: если держать idle‑соединения бесконечно, сервер может накопить огромную «коллекцию молчащих клиентов».
IdleTimeout — это время, сколько сервер готов держать соединение открытым без активности, ожидая следующего запроса. Это не про чтение/запись конкретного запроса, а про паузы между запросами.
Типовая настройка:
package main
import (
"net/http"
"time"
)
func newServer(mux http.Handler) *http.Server {
return &http.Server{
Addr: ":8080",
Handler: mux,
IdleTimeout: 60 * time.Second,
}
}
Почему часто ставят около минуты (или несколько минут)? Потому что это компромисс. Слишком маленький IdleTimeout ухудшит жизнь нормальным клиентам (им придётся чаще переподключаться). Слишком большой — позволит держать много «пустых» соединений.
3. Собираем настройки сервера в одном месте
Сейчас сделаем ровно то, что должен уметь делать разработчик на Go: превратить набор разрозненных полей в понятную, поддерживаемую конфигурацию. Мы не будем разбрасывать 5*time.Second по всему main.go. Мы вынесем политику таймаутов в одно место и соберём http.Server аккуратно.
Сначала объявим константы (или package‑level переменные, но константы здесь читаются проще):
package main
import "time"
const (
readHeaderTimeout = 5 * time.Second
readTimeout = 10 * time.Second
writeTimeout = 15 * time.Second
idleTimeout = 60 * time.Second
)
Теперь создадим сервер одной функцией:
package main
import "net/http"
func newServer(addr string, mux http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
}
Обратите внимание на «мелочь», которая на деле делает код взрослее: addr передаётся параметром. Даже если вы пока всегда используете ":8080", такой стиль облегчает жизнь, когда вы захотите поднять тестовый сервер на другом порту.
Дальше в main вы подключаете mux и запускаете сервер. Здесь — только центральная часть:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok\n"))
})
srv := newServer(":8080", mux)
log.Printf("listening on %s", srv.Addr)
log.Fatal(srv.ListenAndServe())
}
Да, graceful shutdown‑каркас обычно чуть сложнее (там появляются signal.NotifyContext, Shutdown и фильтрация http.ErrServerClosed), но здесь важно другое: таймауты задаются при создании http.Server и работают во время обычной жизни сервера.
4. Как выбирать значения таймаутов
Хочется честный рецепт: «поставь 3 секунды сюда, 7 туда, и живи счастливо». Но значения зависят от характера запросов, сети, клиентов и того, чего вы боитесь больше: «зависаний» или «ложных обрывов».
Поэтому стратегия практичная: сначала выбираем разумные дефолты для небольшого JSON API, затем наблюдаем поведение в окружениях, похожих на прод, и только потом корректируем.
Для маленьких запросов заголовки почти всегда приходят быстро, поэтому ReadHeaderTimeout обычно делают небольшим. Для чтения JSON‑body часто достаточно нескольких секунд или десятка секунд. Для записи ответа WriteTimeout часто чуть больше, потому что запись зависит от клиента. Для IdleTimeout часто берут десятки секунд или минуты, чтобы keep‑alive работал, но «вечно» никто не жил.
Полезный принцип: таймауты должны читаться как политика. Когда человек открывает ваш код, он должен сразу видеть: «сервер ждёт заголовки 5 секунд, запрос целиком 10 секунд, ответ пишет 15 секунд, keep‑alive держит минуту». Если вместо этого он видит числа «13, 17, 19», разбросанные по проекту, — это не конфигурация, а квест.
5. Типичные ошибки
Ошибка №1: оставить все таймауты нулевыми «потому что и так работает».
Нулевое значение для большинства таймаутов означает «без ограничений». На тестах это выглядит нормально: всё быстро, все клиенты хорошие. В реальности вы получаете сервер, который может держать соединения бесконечно, и проблема проявится не как красивый баг, а как «почему у нас всё умерло в пятницу вечером».
Ошибка №2: путать shutdown‑таймаут и server timeouts.
Таймаут в Server.Shutdown(ctx) ограничивает время, которое вы готовы ждать корректного завершения при остановке процесса. ReadTimeout/WriteTimeout/IdleTimeout работают во время обычной жизни сервера. Попытка заменить одно другим приводит к странным эффектам: вы вроде «поставили таймаут», но сервер продолжает виснуть на медленных клиентах.
Ошибка №3: поставить слишком маленький WriteTimeout и потом удивляться «обрывам».
Если ваш handler иногда честно работает 10–20 секунд, а WriteTimeout поставлен в 3 секунды, клиент будет получать оборванные ответы. Это особенно неприятно, потому что вы начинаете искать баг в JSON, в кодеке, в бизнес‑логике — а виноват просто слишком строгий таймаут.
Ошибка №4: поставить огромный IdleTimeout , чтобы «реже переподключались», и получить кладбище idle‑соединений.
Keep‑alive полезен, но не должен превращаться в бесконечный «чатик» на уровне TCP. Если IdleTimeout очень большой, сервер долго держит множество соединений, которые уже никому не нужны. Это тихая утечка ресурсов: медленная, скучная и потому особенно опасная.
Ошибка №5: размазать числа по коду и потерять контроль над политикой времени.
Когда в одном файле ReadTimeout: 7*time.Second, в другом WriteTimeout: 12*time.Second, а где-то ещё IdleTimeout: time.Minute + 15*time.Second, то настройка превращается в набор случайностей. Через месяц вы уже не помните, почему числа такие. Гораздо проще держать таймауты рядом, в константах, чтобы политика была видна сразу и менялась осознанно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ