JavaRush /Курси /Go SELF /Таймаути в HTTP‑клієнті — http.Client.Timeout vs context....

Таймаути в HTTP‑клієнті — http.Client.Timeout vs context.WithTimeout

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

1. Навіщо потрібні таймаути

Коли ви вперше пишете HTTP‑клієнт, усе здається простим: зробили запит — отримали відповідь — і всі задоволені. Але мережа вміє дивувати. Сервер може зависнути, проксі може бути «в настрої», Wi‑Fi — зникнути саме в той момент, коли ви натиснули Enter. І без таймауту ваш код може чекати дуже довго. Іноді — нескінченно довго. Так, це не метафора.

Таймаут — це ваше правило пристойності в мережевому світі: «Я зачекаю, але не вічно». Причому таймаут потрібен не лише для зручності користувача. Він потрібен і для здоров’я вашої програми: якщо запити зависають, вони накопичуються, тримають ресурси — з’єднання, пам’ять, ґорутини — а потім ви дивуєтеся, чому все стало повільним і сумним.

У Go є два популярні рівні, де можна встановити обмеження за часом:

  • загальний таймаут на клієнті: http.Client.Timeout
  • таймаут на конкретний запит: context.WithTimeout

І сьогодні ми навчимося обирати правильний рівень і не влаштовувати «таймаутний вінегрет».

2. Два рівні таймаутів

Коли в проєкті з’являється HTTP‑клієнт, у новачків часто виникає бажання «поставити таймаут десь». Але «десь» — це як «закрутити гайку чимось»: іноді працює, але зазвичай страждають меблі. У Go прийнято мислити шарами: є загальний запобіжник і є таймаут для конкретної операції. Це не два конкуруючі підходи, а два взаємодоповнювальні рівні.

Невелика порівняльна таблиця

Підхід Де задається Що обмежує за часом Коли зручно
http.Client.Timeout
у
http.Client
увесь запит цілком (включно з отриманням відповіді та читанням тіла) як базовий захист, щоб ніколи не чекати вічно
context.WithTimeout
поруч із конкретним викликом конкретну операцію (конкретний запит або крок) коли для різних операцій різні бюджети часу

У context є важлива ідея: контексти утворюють дерево, а скасування або таймаут поширюється вниз до дочірніх операцій. Це як «батьки сказали: все, час додому» — і всі діти теж перестали гуляти.

3. http.Client.Timeout: загальний запобіжник

Якщо пояснити http.Client.Timeout простими словами, це звучить так: «Цей клієнт не чекатиме довше за N часу за жодних обставин». Це дуже вдалий перший крок, бо він захищає ваш код від вічного очікування навіть тоді, коли ви десь забули про контекст.

Приклад: створюємо клієнта з базовим таймаутом

package main

import (
	"net/http"
	"time"
)

func newHTTPClient() *http.Client {
	return &http.Client{
		Timeout: 2 * time.Second,
	}
}

Тут важливо відчути інженерний сенс: ми не намагаємося вгадати ідеальний таймаут для всіх можливих запитів у Всесвіті. Ми ставимо базову страховку, щоб програма не зависала назавжди.

Що саме покриває Client.Timeout

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

Це часто саме те, чого ви хочете від «запобіжника»: або операція вклалася в бюджет, або ні — і ми не зависаємо.

4. context.WithTimeout: таймаут на конкретний запит

context.WithTimeout — це інструмент для точного керування часом там, де виконується конкретна операція. Він особливо корисний, коли для різних дій у вас різні часові бюджети. Наприклад, «перевірити здоров’я сервісу» має бути швидко, а «завантажити звіт» може тривати трохи довше.

Ключовий момент: WithTimeout повертає не лише новий ctx, а й cancel() — і його потрібно викликати завжди. Скасування — це сигнал завершення та звільнення ресурсів, зокрема таймера.

Приклад: таймаут 400 мс на один запит

package main

import (
	"context"
	"net/http"
	"time"
)

func doPing(ctx context.Context, client *http.Client, url string) error {
	ctx, cancel := context.WithTimeout(ctx, 400*time.Millisecond)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}

