сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Веб и тесты на Go

HTTP-клиент в Go

HTTP-клиент в проде

Замер, который объясняет всю тему разом. Двадцать запросов к одному серверу: если дочитывать тело ответа - открылось 1 соединение. Если не дочитывать - 19. Если на каждый запрос заводить новый клиент - 20. Спрашивают ровно про это: чем плох http.Get в боевом коде, зачем один клиент и что будет, если тело не дочитать.

Стержень: клиент создают один раз и настраивают, а тело ответа закрывают и дочитывают.

// Формулировки: «чем плох http.Get в проде?», «зачем один клиент?», «что если не дочитать тело?»

Один клиент и пул соединений

Соединения кэширует Transport внутри клиента. Заводишь новый клиент на каждый запрос - получаешь новый пул, а значит новое TCP-соединение и новое TLS-рукопожатие каждый раз. Мои двадцать запросов дали ровно двадцать соединений, тогда как одному клиенту хватило одного.

Общий клиент безопасен для конкурентного использования, поэтому его смело делят между горутинами. У http.DefaultClient проблема одна, зато решающая: я напечатал его Timeout и получил 0s. Таймаута нет вовсе, и зависший запрос будет ждать вечно, удерживая горутину.

// Под высокую нагрузку донастраивают Transport. В структуре MaxIdleConnsPerHost лежит ноль, а ноль означает встроенный дефолт - два простаивающих соединения на хост. При активном общении с одним сервисом этого мало, и соединения будут пересоздаваться.

Transport
хранит пул соединений и настройки транспорта
MaxIdleConnsPerHost
простаивающих соединений на хост, по умолчанию 2

Тело ответа: одна строка ценой в пул

Разница между 1 соединением и 19 в моём замере - ровно одна строка io.Copy(io.Discard, resp.Body) перед закрытием. Транспорт возвращает соединение в пул, только когда предыдущий ответ дочитан до конца; недочитанное он закрывает, потому что не знает, где кончается прошлый ответ и начинается следующий.

На потоке запросов это выглядит как медленная деградация: keep-alive тихо перестаёт работать, растёт задержка на рукопожатиях, копятся сокеты в TIME_WAIT. Никаких ошибок при этом в логах нет.

// Закрывать тело нужно всегда, обычно через defer сразу после проверки ошибки. Дочитывать - если остаток не нужен. Обе строки, а не одна.

keep-alive
переиспользование соединения между запросами
io.Discard
приёмник для слива ненужного остатка тела

Таймауты на двух уровнях и статус

Поле Timeout у клиента - общая планка на весь путь запроса, включая чтение тела. Это страховка от зависания, и она обязана быть всегда.

Контекст задаёт срок конкретному вызову и, в отличие от общей планки, идёт по цепочке дальше: тот же дедлайн уедет в базу и в соседние сервисы. Держат обычно оба. Различать причины полезно: DeadlineExceeded часто транслируют клиенту как 504 и повторяют, а Canceled - это ушёл сам клиент, повторять бессмысленно.

// И про статус. Я постучал в сервер, отдающий 500: ошибка от клиента пришла nil, а StatusCode 500. С точки зрения клиента запрос удался, ошибка возвращается только при сетевом сбое. Проверка кода ответа - твоя работа, никто её за тебя не сделает.

Timeout клиента
общий предел на весь запрос вместе с телом
дедлайн контекста
срок конкретного вызова, идёт по цепочке

Как отвечать: «Чем плох http.Get в проде и что обязательно сделать с ответом?»

У http.Get и DefaultClient нет таймаута вообще - я печатал поле Timeout, там ноль. Значит зависший запрос будет ждать бесконечно и удерживать горутину, а в сервисе это верный путь к исчерпанию ресурсов. Поэтому создаю свой http.Client один раз, задаю Timeout как страховку и дополнительно передаю контекст с дедлайном на конкретный вызов: контекст, в отличие от общей планки, идёт дальше по цепочке в базу и соседние сервисы. Создаю именно один раз и переиспользую, потому что пул соединений живёт в Transport внутри клиента. Я это мерил: двадцать запросов через один клиент открыли одно соединение, а через новый клиент каждый раз - двадцать, то есть двадцать рукопожатий на пустом месте. Клиент безопасен для конкурентного использования, так что делю его между горутинами. С ответом обязательны две вещи, и вторую часто забывают. Закрыть тело через defer сразу после проверки ошибки - и дочитать его до конца. В том же замере двадцать запросов без дочитывания открыли девятнадцать соединений вместо одного: транспорт не возвращает в пул соединение с недочитанным ответом. Если остаток не нужен, сливаю через io.Copy в io.Discard. И проверяю статус: сервер ответил 500, а ошибка от клиента пришла nil - формально запрос удался.

Сильный ответ: названа конкретная проблема DefaultClient с проверенным значением, переиспользование клиента и дочитывание тела подтверждены замером, разложены два уровня таймаутов с объяснением, зачем нужен именно контекст, и добавлена проверка статуса. Деталь про недочитанное тело сразу выдаёт человека, который ловил деградацию в проде.

