JavaRush /Курси /Go SELF /Тайм-аути HTTP‑сервера

Тайм-аути HTTP‑сервера

Go SELF
Рівень 63 , Лекція 2
Відкрита

1. Навіщо потрібні тайм-аути й де вони працюють

Коли ви вперше пишете сервер, здається, що запити приходять швидко, клієнти поводяться коректно, інтернет чесний, а кіт не впустить роутер. Але реальність любить інший жанр: «клієнт надсилає заголовки по байту на секунду», «клієнт читає відповідь так повільно, ніби в нього модем 1998 року», «зʼєднання тримають відкритими просто тому, що можуть». Тайм-аути сервера — це спосіб сказати: «або працюємо, або розходимося без драми».

Є ще одна важлива причина: ресурси обмежені. Кожен відкритий сокет, кожне зависле зʼєднання та кожен «підвислий» запис відповіді — це пам’ять, файлові дескриптори й час. Коли таких клієнтів багато, сервер починає страждати, навіть якщо ваша бізнес-логіка ідеальна. Тому тайм-аути — не «оптимізація», а базова гігієна живого сервісу.

До речі, context.Context і дедлайни як ідея потрібні саме для того, щоб запити можна було припиняти, не накопичуючи «хвости» й не виїдаючи ресурси процесу. Якщо запит не можна зупинити, він може створювати чергу й зʼїдати пам’ять.

Де саме працюють ці тайм-аути

Важливо не просто «знати назви» тайм-аутів, а розуміти, яку ділянку життя зʼєднання вони обмежують. Тоді ви перестанете плутати ReadHeaderTimeout і ReadTimeout, а IdleTimeout не будете ставити навмання, як температуру духовки.

Спрощений життєвий цикл HTTP‑зʼєднання можна уявити так:

flowchart TD
    A[Клієнт підключився] --> B[Надсилає заголовки]
    B --> C["Надсилає тіло (не завжди)"]
    C --> D[Сервер обробляє]
    D --> E[Сервер записує відповідь]
    E --> F[Зʼєднання простоює в режимі keep-alive]

Зверху накладаються обмеження:

  • ReadHeaderTimeout обмежує ділянку B (заголовки).
  • ReadTimeout обмежує B + C (увесь вхідний запит повністю, включно з тілом).
  • WriteTimeout обмежує ділянку E (запис відповіді клієнту).
  • IdleTimeout обмежує ділянку F (простій keep‑alive зʼєднання).

Для закріплення — табличка:

Поле http.Server Що обмежує Чому це важливо
ReadHeaderTimeout
час на читання заголовків запиту захист від «повільних заголовків» і утримання зʼєднань
ReadTimeout
час на читання всього запиту (заголовки + body) захист від повільного тіла запиту та нескінченного надсилання
WriteTimeout
час на запис відповіді клієнту захист від клієнтів, які надто повільно читають відповідь
IdleTimeout
скільки тримаємо 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. Якщо клієнт надсилатиме тіло повільно, сервер знову триматиме зʼєднання й чекатиме, перш ніж узагалі перейде до обробки.

ReadTimeout — це загальний тайм-аут на весь вхідний запит. Його зручно сприймати як максимально допустимий час, за який клієнт має передати нам усе, що хотів передати.

Практичний нюанс: чим більшим є body, наприклад під час завантаження файлу, тим «щедрішого» ReadTimeout потребує сценарій. Але для JSON‑API, де тіло зазвичай невелике, 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‑тіла часто достатньо кількох секунд або десятка секунд. Для запису відповіді 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, то налаштування перетворюється на набір випадковостей. За місяць ви вже не памʼятаєте, чому числа саме такі. Значно простіше тримати тайм-аути поруч, у константах, щоб політика була видна одразу й змінювалася свідомо.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