Зверніть увагу на порядок: спочатку створюємо контекст із таймаутом, одразу ставимо defer cancel(), а потім створюємо запит через http.NewRequestWithContext. Це важливо: контекст має бути «прикріплений» до запиту, інакше таймаут житиме окремо, а запит — окремо. І ви щиро здивуєтеся, чому нічого не скасувалося.

5. Що буде, якщо встановити обидва таймаути

Дуже поширена ситуація в реальному коді: у вас є client.Timeout, і ще ви робите context.WithTimeout на конкретну операцію. Виникає закономірне питання: «Хто переможе?»

Переможе більш ранній дедлайн. Простіше кажучи: спрацює те обмеження, яке закінчується раніше.

Уявіть, що у вас два будильники. Один на 07:00, другий на 07:15. Ви прокинетеся о 07:00, а не чекатимете, поки спрацює другий.

Приклад: клієнт 2 с, запит 300 мс

package main

import (
	"context"
	"net/http"
	"time"
)

func doSomething(ctx context.Context, client *http.Client, url string) error {
	// Таймаут запиту менший, ніж у клієнта:
	ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}

	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}

Якщо сервер відповідає за 800 мс, то спрацює таймаут контексту (300 мс), навіть якщо клієнт готовий чекати 2 с.

6. Як відрізняти таймаут від скасування

Коли таймаут спрацьовує, ви отримуєте помилку. І тут новачок часто вдається до того, що в паніці робить майже кожен: друкує err.Error() і починає порівнювати рядки. Це працює, доки не перестає працювати. Зазвичай — у п’ятницю ввечері.

Зазвичай у Go причини скасування перевіряють через errors.Is:

  • context.DeadlineExceeded — час вийшов
  • context.Canceled — хтось скасував вручну або завершився батьківський контекст

Приклад: класифікація помилок через errors.Is

package main

import (
	"context"
	"errors"
	"fmt"
)

func printReason(err error) {
	switch {
	case errors.Is(err, context.DeadlineExceeded):
		fmt.Println("таймаут запиту") // таймаут запиту
	case errors.Is(err, context.Canceled):
		fmt.Println("запит скасовано") // запит скасовано
	default:
		fmt.Println("інша помилка") // інша помилка
	}
}

Це правильний стиль: ми ухвалюємо рішення за суттю помилки, а не за її текстом.

Додатково: net.Error і Timeout() bool

У мережевому коді інколи трапляється інтерфейс net.Error, у якого є метод Timeout() bool. Це один зі способів зрозуміти, що помилка пов’язана з таймаутом або тимчасовою проблемою.

Це не заміна errors.Is(..., context.DeadlineExceeded), а додатковий інструмент. Він особливо корисний, коли помилка «глибоко всередині» мережі, і ви хочете класифікувати її ширше.

package main

import (
	"fmt"
	"net"
)

func isNetTimeout(err error) bool {
	if err == nil {
		return false
	}
	if nerr, ok := err.(net.Error); ok {
		return nerr.Timeout()
	}
	return false
}

func demo(err error) {
	fmt.Println(isNetTimeout(err)) // true/false
}

У реальному застосунку ви зазвичай комбінуєте підходи: спочатку errors.Is на контекстні причини, а якщо потрібно — додатково перевіряєте net.Error.

7. Вбудовуємо таймаути в клієнт для tasks API

Тепер перетворімо це на частину невеликого застосунку, який ми розвиваємо. Нехай у нас є клієнт, який звертається до API задач: /api/v1/tasks. Ми поки не заглиблюємося в складну обробку JSON‑помилок і статусів — це окремі теми. Наша мета зараз — правильно закласти таймаути в каркас клієнта.

Крок 1: структура клієнта з базовим таймаутом

package main

import (
	"net/http"
	"time"
)

type TaskClient struct {
	BaseURL string
	HTTP    *http.Client
}

func NewTaskClient(baseURL string) *TaskClient {
	return &TaskClient{
		BaseURL: baseURL,
		HTTP: &http.Client{
			Timeout: 2 * time.Second,
		},
	}
}