На чём валят

  • Используют http.Get в проде. Timeout у DefaultClient нулевой, запрос виснет навсегда.
  • Создают новый клиент на каждый запрос и теряют пул: двадцать запросов - двадцать соединений.
  • Закрывают тело, но не дочитывают. У меня это превратило одно соединение в девятнадцать.
  • Не проверяют статус: ответ 500 приходит с nil в ошибке.
  • Полагаются только на Timeout клиента и не пробрасывают дедлайн дальше по цепочке.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.

  1. #go_http_client1 / 5
    Чем опасен http.Get / http.DefaultClient в проде без настройки?
    A)Он кэширует ответы и может отдать устаревшие данные
    B)Он не поддерживает HTTPS без ручной настройки TLS
    C)Он открывает по новому TCP-соединению на каждый запрос, вообще без пула переиспользования
    D)У него нет таймаута — зависший сервер повесит горутину навсегда
    показать ответ и разбор
    +D)У него нет таймаута — зависший сервер повесит горутину навсегда

    // разбор: http.DefaultClient (и http.Get) НЕ имеют таймаута по умолчанию: если сервер принял соединение и молчит, запрос висит бесконечно, держа горутину и соединение — под нагрузкой это копится в отказ. В проде заводят свой http.Client{Timeout: ...} или ставят дедлайн через context на запрос. HTTPS работает из коробки, пул keep-alive есть.

  2. #go_http_client2 / 5
    Как ограничить время одного HTTP-запроса, не трогая общий http.Client?
    A)http.NewRequestWithContext с context.WithTimeout — дедлайн живёт на запросе
    B)Запустить запрос в отдельной горутине и затем прерывать её по срабатыванию time.After
    C)Поставить time.Sleep перед вызовом, чтобы ограничить ожидание
    D)Указать флаг --timeout при сборке бинарника
    показать ответ и разбор
    +A)http.NewRequestWithContext с context.WithTimeout — дедлайн живёт на запросе

    // разбор: Дедлайн на отдельный запрос ставят через контекст: ctx, cancel := context.WithTimeout(...); req, _ := http.NewRequestWithContext(ctx, ...). По истечении времени запрос отменяется, Do вернёт ошибку. Это не трогает общий клиент и корректно освобождает соединение. Горутину «убить» в Go нельзя (time.After её не остановит), а Sleep лишь задержит старт.

  3. #go_http_client3 / 5
    Почему http.Client создают один раз и переиспользуют, а не заводят на каждый запрос?
    A)Создание клиента — дорогая операция, требующая обращения к системе
    B)У каждого клиента свой пул соединений, и новый теряет keep-alive
    C)Иначе не получится задать общий таймаут для всех запросов сервиса
    D)Пакет net/http ограничивает число одновременно живущих клиентов
    показать ответ и разбор
    +B)У каждого клиента свой пул соединений, и новый теряет keep-alive

    // разбор: Соединения кэширует Transport внутри клиента. Новый клиент на каждый запрос — это новое TCP-соединение и новое TLS-рукопожатие каждый раз, плюс горы сокетов в состоянии TIME_WAIT под нагрузкой. Один общий клиент переиспользует соединения; он безопасен для конкурентного использования, так что делить его между горутинами можно без опаски.

  4. #go_http_client4 / 5
    Тело ответа закрывают через defer, но не читают до конца. Чем это плохо?
    A)Соединение не вернётся в пул и будет закрыто вместо переиспользования
    B)Часть данных останется в буфере и попадёт в следующий ответ
    C)Close вернёт ошибку о неполном чтении, которую обычно игнорируют
    D)Сборщик мусора не сможет освободить буфер до конца работы программы
    показать ответ и разбор
    +A)Соединение не вернётся в пул и будет закрыто вместо переиспользования

    // разбор: Транспорт переиспользует соединение, только когда предыдущий ответ дочитан до конца: недочитанное соединение он закрывает. При потоке запросов это тихо убивает keep-alive — соединения устанавливаются заново, растёт задержка и число сокетов. Поэтому тело либо читают полностью, либо явно сливают остаток через io.Copy(io.Discard, resp.Body) перед закрытием.

  5. #go_http_client5 / 5
    Чем поле Timeout у http.Client отличается от таймаута через контекст запроса?
    A)Timeout ограничивает только установку соединения, контекст — всё остальное
    B)Timeout действует на все запросы клиента, контекст — на конкретный вызов
    C)Timeout прерывает запрос молча, а контекст возвращает ошибку
    D)Timeout работает только для GET, а контекст — для остальных методов
    показать ответ и разбор
    +B)Timeout действует на все запросы клиента, контекст — на конкретный вызов

    // разбор: Поле Timeout — общая планка клиента, покрывающая весь путь запроса вместе с чтением тела. Контекст задаёт срок конкретному вызову и, в отличие от общего лимита, распространяется по цепочке: тот же дедлайн уедет в соседние сервисы и в базу. Обычная практика — держать оба: клиентский предел как страховку и контекстный под конкретную операцию.

дальше

Теорию прочитали. Навык ставится повторением

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