HTTP-клиент в Go
Замер, который объясняет всю тему разом. Двадцать запросов к одному серверу: если дочитывать тело ответа - открылось 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, остальные разбираются в тренажёре.
- Чем опасен http.Get / http.DefaultClient в проде без настройки?A)Он кэширует ответы и может отдать устаревшие данныеB)Он не поддерживает HTTPS без ручной настройки TLSC)Он открывает по новому TCP-соединению на каждый запрос, вообще без пула переиспользованияD)У него нет таймаута — зависший сервер повесит горутину навсегда
показать ответ и разбор
+D)У него нет таймаута — зависший сервер повесит горутину навсегда// разбор: http.DefaultClient (и http.Get) НЕ имеют таймаута по умолчанию: если сервер принял соединение и молчит, запрос висит бесконечно, держа горутину и соединение — под нагрузкой это копится в отказ. В проде заводят свой http.Client{Timeout: ...} или ставят дедлайн через context на запрос. HTTPS работает из коробки, пул keep-alive есть.
- Как ограничить время одного HTTP-запроса, не трогая общий http.Client?A)http.NewRequestWithContext с context.WithTimeout — дедлайн живёт на запросеB)Запустить запрос в отдельной горутине и затем прерывать её по срабатыванию time.AfterC)Поставить time.Sleep перед вызовом, чтобы ограничить ожиданиеD)Указать флаг --timeout при сборке бинарника
показать ответ и разбор
+A)http.NewRequestWithContext с context.WithTimeout — дедлайн живёт на запросе// разбор: Дедлайн на отдельный запрос ставят через контекст: ctx, cancel := context.WithTimeout(...); req, _ := http.NewRequestWithContext(ctx, ...). По истечении времени запрос отменяется, Do вернёт ошибку. Это не трогает общий клиент и корректно освобождает соединение. Горутину «убить» в Go нельзя (time.After её не остановит), а Sleep лишь задержит старт.
- Почему http.Client создают один раз и переиспользуют, а не заводят на каждый запрос?A)Создание клиента — дорогая операция, требующая обращения к системеB)У каждого клиента свой пул соединений, и новый теряет keep-aliveC)Иначе не получится задать общий таймаут для всех запросов сервисаD)Пакет net/http ограничивает число одновременно живущих клиентов
показать ответ и разбор
+B)У каждого клиента свой пул соединений, и новый теряет keep-alive// разбор: Соединения кэширует Transport внутри клиента. Новый клиент на каждый запрос — это новое TCP-соединение и новое TLS-рукопожатие каждый раз, плюс горы сокетов в состоянии TIME_WAIT под нагрузкой. Один общий клиент переиспользует соединения; он безопасен для конкурентного использования, так что делить его между горутинами можно без опаски.
- Тело ответа закрывают через defer, но не читают до конца. Чем это плохо?A)Соединение не вернётся в пул и будет закрыто вместо переиспользованияB)Часть данных останется в буфере и попадёт в следующий ответC)Close вернёт ошибку о неполном чтении, которую обычно игнорируютD)Сборщик мусора не сможет освободить буфер до конца работы программы
показать ответ и разбор
+A)Соединение не вернётся в пул и будет закрыто вместо переиспользования// разбор: Транспорт переиспользует соединение, только когда предыдущий ответ дочитан до конца: недочитанное соединение он закрывает. При потоке запросов это тихо убивает keep-alive — соединения устанавливаются заново, растёт задержка и число сокетов. Поэтому тело либо читают полностью, либо явно сливают остаток через io.Copy(io.Discard, resp.Body) перед закрытием.
- Чем поле Timeout у http.Client отличается от таймаута через контекст запроса?A)Timeout ограничивает только установку соединения, контекст — всё остальноеB)Timeout действует на все запросы клиента, контекст — на конкретный вызовC)Timeout прерывает запрос молча, а контекст возвращает ошибкуD)Timeout работает только для GET, а контекст — для остальных методов
показать ответ и разбор
+B)Timeout действует на все запросы клиента, контекст — на конкретный вызов// разбор: Поле Timeout — общая планка клиента, покрывающая весь путь запроса вместе с чтением тела. Контекст задаёт срок конкретному вызову и, в отличие от общего лимита, распространяется по цепочке: тот же дедлайн уедет в соседние сервисы и в базу. Обычная практика — держать оба: клиентский предел как страховку и контекстный под конкретную операцию.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.