Тут ми робимо просту річ: у клієнта є базовий захист Timeout: 2 с. Навіть якщо далі хтось забуде про контекст, запит усе одно не зависне назавжди.

Крок 2: метод, який приймає context.Context

Дуже корисна звичка в Go — приймати ctx параметром, а не створювати context.Background() усередині функції. Так ви дозволяєте верхньому рівню керувати часом і скасуванням. Саме для цього й існує context: протягувати дедлайни й скасування через межі API.

package main

import (
	"context"
	"net/http"
)

func (c *TaskClient) Ping(ctx context.Context) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
	if err != nil {
		return err
	}

	resp, err := c.HTTP.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}

Метод поки що нічого не читає з тіла відповіді. Нині нам важливіша не бізнес-логіка, а правильна механіка часу.

Крок 3: внутрішній таймаут на конкретну операцію

Припустимо, за вимогами UX Ping має відповідати швидко. Тоді ми можемо зробити так: у клієнта загальний 2 с, але для Ping300 мс.

package main

import (
	"context"
	"net/http"
	"time"
)

func (c *TaskClient) Ping(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
	if err != nil {
		return err
	}

	resp, err := c.HTTP.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return nil
}

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

8. Як обирати значення таймаутів

Вибір таймауту — це не математика, а інженерний компроміс. Надто малий таймаут перетворює мережу на суцільні збої, надто великий — на «чому воно зависло на 40 секунд». Найпрактичніша стратегія для навчання і для старту проєкту виглядає так: поставити розумний загальний таймаут на клієнті (наприклад, 25 секунд), а для окремих операцій задавати таймаути, виходячи з очікувань UX і характеру даних.

Корисно подумки розділяти операції на «швидкі» і «такі, що можуть тривати довше». Запит до /health і запит списку невеликих завдань — це зазвичай швидкі операції. А от запит звіту, експорту або аналітики може тривати довше. Головне — щоб таймаут був явно заданий, а не залишався «як вийде».

І ще один нюанс: таймаути мають бути вимірюваними й перевірюваними. Коли ви налагоджуватимете цей клієнт, ви подякуєте собі за чіткі бюджети часу, а не за абстрактне «ну інколи воно підвисає».

9. Типові помилки

Помилка № 1: взагалі не ставити таймаути («у мене ж localhost»).
На локальній машині все справді часто літає. Але ви пишете код не «для localhost», а для світу, де мережа — це хаос, а сервер може бути перевантажений. Без таймауту запит може зависнути надовго, а ваша програма виглядатиме так, ніби «зламалася і мовчить».

Помилка № 2: використовувати context.WithTimeout, але забувати cancel().
WithTimeout створює внутрішній таймер і ресурси для скасування. Якщо ви не викликаєте cancel(), ці ресурси можуть жити довше, ніж потрібно. Правильна дисципліна проста: створили ctx, cancel := ... — одразу пишемо defer cancel() (навіть якщо «ніби й так усе завершиться»).

Помилка № 3: створювати context.Background() усередині методу клієнта.
Так ви відрізаєте верхній рівень від керування часом. У результаті main, CLI‑шар або викличний код не зможе скасувати операцію, наприклад за сигналом або за загальним таймаутом сценарію. Значно краще передавати ctx як параметр і працювати з ним.

Помилка № 4: намагатися розпізнавати таймаут порівнянням рядків err.Error().
Текст помилок може змінюватися, обгортатися, доповнюватися. Для логів текст — ок, але для розгалуження логіки використовуйте errors.Is(err, context.DeadlineExceeded) і errors.Is(err, context.Canceled). Це надійніше й по‑Go.

Помилка № 5: ставити одночасно Client.Timeout і WithTimeout, але не розуміти, який реально працює.
У такій ситуації спрацює більш ранній дедлайн. Якщо ви виставили client.Timeout = 2s, а WithTimeout(..., 200ms), то реальний таймаут буде близько 200ms. Якщо це не усвідомлено, ви отримаєте «дивні випадкові таймаути», які насправді не випадкові.

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