HTTP-клиент в Go может получить заголовки без ошибки, а затем зависнуть при чтении тела ответа. http.Client.Timeout задаёт общий бюджет вызова, контекст связывает запрос со временем жизни операции, а настройки Transport ограничивают отдельные этапы обмена. Разберём, как выбрать эти ограничения и обработать ошибки до конца чтения ответа.
Для короткого обращения к API задайте Client.Timeout и передайте контекст операции в http.NewRequestWithContext. Если нужно раньше прерывать подключение или ожидание заголовков, добавьте таймауты Transport. Контекст должен оставаться активным до конца чтения тела. Ниже есть таблица ограничений и полный пример клиента.
Где запрос может задержаться
Перед получением ответа клиенту иногда нужно дождаться свободного соединения, разрешить имя через DNS, установить TCP-соединение и выполнить TLS-рукопожатие. После этого он отправляет запрос, ждёт заголовки и читает тело ответа. При перенаправлении часть пути повторяется. На уже открытом соединении DNS, TCP и TLS обычно не нужны.
У каждого этапа своя причина задержки. Переполненный пул соединений отличается от медленного DNS. Сервер может быстро вернуть заголовки и затем перестать отправлять тело. Поэтому одного сообщения «запрос завершился по таймауту» мало для диагностики.
На схеме клиент обращается по HTTP и получает редирект на HTTPS. Показан путь с новыми соединениями. При переиспользовании соединения DNS, TCP и TLS пропускаются.
Редирект не даёт новые пять секунд при Client.Timeout: 5*time.Second. Если HTTP-запрос и переход заняли секунду, на HTTPS-запрос и чтение его ответа остаётся около четырёх секунд. При более раннем дедлайне контекста времени будет ещё меньше. Таймауты этапов применяются к соответствующим этапам следующего запроса заново, но общий срок остаётся прежним.
Схема предполагает, что политика CheckRedirect разрешает переход. IdleConnTimeout в неё не включён: он ограничивает простой соединения в пуле между запросами. ExpectContinueTimeout нужен только при Expect: 100-continue, его рассмотрим отдельно.
| Ограничение | Что ограничивает | Чего не делает |
|---|---|---|
http.Client.Timeout | Один вызов клиента, включая перенаправления и чтение тела | Не ограничивает суммарное время нескольких вызовов в цикле повторов |
| Дедлайн контекста запроса | Время жизни запроса, включая получение соединения и чтение ответа | Не продлевает более короткий дедлайн родителя |
net.Dialer.Timeout | Установление нового соединения, включая разрешение имени | Не ограничивает обмен по готовому соединению |
TLSHandshakeTimeout | TLS-рукопожатие | Не ограничивает получение HTTP-ответа |
ResponseHeaderTimeout | Ожидание заголовков после полной отправки запроса | Не ограничивает отправку тела запроса и чтение тела ответа |
ExpectContinueTimeout | Ожидание ответа перед отправкой тела при Expect: 100-continue | Не служит общим таймаутом загрузки |
IdleConnTimeout | Простой соединения в пуле | Не прерывает активный запрос |
Описание полей есть в локальной документации: go doc net/http.Client, go doc net/http.Transport и go doc net.Dialer. Дальше речь о стандартном *http.Transport. Если вы подставили собственный RoundTripper, его поведение при отмене тоже нужно проверить.
http.Client.Timeout: весь обмен, включая тело
Для коротких обращений к API удобно начать с общего ограничения:
client := &http.Client{
Timeout: 5 * time.Second,
}
Это фрагмент настройки; нужны импорты net/http и time. Если Transport не указан, клиент использует http.DefaultTransport.
Пять секунд отсчитываются для одного вызова Get, Post или Do. В бюджет входят получение соединения, отправка запроса, перенаправления и чтение тела ответа. Таймер продолжает работать после возврата Do. Это прямо оговорено в документации Client.Timeout.
Например, сервер вернул заголовки за 100 мс. Do завершился без ошибки, но тело пришло только наполовину. Через пять секунд с начала вызова чтение resp.Body будет прервано. Проверка ошибки только после Do пропустит эту ситуацию. Ошибку io.ReadAll, io.Copy или декодера нужно обрабатывать отдельно.
Таймаут не сбрасывается после каждого полученного байта. Если сервер отправляет небольшой фрагмент каждую секунду, пятисекундное ограничение всё равно завершит чтение примерно через пять секунд. Это общий срок обмена, а не допустимая пауза между фрагментами.
Нулевой Client.Timeout означает отсутствие этого общего ограничения. Поэтому http.Get через стандартный DefaultClient сам по себе не задаёт конечный срок всему запросу. При этом у DefaultTransport есть собственные ограничения некоторых этапов. Нельзя делать вывод, что нулевой таймаут клиента отключает вообще все таймауты.
Клиент стоит создать один раз и использовать повторно. Он безопасен для конкурентного использования. Настройки задавайте до начала работы: изменение Timeout общего клиента из обработчика создаёт гонку с другими запросами. Индивидуальный бюджет передавайте через контекст.
Контекст задаёт бюджет конкретной операции
Предположим, обработчик получает две секунды на обращение к сервису каталога. Общий клиент при этом допускает до пяти секунд. В запрос передаём более короткий контекст:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, err = io.Copy(io.Discard, resp.Body)
return err
Фрагмент находится внутри функции, возвращающей error. Здесь parent, endpoint и client переданы вызывающим кодом. Нужны пакеты context, net/http, io и time.
Контекст исходящего запроса управляет получением соединения, отправкой запроса и чтением ответа. Эта граница описана в документации NewRequestWithContext. Для вызова из HTTP-обработчика родителем обычно становится r.Context(): тогда отмена входящего запроса передаётся дальше.
Если до дедлайна родителя осталось 300 мс, WithTimeout(parent, 2*time.Second) не добавит две секунды. Производный контекст завершится вместе с родителем. По той же причине бюджет начинается в момент создания контекста. Подготовка данных между WithTimeout и Do уже расходует его.
Ограничения не складываются. При двух секундах в контексте и пяти секундах у клиента запрос не получает семь секунд. Он остановится при первом применимом ограничении. Это может быть и более короткий таймаут этапа, например ожидания заголовков.
Не отменяйте контекст до чтения тела
Распространённая ошибка возникает в обёртке, которая возвращает *http.Response:
// Плохой пример: контекст отменяется при выходе из функции.
func open(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Вызывающий код получает ответ, а его контекст уже отменён. Чтение сетевого тела может завершиться ошибкой. Если ответ маленький и успел попасть в буфер, проблема может некоторое время оставаться незаметной.
Удобнее прочитать и декодировать ответ внутри функции, владеющей контекстом. Другой вариант: вызывающий код создаёт контекст и вызывает cancel только после работы с телом. В любом случае время жизни контекста должно включать чтение ответа.
Transport: ограничения отдельных этапов
Общий бюджет отвечает на вопрос, сколько операция может ждать всего. Таймауты транспорта помогают раньше остановить конкретный зависший этап.
Настройки обычно удобнее брать из копии стандартного транспорта:
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = (&net.Dialer{
Timeout: 800 * time.Millisecond,
KeepAlive: 30 * time.Second,
}).DialContext
transport.TLSHandshakeTimeout = time.Second
transport.ResponseHeaderTimeout = 1500 * time.Millisecond
transport.ExpectContinueTimeout = time.Second
transport.IdleConnTimeout = 90 * time.Second
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
Нужны импорты net, net/http и time. Пример предполагает, что приложение не заменяло глобальный http.DefaultTransport своим RoundTripper. Значения здесь иллюстрируют соотношение бюджетов. Подбирать их нужно по требованиям к операции и измерениям задержек вашей зависимости.
Clone сохраняет настройки стандартного транспорта, включая использование прокси из окружения и попытку работы по HTTP/2. При создании пустого &http.Transport{} вы получаете другие исходные значения. После настройки используйте транспорт повторно вместе с клиентом. Подробнее о пуле соединений есть в статье о переиспользовании HTTP-соединений.
Dialer.Timeout и TLSHandshakeTimeout
Dialer.Timeout относится к созданию соединения. При обычном вызове DialContext в этот срок входит разрешение имени. Если у имени несколько адресов, время соединения может распределяться между ними. Это не отдельные 800 мс на DNS плюс ещё 800 мс на каждый IP.
После TCP-соединения HTTPS требует TLS-рукопожатия. Для него у транспорта есть TLSHandshakeTimeout. При повторном использовании готового соединения оба этапа пропускаются. Поэтому уменьшение этих таймаутов не поможет, если сервер медленно формирует ответ на уже открытом соединении.
KeepAlive у net.Dialer относится к TCP-проверкам живости соединения. Его не следует принимать за таймаут HTTP-запроса или настройку срока хранения соединения в пуле.
Есть отдельный случай: собственный DialTLSContext. Если он задан, для прямых HTTPS-запросов транспорт считает возвращённое соединение уже прошедшим TLS-рукопожатие. В этом пути TLSHandshakeTimeout игнорируется. Ограничение нужно обеспечить внутри своей функции.
ResponseHeaderTimeout начинается после отправки запроса
ResponseHeaderTimeout ограничивает ожидание заголовков только после того, как запрос отправлен полностью, включая его тело. Для обычного GET это часто близко к ожиданию начала ответа. Для загрузки большого файла разница существенна.
Если отправка тела занимает минуту, ResponseHeaderTimeout: 2*time.Second не превращает всю загрузку в двухсекундную операцию. Нужен общий дедлайн или таймаут клиента. После получения заголовков это поле тоже больше не защищает: сервер может остановиться посреди тела ответа.
В стандартном Transport нет универсального поля WriteTimeout для всего исходящего запроса и поля ReadTimeout для тела ответа. Настройки с такими именами у http.Server относятся к другой стороне обмена. Использовать серверную конфигурацию для настройки клиента по аналогии нельзя.
ExpectContinueTimeout и IdleConnTimeout
ExpectContinueTimeout нужен запросам с заголовком Expect: 100-continue. Клиент сначала отправляет заголовки и некоторое время ждёт реакции сервера перед отправкой тела. По истечении этого ожидания он может продолжить отправку. Это не общий запрет на долгую загрузку. При нулевом значении тело отправляется сразу.
IdleConnTimeout определяет, сколько неиспользуемое соединение хранится в пуле. Поле помогает убрать старые соединения, но не ограничивает активный запрос. Даже значение в одну секунду не остановит чтение ответа, которое продолжается минуту.
Ожидание соединения тоже расходует общий бюджет. Например, MaxConnsPerHost ограничивает число соединений к хосту. При достижении лимита новые подключения ждут. Если забывать закрывать тела ответов, можно получить таймаут ещё до отправки следующего запроса. Увеличение ResponseHeaderTimeout такую проблему не решит.
Полный пример клиента для короткого API-запроса
Обёртка ниже читает тело внутри своей функции. Поэтому defer cancel() не мешает вызывающему коду. Размер ответа ограничен отдельно: таймаут сам по себе не защищает память от большого быстрого ответа.
package apiclient
import (
"context"
"fmt"
"io"
"net"
"net/http"
"time"
)
func NewClient() *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = (&net.Dialer{
Timeout: 800 * time.Millisecond,
KeepAlive: 30 * time.Second,
}).DialContext
tr.TLSHandshakeTimeout = time.Second
tr.ResponseHeaderTimeout = 1500 * time.Millisecond
return &http.Client{
Transport: tr,
Timeout: 5 * time.Second,
}
}
func Fetch(ctx context.Context, client *http.Client, endpoint string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
const maxBody = 1 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if len(body) > maxBody {
return nil, fmt.Errorf("response exceeds %d bytes", maxBody)
}
return body, nil
}
NewClient вызывается один раз при сборке зависимостей приложения. Fetch вызывается для каждого обращения. Лимит в 1 МиБ и двухсекундный бюджет задаёт эта обёртка. Их нужно согласовать с контрактом API.
На успешном пути тело прочитано до конца и закрыто. Это позволяет стандартному транспорту повторно использовать HTTP/1.x-соединение, если остальные условия допускают переиспользование. При ошибочном статусе или слишком большом теле функция закрывает ответ без попытки дочитать произвольный объём данных. Ради ограничения ресурсов здесь допустимо потерять возможность переиспользовать конкретное соединение.
HTTP-статус проверяется отдельно. Ответ 500 или 504 сам по себе не приводит к ненулевой ошибке Do. Это ответ сервера, а не локальное срабатывание таймера клиента.
Как распознавать ошибки
Сравнение текста ошибки хрупко. Для стандартных ошибок используйте errors.Is, для более широкой проверки таймаутов можно проверять интерфейс net.Error.
Следующая функция классифицирует как ошибку Do, так и обёрнутую через %w ошибку чтения:
package apiclient
import (
"context"
"errors"
"net"
)
func ErrorKind(err error) string {
if err == nil {
return "ok"
}
if errors.Is(err, context.Canceled) {
return "canceled"
}
if errors.Is(err, context.DeadlineExceeded) {
return "deadline"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "timeout"
}
return "other"
}
context.Canceled означает отмену, а не истечение срока. Таймаут соединения или заголовков не обязан совпадать с context.DeadlineExceeded. Поэтому одной проверки errors.Is(err, context.DeadlineExceeded) недостаточно для всех сетевых таймаутов.
Обратный вывод тоже неверен: совпадение с DeadlineExceeded не доказывает, что истёк именно контекст, который вы создали. В стандартном клиенте общий Client.Timeout тоже может давать ошибку, совпадающую с этим значением. Для диагностики полезно дополнительно смотреть ctx.Err() и сохранять этап, на котором произошёл сбой.
Даже сочетание этих проверок не всегда позволяет однозначно восстановить причину при почти одновременном срабатывании нескольких ограничений. Для метрик стоит разделить хотя бы отмену вызывающей стороной, таймаут и остальные ошибки. Для поиска задержек пригодится net/http/httptrace: он позволяет наблюдать DNS, подключение, TLS и получение первого байта. Первый байт при этом не означает, что тело ответа уже прочитано.
Потоки, большие файлы и повторные попытки
Для долгого скачивания или получения событий через постоянно открытый HTTP-ответ общий таймаут в пять секунд обычно не подходит. Он оборвёт даже исправный поток, который регулярно передаёт данные. Для таких операций удобно выделить отдельный клиент с Timeout: 0, сохранить ограничения установления соединения и задать время жизни через контекст вызывающего кода.
При этом нулевой общий таймаут оставляет вопрос: сколько можно ждать следующий фрагмент? ResponseHeaderTimeout его не решает. Для потока нужен отдельный механизм контроля прогресса. Например, протокол может требовать heartbeat, а приложение может отменять контекст при слишком долгом отсутствии данных. Такой механизм должен учитывать остановку чтения и корректно завершать свои горутины.
Не стоит бездумно ставить SetReadDeadline на соединение, полученное через собственный dialer. В HTTP/2 одно соединение обслуживает несколько потоков. Дедлайн соединения затронет их все. Даже в HTTP/1.x нужно учитывать переиспользование соединения и сброс ранее выставленных дедлайнов.
Повторные попытки требуют ещё одного уровня бюджета. Client.Timeout применяется заново к каждому вызову Do. Три вызова по пять секунд вместе с паузами могут занять больше пятнадцати секунд. Если всей операции разрешено пять секунд, создайте общий контекст до цикла. Для отдельных попыток при необходимости создавайте дочерние контексты и освобождайте их после каждой попытки.
Сам факт таймаута не делает повтор безопасным. Сервер мог применить изменение, пока клиент ждал ответ. Для изменяющих запросов нужна политика идемпотентности. Подробнее о передаче отмены между вызовами есть в статье про context.
Проверяем таймаут после получения заголовков
Этот тест воспроизводит случай, в котором Do успешен, а чтение тела завершается по таймауту. Он не требует внешнего сервиса:
package apiclient
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestTimeoutWhileReadingBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
// Заголовки отправлены, тело не придёт до отмены запроса.
<-r.Context().Done()
}))
defer srv.Close()
client := &http.Client{Timeout: 500 * time.Millisecond}
resp, err := client.Get(srv.URL)
if err != nil {
t.Fatalf("get headers: %v", err)
}
defer resp.Body.Close()
_, err = io.ReadAll(resp.Body)
if err == nil {
t.Fatal("expected a timeout while reading the body")
}
if got := ErrorKind(err); got != "deadline" && got != "timeout" {
t.Fatalf("expected timeout, got %s: %v", got, err)
}
}
Тест использует ErrorKind из предыдущего раздела. Сервер отправляет заголовки через Flush и ждёт отмены запроса. Клиент получает ответ, затем зависает на чтении тела до своего таймаута. Проверяется тип результата, а не точная длительность в миллисекундах.
В сильно перегруженной среде даже получение локальных заголовков может не уложиться в 500 мс. Тогда тест упадёт раньше, на Get. Для такой среды бюджет можно увеличить, сохранив тот же сценарий. Отдельно стоит проверить дедлайн контекста, отмену родителя и медленные заголовки. Другие приёмы разобраны в статье о тестировании HTTP-клиентов.
Итог
Для короткого API-запроса задайте общий предел через Client.Timeout и передавайте контекст операции. Ограничения транспорта помогут отдельно контролировать подключение, TLS и ожидание заголовков. Эти сроки действуют одновременно, а не складываются.
Перед использованием клиента проверьте весь путь ответа:
- Контекст остаётся активным до конца чтения тела.
- Ошибки чтения обрабатываются отдельно от ошибки
Do. - Тело ответа закрывается на всех путях после успешного
Do. - Повторные попытки вместе с паузами укладываются в общий бюджет.
- Для потоков определены срок жизни и допустимая пауза без данных.
Теги: