Содержание урока · 15 разделов
- HTTP-клиент в Go: запрос как значение
- Анатомия ответа: StatusCode, Status, Header, Body
- http.NewRequest: собери запрос, потом отправляй
- Заголовки: Header, канонизация и Basic Auth
- http.Client: таймаут — не опция, а страховка
- Ошибки клиента: до сети, в сети и по времени
- POST и JSON: тело запроса — это io.Reader
- NewRequestWithContext: отмена и дедлайны из контекста
- httptest: тестовый сервер для настоящего клиента
- Шпаргалка HTTP-клиента
- Правила гигиены HTTP-клиента
- Что дальше: клиент становится привычкой
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
HTTP-клиент в Go: запрос как значение#
Весь HTTP-клиент Go живёт в одном пакете — net/http, и это тот же пакет, из которого в Gin и net/http-серверах берут хендлеры и ResponseWriter; клиент и сервер встречаются на середине пути и говорят одним языком терминов. У запроса «http клиент go» есть два этажа API, и выбирать между ними нужно осознанно. Нижний этаж — шорткаты: http.Get и http.Post, вызовы в одну строку, которые используют http.DefaultClient — глобальный клиент без единой настройки. Верхний этаж — собственный экземпляр http.Client: структура с полями Timeout, Transport, CheckRedirect, которую вы создаёте один раз и переиспользуете. Для запросы из golang в учебных примерах хватает http.Get, в продакшене почти всегда нужен настроенный Client — и главная причина одна: таймаут.
Ключевая идея пакета, которая делает его приятным для тестов и понимания: запрос — это ЗНАЧЕНИЕ. Функция http.NewRequest собирает структуру *http.Request — метод, URL, заголовки, тело — и не трогает сеть вовсе. Сеть вступает в игру только когда вы передаёте запрос клиенту: client.Do(req). Между сборкой и отправкой лежит целая жизнь: можно дописать заголовок, поменять метод, распечатать запрос на экран, проверить URL — всё без единого пакета по сети. Именно поэтому весь интерактивный слой этого урока честен: песочница не имеет доступа к сети, но сборка и разбор запросов — чистая работа с данными, и она выполняется детерминированно.
Дорожная карта урока: сначала http.Get и анатомия ответа (StatusCode, Status, Header, Body), затем http.NewRequest и заголовки, затем http.Client с таймаутом и ошибки, затем POST с JSON-телом — прямое продолжение двадцать первого урока, и в финале http.NewRequestWithContext из девятого и httptest как мост к тестированию. Связка с прошлым уроком самая прямая в курсе: тело ответа — это io.Reader, и живой жанр «запросил API — декодируй ответ» собирается из двух уроков подряд: resp.Body отдаёт байты, json.Decoder или json.Unmarshal превращает их в структуры.
Честная рамка урока. Резервный движок песочницы net/http не знает: import "net/http" даёт отказ «пакет "net/http" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)» — тот же список пакетов, что и у encoding/json в двадцать первом уроке. Основной движок (yaegi) пакет net/http исполняет, и все раннеры урока нативные: http.NewRequest, заголовки, тело, req.Write и ошибки парсинга выводятся детерминированно — каждый прогнан трижды. Но сеть недоступна даже нативному движку: ни один запрос в песочнице не долетит до сервера, и попытка доставки (client.Do, http.Get) вернёт ошибку соединения. Поэтому ранимые примеры урока — сборка запросов и разбор их полей, а фактические отправки разобраны как код без исполнения. Один раннер — «запрос глазами текста» — собран вообще без net/http и исполняется обоими движками. Доставку прогоняйте в настоящем Go: любой пример урока там работает как есть.
http.Get базово: ответ, тело, закрытие
GOpackage main
import (
"fmt"
"io"
"net/http"
)
func main() {
// Шорткат: GET-запрос через http.DefaultClient, без настроек.
resp, err := http.Get("https://api.example.com/v1/items")
if err != nil {
// Сеть не дотянулась: адрес, DNS, соединение, таймаут.
fmt.Println("ошибка:", err)
return
}
// Закрытие — сразу после проверки err: соединение вернётся в пул.
defer resp.Body.Close()
// Тело — io.Reader: читаем целиком одним вызовом.
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("ошибка чтения:", err)
return
}
fmt.Println(resp.StatusCode, resp.Status, len(body))
fmt.Println(string(body)[:20]) // первые байты ответа
}Три правила читаются по порядку: err проверяется раньше resp (при ошибке resp — nil, и resp.Body без проверки — паника), defer resp.Body.Close() ставится сразу после первой проверки, тело читается io.ReadAll — тем же стилем, что и os.ReadFile в уроке про файлы. string(body) даёт текст ответа, а json.Unmarshal(body, &v) — структуру из двадцать первого урока. В песочнице этот код не исполняется: доставку он делает в настоящем Go.
Анатомия ответа: StatusCode, Status, Header, Body#
*http.Response — структура, и четыре её поля вы будете открывать каждый раз. StatusCode — int, числовой код: 200, 201, 404, 500; сравнивайте именно его — resp.StatusCode == http.StatusOK читается лучше магической двойки, а константы http.StatusCreated и http.StatusNotFound делают ветвления самодокументированными. Status — строка целиком, как её прислал сервер: "200 OK", "404 Not Found"; она для логов и отладки, не для сравнений. Header — карта http.Header с заголовками ответа: resp.Header.Get("Content-Type") вернёт тип содержимого, и пустая строка здесь означает «заголовка нет» — удобный контракт, не требующий проверки на присутствие; по заголовкам читается и режим обслуживания: Date показывает время ответа сервера, а Retry-After после кода 503 подсказывает, через сколько секунд стоит повторить запрос — вплоть до таймаута вашего клиента.
Body — поле типа io.Reader, и это сердце связки с прошлыми уроками. Тело приходит потоком: его читают io.ReadAll целиком (быстро, весь ответ в памяти), io.Copy прямо в файл или стандартный вывод, или передают json.NewDecoder(resp.Body) — и тогда декодер из двадцать первого урока жуёт ответ прямо из сети, без промежуточного среза байт. Для больших ответов это единственный разумный путь; для маленьких JSON-API io.ReadAll с последующим Unmarshal проще и отладочнее. Один нюанс, о котором спотыкаются: тело ОДНОРАЗОВОЕ. io.Reader вычитывается до конца — и второй ReadAll вернёт пустоту, EOF. Нужен повторный проход — прочитайте один раз в []byte и разбирайте байты сколько угодно.
И правило, которое стоит выучить с первого дня: resp.Body.Close() обязателен. Не закрытое тело держит соединение занятым — пул соединений внутри Client конечен, и утечка тел по одной на запрос выливается в «программа зависла на сотом запросе». Пара defer resp.Body.Close() сразу после проверки err — рефлекс, который должен срабатывать раньше, чем вы начинаете писать тело функции. Да, даже для resp с кодом 404: тело ответа с ошибкой — тоже тело, его тоже надо закрыть.
HTTP-запрос глазами текста
Этот раннер исполняют ОБА движка: в коде только fmt и strings, ни одного сетевого пакета. Разбор показывает, из чего состоит запрос: метод, путь, строка запроса, версия протокола. Точно так же раскладывает URL на части http.NewRequest — только делает это внутри структуры url.URL, а не руками. Эксперимент: добавьте в raw третий параметр &tag=net и убедитесь, что цикл подхватит пару без изменений кода.
Запустите код, чтобы увидеть вывод.
http.NewRequest: собери запрос, потом отправляй#
req, err := http.NewRequest(method, url, body) — конструктор запроса-значения. Три аргумента, знакомые по любому HTTP-учебнику: метод строкой ("GET", "POST", "PUT", "DELETE"), адрес строкой и тело в виде io.Reader (nil для GET). Возврат — пара из *http.Request и error, и ошибка здесь возникает ещё до всякой сети: NewRequest парсит URL функцией url.Parse, и «://bad» даёт ошибку parse "://bad": missing protocol scheme сразу на сборке. Это хороший фильтр глупостей: битый адрес вскрывается в том же месте, где вы его написали, а не где-то глубине клиентского кода.
Поля собранного запроса читаются как обычная структура: req.Method — метод, req.URL — указатель на url.URL с полями Scheme, Host, Path, RawQuery; req.Proto — версия протокола, которую клиент планирует говорить ("HTTP/1.1"); req.Body — io.Reader тела (nil у пустого GET), req.ContentLength — размер тела в байтах, вычисленный для буферизованных тел автоматически. Строку запроса ?q=go&page=2 разбирает req.URL.Query() — карта url.Values, и req.URL.Query().Get("q") возвращает значение параметра уже раскодированным. Соберёте запрос — рассмотрите его — и только потом отдавайте клиенту: та самая жизнь между сборкой и отправкой.
Пара практических привычек. Первая: пустая строка метода — не ошибка; NewRequest с method "" вернёт GET — так и написано в документации, пустота означает GET. Вторая: NewRequest не склеит base и path за вас — "https://api.example.com" + "/v1/items" вы собираете сами, и лишний слэш в стыке ляжет в URL как есть. Третья: запрос-значение можно печатать — а лучше один раз в жизни прогнать его через req.Write и увидеть провод, что мы и сделаем в раннере POST ниже: запрос на экране перестаёт быть магией.
Собираем GET-запрос: Method, URL и Query
Раннер нативный: резервный движок net/http не умеет (import "net/http" даёт отказ с точным списком доступных пакетов), а основной движок (yaegi) исполняет сборку запроса честно — сеть при этом не трогается вовсе. Вывод: метод и версия, хост и путь, сырая строка запроса и раскодированное значение q. Обратите внимание на последнюю строку: у GET без тела req.Body — nil, ContentLength — 0. Эксперимент: передайте в NewRequest метод "" — вернётся GET, пустота означает GET.
Запустите код, чтобы увидеть вывод.
Заголовки: Header, канонизация и Basic Auth#
req.Header — это map[string][]string, карта «ключ → список значений», и с ней есть четыре приёма. Set(key, value) ставит значение, ЗАМЕНЯЯ прежнее. Add(key, value) дописывает ещё одно значение к тому же ключу — так живут повторяемые заголовки вроде Set-Cookie или X-Request-Id. Get(key) возвращает первое значение (пустую строку для отсутствующего), а Values(key) — весь список. Нюанс, который экономит часы: ключи канонизируются. Set("content-type", ...) ложит значение в ключ "Content-Type", и Get("Content-Type") его найдёт — канонизация (MIME-стиль «Первые Буквы Слов») происходит при записи и при чтении, поэтому регистр в вашем коде значения не имеет. Но карта под капотом — с каноническими ключами, и если вы полезете в req.Header напрямую как в map, мимо канонизации, несоответствие регистра станет багом.
Свои заголовки — половина жизни клиента: Content-Type для тела (application/json из прошлого урока), Authorization для токенов, X-Request-Id для трассировки, User-Agent для самоименования клиента. Для последнего классического случая в пакете есть готовый приём: req.SetBasicAuth(username, password) собирает заголовок Authorization со схемой Basic — пара «user:pass», закодированная base64. Вы видели base64 в двадцать первом уроке как судьбу []byte в JSON; здесь тот же кодек работает на авторизацию, и req.Header.Get("Authorization") покажет строку вида Basic aXZhbjpzM2NyZXQ=. Полезно понимать: Basic Auth — не шифрование, просто конверт; в настоящих системах поверх него обязателен HTTPS.
Заголовки на ответе читаются тем же API: resp.Header.Get("Content-Type") подскажет, JSON ли пришёл и с каким charset; resp.Header.Get("X-Request-Id") вернёт идентификатор трассировки, если сервер его прислал. И не забывайте: у запроса, который собрал NewRequest, заголовков по умолчанию нет вообще — Host и User-Agent клиент допишет при отправке (вы увидите User-Agent: Go-http-client/1.1 в проводе раннера), а всё остальное — ваша забота.
Заголовки: Set, Add, канонизация и Basic Auth
Раннер нативный: сборка и разбор заголовков — чистая работа с картой, поэтому вывод детерминирован на основном движке (yaegi); резервный движок net/http не знает. Три наблюдения: content-type в нижнем регистре найдётся через Get("Content-Type") — канонизация честная; Values вернула оба значения X-Request-Id; Authorization — это base64 от «ivan:s3cret», тот же кодек, что кодирует []byte в JSON двадцать первого урока. Эксперимент: вызовите Set для X-Request-Id после двух Add — Values вернёт одно значение: Set заменил список.
Запустите код, чтобы увидеть вывод.
Проверьте себя: таймаут по умолчанию
Вы вызвали http.Get(url) — то есть пошли через http.DefaultClient. Какой таймаут у этого запроса?
http.Client: таймаут — не опция, а страховка#
http.Client — не один запрос, а конфигурация для многих: метода создания клиента «один на приложение» стоит держаться — клиент внутри управляет пулом соединений, и десятки клиентов плодят десятки пулов. Поля: Timeout — единый бюджет на ВЕСЬ путь запроса: установление соединения, TLS-рукопожатие, отправка заголовков и тела, ожидание ответа, чтение тела. Не «время до ответа», а всё вместе — это делает поле идеальным для здравого смысла: &http.Client{Timeout: 5 * time.Second} гарантирует, что никакой запрос не проживёт дольше пяти секунд, что бы ни случилось на другом конце. Transport — транспорт, где настраиваются тонкости (свои пулы, прокси, TLS); CheckRedirect — политика переходов по редиректам; у меня в уроке они в прографе, а Timeout — герой.
Опасность нуля стоит проговорить отдельным предложением: в Go нулевое значение структуры Client — валидный клиент, и http.DefaultClient им является. Валидный, но без бюджета: Timeout == 0 читается кодом как «ограничений нет». В учебном примере — терпимо; в сервисе, который отвечает за своих пользователей, — инцидент на первом зависшем upstream-сервере. Отсюда правило: клиент без явного Timeout — незакрытая дыра в ревью. Дедлайн, общий на весь запрос, и есть это поле; когда нужны более тонкие срезы времени (отдельный дедлайн только на ожидание ответа, отдельный на чтение тела), подмешивают контекст — о нём ниже.
client.Get / client.Post / client.Head — те же шорткаты, что у пакета, только на вашем экземпляре: client.Get(url) — это client.Do(req) с заранее собранным GET. А client.Do(req) — главная дверь: принимает запрос, собранный NewRequest (или NewRequestWithContext), выполняет его, возвращает *http.Response и error. Именно пара «собрал запрос — отправил клиентом» и есть верхний этаж API, а http.Get из начала урока — просто client.Do по умолчанию без настроек.
Клиент с таймаутом и распознавание net.Error
GOpackage main
import (
"errors"
"fmt"
"net"
"net/http"
"time"
)
func main() {
// Таймаут — единый бюджет на весь запрос: соединение, TLS,
// отправка, ожидание ответа и чтение тела.
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.example.com/v1/slow")
if err != nil {
// Таймаут распознаётся интерфейсом net.Error.
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
fmt.Println("сервер не ответил за 5 секунд")
return
}
fmt.Println("ошибка:", err)
return
}
defer resp.Body.Close()
fmt.Println(resp.StatusCode)
}Паттерн распознавания таймаута: errors.As (пятнадцатый урок — обёртывание ошибок) достаёт net.Error из цепочки, метод Timeout() отделяет именно превышение бюджета от refused-соединения или битого DNS. Почему не сравнение строк: текст ошибки зависит от слоя, который её породил, а интерфейс net.Error — контракт. Client из этого кода — один на приложение: тот же экземпляр обслужит все запросы программы. В песочнице пример не исполняется — доставку делает настоящий Go; сборка клиента и проверка net.Error при этом чистые операции над структурами.
Ошибки клиента: до сети, в сети и по времени#
Ошибки клиента удобно раскладывать на три слоя по моменту возникновения. Первый слой — ДО сети: ошибки сборки. http.NewRequest парсит URL и проверяет метод, поэтому parse "://bad": missing protocol scheme и net/http: invalid method "GET POST" выходят немедленно, на том же стеке, где вы написали вызов; resp при этом nil. Второй слой — В СЕТИ: соединение не установилось (dial tcp: connection refused — сервер не слушает порт), DNS не разрешил имя, TLS не согласился, соединение разорвано посреди ответа. Эти ошибки приходят парой с resp == nil, и в них же попадает таймаут — это тоже ошибка доставки, только с меткой времени.
Третий слой — в ОТВЕТЕ: сеть отработала, но код не 2xx. Ключевая точка честности: 404 и 500 для клиента — НЕ ошибки API net/http. err будет nil, resp — полноценный ответ, StatusCode — 404. Клиент не решает за вас, что «хорошо»: проверка resp.StatusCode — ваша забота, и типовой клиент ветвится по диапазонам: 2xx — успех, 4xx — моя вина в запросе, 5xx — сервер сломался. Отсюда же — привычка не доверять телу без проверки кода: JSON с описанием ошибки пришлит и на 200, и на 500, а парсить его вслепую — самообман.
Распознавание вида ошибки — по интерфейсам и обёрткам, не по строкам: errors.As(err, &ne) с net.Error даёт ne.Timeout() для превышения бюджета; соединение с отказом узнаётся по типам net.OpError и syscall.Errno глубже в цепочке, но на практике почти всегда достаточно двух веток — «таймаут» и «всё остальное с логом». Правило пары (значение, ошибка) из пятнадцатого урока здесь абсолют: сначала err != nil с выходом, потом resp. Код, который обращается к resp.Body до проверки err, компилируется — и падает паникой на первом же отказе сети: resp nil, разыменование nil. Этот порядок прогоняется в квизе ниже наизусть.
Проверьте себя: err и resp
resp, err := http.Get(url) — сеть недоступна, соединение не установилось. Что верно про resp и как правильно продолжить?
Что выйдет из NewRequest с битым URL?
Прочитайте код и выберите, что он выведет:
package main
import (
"fmt"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "://bad", nil)
fmt.Println(req == nil)
fmt.Println(err)
}POST и JSON: тело запроса — это io.Reader#
Третий аргумент NewRequest — io.Reader, и это тот же контракт io.Reader, на котором стоят файлы и resp.Body. Для POST вы готовите байты, оборачиваете их в reader и передаёте: bytes.NewBuffer(b) — из среза байт, bytes.NewBufferString(s) — из строки, strings.NewReader(s) — то же самое чуть легче. ContentLength NewRequest посчитает сам для этих «буферных» тел: срез длиной 44 байта даст ContentLength 44 — серверу заранее объявленный размер позволяет корректно прочитать запрос. Для бесконечных или тяжёлых источников (файл на 10 ГБ) ставят req.ContentLength вручную или используют req.GetBody — отдельная история, здесь важно главное: маленькое JSON-тело проходит через bytes.NewBuffer без единой лишней строчки.
И здесь замыкается мост двадцать первого урока: json.Marshal(task) возвращает []byte — их отдаёте bytes.NewBuffer — буфер передаёте NewRequest третьим аргументом — и ставите req.Header.Set("Content-Type", "application/json"). Четыре строки — json.Marshal, bytes.NewBuffer, http.NewRequest, Header.Set — и структура из вашего кода становится телом POST-запроса. На принимающей стороне декодер из того же урока жуёт ответ: json.NewDecoder(resp.Body).Decode(&v). Так выглядит полный цикл «запросы из golang к JSON-API»: Marshal в байты, NewRequest с буфером, client.Do, проверка StatusCode, Decode из Body.
Метод — просто первый аргумент: "POST", "PUT", "PATCH", "DELETE" — NewRequest собирает любой, никаких отдельных функций для каждого метода нет (http.Post и http.PostForm — шорткаты DefaultClient для частного случая). Тело для GET тоже можно передать — протокол это допускает, хотя серверы обычно его игнорируют; стандартная практика: GET с nil. А в раннере ниже мы проделаем финальный трюк: распечатаем собранный запрос через req.Write — на экране появится провод, ровно те байты, которые сервер прочитает из сокета: стартовая строка, Host, User-Agent, Content-Length, Content-Type, пустая строка, тело.
POST с JSON-телом и провод req.Write
Раннер нативный: основной движок (yaegi) исполняет и json.Marshal из двадцать первого урока, и сборку запроса, и req.Write — сеть не трогается, вывод детерминирован; резервный движок не знает ни encoding/json, ни net/http. Смотрите: 44 байта кириллического JSON (len считает байты UTF-8!), ContentLength посчитан автоматически, а провод честно показывает Content-Length: 44, Content-Type и тело. Строки провода кончаются CRLF (\r\n) — в выводе видны \r. Эксперимент: замените метод на "PUT" — стартовая строка обновится, всё остальное останется.
Запустите код, чтобы увидеть вывод.
Соберите POST-запрос
Соберите программу: буфер с телом, http.NewRequest с методом POST, заголовок Content-Type и печать метода с длиной тела. Импорты — по одному на строку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
NewRequestWithContext: отмена и дедлайны из контекста#
Девятый урок курса дал вам контекст — механизм отмены и дедлайнов, протекающий через все слои программы. Клиент net/http встроен в эту систему: req, err := http.NewRequestWithContext(ctx, method, url, body) собирает запрос, привязанный к контексту, и дальше два механизма начинают работать в паре. Первый: если ctx отменён (cancel() или отмена родителя), запрос прерывается немедленно, и client.Do вернёт context.Canceled. Второй: если у контекста есть дедлайн (context.WithTimeout), запрос укладывается в него, иначе — context.DeadlineExceeded. У собранного запроса контекст читается методом req.Context(), а поле req.Request хранит сам контекст внутри структуры.
Чем отличается контекстный дедлайн от клиентского Timeout? Областью ответственности. Client{Timeout: 5 * time.Second} — бюджет одного запроса, проставленный на клиенте навсегда. Контекст — бюджет ЛОГИЧЕСКОЙ ОПЕРАЦИИ, которая может состоять из многих запросов: пользователь закрыл страницу — отменились три параллельных вызова API; операция с общим дедлайном в 10 секунд отдала по 4 секунды на два запроса и сохранила остаток на третий. Контекст протекает от входной точки (запрос пользователя, сообщение очереди) через хендлеры в клиент — и отмена доезжает до сокета. В серверном коде (Gin и net/http) контекст входящего запроса обязан передаваться во все исходящие — иначе отмена клиента не остановит ваши походы наружу.
Практический расклад: для сервисов, где всё равно есть контекст, NewRequestWithContext — дефолтный выбор, а клиентский Timeout — нижняя страховка на случай, когда контекст никто не привязал; вместе они дают и отзывчивость, и гарантированный потолок. Учебный филл ниже собирает обе страховки в один вызов: клиент с Timeout и запрос с context.Background() — минимальный каркас, из которого вырастает настоящий клиент. Замечание про песочницу: NewRequestWithContext исполняется нативным движком детерминированно — привязка контекста это чистая операция со структурой, сеть не нужна.
Соберите защищённый запрос
Заполните пропуски так, чтобы клиент получил таймаут из пакета time, а запрос — фоновый контекст из девятого урока.
httptest: тестовый сервер для настоящего клиента#
Как тестировать клиент, не выходя в интернет? Ответ стандартной библиотеки — пакет net/http/httptest: httptest.NewServer(handler) поднимает НАСТОЯЩИЙ HTTP-сервер на свободном порту localhost, с вашим хендлером внутри, и отдаёт server.URL — готовый адрес для client.Get. Клиент в тесте ходит по HTTP по-настоящему: соединение, запрос, ответ, StatusCode, Body — весь конвейер, только без внешней сети. server.Client() — специально сконфигурированный клиент, знающий про тестовый сервер (тонкости TLS на локальном порту уже решены). Хендлер — обычная функция http.HandlerFunc: печатаете в w то, что «сервер должен вернуть», и клиент ваш разбирает это как продакшен-ответ.
В песочнице этот код не исполняется — сервер это listener, то есть сеть, которой у движков нет; в настоящем Go пример ниже работает как есть, и это самый быстрый способ увидеть полный цикл «клиент — сервер» в одном файле. Важно и то, что тест с httptest проверяет не только happy path: хендлер может вернуть 404, зависнуть на time.Sleep — и ваш клиент упрётся в собственный таймаут, — прислать битый JSON или неожиданный Content-Type — и вы отрабатываете эти ветки против реального HTTP-стека. Вы уже писали таблицные тесты в шестнадцатом уроке — httptest встраивается туда же: таблица случаев, в каждом свой мини-сервер с заготовленным ответом.
httptest — ещё и лучший учитель проводного формата: чтобы заготовить ответ, надо понять, что сервер видит в запросе; чтобы проверить запрос, хендлер читает r.Method, r.URL, r.Header и сравнивает с ожиданием. Это переворачивает урок: вы начинаете видеть обе стороны очереди. Курс про тестирование ещё вернётся к httptest ближе к интеграционным темам, а пока — держите каркас из блока ниже как шаблон первого клиентского теста.
Каркас httptest.NewServer
GOpackage main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
// Тестовый сервер: настоящий слушатель на localhost,
// хендлер — обычная функция.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `{"status": "ok"}`)
}))
defer server.Close()
// Клиент, знающий про тестовый сервер.
resp, err := server.Client().Get(server.URL + "/ping")
if err != nil {
fmt.Println("ошибка:", err)
return
}
defer resp.Body.Close()
fmt.Println(resp.StatusCode)
}В песочнице не исполняется: listener — это сеть, которой у движков нет. В настоящем Go печатает 200: сервер поднялся на localhost, client сходил по server.URL, хендлер ответил JSON-строкой. Порядок правил тот же, что и у внешнего клиента: err первым делом, defer resp.Body.Close() сразу после проверки. Для теста вместо main — _test-функция с проверками.
Шпаргалка HTTP-клиента#
| Инструмент | Что делает | Пример | Нюанс |
|---|---|---|---|
| http.Get(url) | шорткат: GET через DefaultClient | resp, err := http.Get(u) | Timeout == 0 — бюджета нет |
| http.Client | конфигурация для многих запросов | client := &http.Client{...} | создают один на приложение |
| Client.Timeout | единый бюджет на весь запрос | Timeout: 5 * time.Second | 0 — не «без лимита», а отсутствие |
| http.NewRequest | сборка запроса-значения | NewRequest("POST", u, body) | сеть не трогает; пустой метод = GET |
| http.NewRequestWithContext | сборка + привязка контекста | NewRequestWithContext(ctx, ...) | отмена из девятого урока доезжает до сети |
| req.Header.Set / Add | заголовки: замена / дописывание | Set("Content-Type", ...) | ключи канонизируются |
| req.SetBasicAuth | Basic-авторизация | SetBasicAuth("u", "p") | base64 в Authorization |
| StatusCode / Status | код и строка ответа | resp.StatusCode == 200 | 404 для клиента — не ошибка API |
| io.ReadAll(resp.Body) | прочитать тело целиком | body, err := io.ReadAll(b) | тело одноразовое: Reader |
| resp.Body.Close() | вернуть соединение в пул | defer resp.Body.Close() | обязательно, даже на 404 |
| req.Write(&buf) | провод без сети | req.Write(w) | то, что увидит сервер |
| httptest.NewServer | тестовый сервер на localhost | httptest.NewServer(h) | server.URL + server.Close() |
Правила гигиены HTTP-клиента#
- err проверяется первым делом: при ошибке доставки resp — nil, и resp.Body без проверки — паника на первом же отказе сети.
- defer resp.Body.Close() ставится сразу после проверки err — включая ответы 404 и 500: их тело тоже надо закрыть.
- Тело одноразовое: io.Reader вычитывается до конца, второй ReadAll вернёт EOF; нужен повторный проход — держите байты в переменной.
- Клиент всегда с таймаутом: &http.Client{Timeout: 5 * time.Second} — нижняя страховка; Timeout == 0 у DefaultClient — отсутствие бюджета, не свобода.
- Для всего, кроме однострочных скриптов, — http.NewRequest или NewRequestWithContext: запрос-значение можно рассмотреть, дописать заголовок и напечатать до отправки.
- Контекст из девятого урока протекает во все исходящие запросы: отмена пользователя останавливает ваши походы наружу, client.Do вернёт context.Canceled.
- Вид ошибки — по интерфейсам, не по строкам: errors.As(err, &ne) с net.Error и ne.Timeout() отделяет превышение бюджета (таймаут) от отказа соединения; текст ошибки слоям не доверяют.
- Код ответа проверяете вы: 404 и 500 — не ошибки net/http, err при них nil; ветвь resp.StatusCode обязана существовать.
- Заголовки — Set для замены, Add для дописывания, Get для чтения; регистр ключа не важен из-за канонизации, но прямые записи в карту мимо методов — путь к багам.
- Query-параметры собираются url.Values и кодируются Encode(): ключи сортируются, кириллица превращается в %-эскейпы — иначе пробелы и амперсанды сломают URL.
- Реальные запросы — реальный Go: песочница исполняет сборку и разбор детерминированно, доставка требует сети, которой в браузере нет.
Что дальше: клиент становится привычкой#
Соберём урок в одну карту. Шорткаты: http.Get — DefaultClient без настроек, годится для примеров и скриптов. Клиент: &http.Client{Timeout: 5 * time.Second} — один на приложение, единый бюджет на соединение, рукопожатие, отправку, ожидание и чтение; Timeout == 0 — бюджет отсутствует. Запрос-значение: http.NewRequest — метод, URL, тело-Reader, сеть не трогается; NewRequestWithContext привязывает отмену и дедлайны из девятого урока; заголовки — Set/Add/Get с канонизацией, SetBasicAuth для Authorization. Ответ: StatusCode и Status, Header.Get для заголовков, Body — одноразовый io.Reader с обязательным Close. POST с JSON — Marshal из двадцать первого урока в bytes.NewBuffer, Content-Type application/json, ContentLength считается сам. Ошибки — три слоя: сборка (parse-ошибки), доставка (refused, timeout через net.Error), ответ (коды — ваша забота). Провод — req.Write, тестовый сервер — httptest.NewServer.
Двадцать третий урок — время: time.Time, time.Duration, таймеры и тикеры. Связка прямая: сегодня вы умножали 5 на time.Second — клиентский таймаут построен на этом типе, — а следующая остановка разбирает сам Duration и всё, что с ним можно делать, включая таймауты на таймерах и ритм тикеров. Тело ответа — io.Reader, и на его другом конце стояли файлы: прошлый урок про ввод-вывод остаётся фундаментом сети. А если хочется увидеть, чем жюри разбирает JSON-ответы в реальных клиентских хендлерах, перечитайте урок про JSON: Decoder(resp.Body) — самая частая связка в Go-коде. Про контекст и отмену — урок про контекст: там деревья WithCancel и WithTimeout, которые доезжают до сети через NewRequestWithContext.
До встречи на странице курса. Домашняя работа урока — собрать полный клиент для маленького JSON-API: конструируйте запросы NewRequestWithContext, ставьте таймаут, разбирайте коды и тела — и прогоняйте его в настоящем Go, где сеть дышит.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- HTTP-клиент net/http двухэтажен: http.Get — шорткат через http.DefaultClient без настроек, http.Client — настроенный инструмент с таймаутом, который создаётся один на приложение.
- Timeout у клиента — единый бюджет на весь путь запроса: соединение, TLS, отправка, ожидание ответа, чтение тела; &http.Client{Timeout: 5 * time.Second} — минимальная страховка.
- Timeout == 0 (как у DefaultClient) означает не «без ограничений», а отсутствие бюджета — зависший сервер подвесит такой запрос навсегда.
- http.NewRequest собирает запрос-значение и не трогает сеть: битый URL вскрывается parse-ошибкой на сборке, пустой метод означает GET, поля Method/URL/Body читаются как структура.
- http.NewRequestWithContext привязывает контекст: отмена и дедлайны из девятого урока доезжают до сети, client.Do вернёт context.Canceled или context.DeadlineExceeded.
- Заголовки — Set (замена), Add (дописывание), Get (первое значение); ключи канонизируются, SetBasicAuth пишет Authorization: Basic с base64-парой.
- Ответ: StatusCode — int для сравнений, Status — строка для логов, Header.Get для заголовков; 404 и 500 — не ошибки net/http: err при них nil, коды проверяете вы.
- resp.Body — одноразовый io.Reader: io.ReadAll целиком, json.NewDecoder(resp.Body) потоком из двадцать первого урока; defer resp.Body.Close() обязателен даже для ответов с ошибкой.
- POST с JSON — json.Marshal в bytes.NewBuffer, Header.Set("Content-Type", "application/json"), ContentLength считается сам; req.Write показывает провод без сети.
- Ошибки трёх слоёв: сборки (parse/invalid method), доставки (refused, timeout через errors.As + net.Error.Timeout()), ответа (коды — ваша забота); err всегда проверяется раньше resp.
- httptest.NewServer поднимает настоящий сервер на localhost для клиентских тестов; в песочнице сети нет — ранимые примеры исполняются как сборка запросов, доставка — в настоящем Go.
Домашнее задание
Часть первая — сборка: соберите пять запросов одним NewRequest — GET с двумя query-параметрами, POST с Content-Type application/json, PUT, DELETE и запрос с пустым методом; напечатайте для каждого Method, URL.Path, RawQuery и ContentLength и в комментарии объясните, почему у пустого метода выйдет GET. Часть вторая — заголовки: поставьте content-type в нижнем регистре, прочитайте Get("Content-Type"), добавьте два значения X-Request-Id через Add, замените их Set и напечатайте Values до и после — объясните разницу между Add и Set; затем соберите клиент с таймаутом в одну наносекунду и объясните, зачем такой абсурдный бюджет годится для проверки ветки таймаута. Часть третья — POST с JSON: опишите структуру Отправка (Получатель, Текст, Срочность с omitempty), маршальте её из двадцать первого урока, передайте bytes.NewBuffer в NewRequest("POST", ...) и напечатайте len(b) и req.ContentLength — для кириллического текста объясните расхождение букв и байтов. Часть четвёртая — полный клиент в настоящем Go: соберите клиент с Timeout: 3 * time.Second, запрос через NewRequestWithContext(context.Background(), ...), поход к любому публичному API, проверку err, StatusCode, чтение io.ReadAll и defer resp.Body.Close(); затем попробуйте отменить контекст через WithTimeout в 1 наносекунду и объясните, какая ошибка пришла. Комментарии в коде — по-русски.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему клиенту почти всегда нужен Timeout, и почему ноль — это не «без ограничений»?
Потому что Timeout — единый бюджет на весь путь запроса: установление соединения, TLS-рукопожатие, отправку заголовков и тела, ожидание ответа и чтение тела. Нулевое значение time.Duration — валидная конфигурация, которую код читает как «бюджета нет»: зависший upstream или потерянный пакет подвесит запрос навсегда, а вместе с ним горутину, которая его ждёт. http.DefaultClient — как раз такой клиент, поэтому http.Get уместен в примерах, но не в сервисах. Минимальная страховка — &http.Client{Timeout: 5 * time.Second}; более тонкие срезы времени (дедлайн только на ответ, только на тело) делаются контекстом через http.NewRequestWithContext.
Почему defer resp.Body.Close() обязателен, даже если код ответа 404?
Потому что не закрытое тело не возвращает соединение в пул клиента: пул конечен, и утечка тел по одной на запрос приводит к тому, что сотый запрос зависает в ожидании свободного соединения. Ответ с кодом 404 — полноценный ответ: у него есть заголовки и тело (пусть и маленькое), и конвейер требует его дочитать и закрыть. Ставьте defer resp.Body.Close() сразу после первой проверки err — до любых ветвлений по StatusCode, чтобы ни одна ветка не ушла мимо закрытия. А если вы не планируете читать тело вовсе — это тоже не освобождает от Close.
Чем http.NewRequest лучше http.Get, если GET всё равно?
NewRequest отделяет сборку от отправки, и между ними появляется жизнь: можно дописать заголовки (Content-Type, Authorization, X-Request-Id), привязать контекст (NewRequestWithContext), распечатать запрос через req.Write или проверить URL до похода в сеть. http.Get — это client.Do с заранее собранным GET на DefaultClient: ноль настроек, ноль точек для вставки. Практическое правило: скрипт на три строки — http.Get; всё, что живёт дольше коммита, — http.NewRequestWithContext плюс свой клиент с таймаутом. Бонус: NewRequest проверяет URL на сборке, поэтому битый адрес падает рядом со своим местом в коде.
Работает ли net/http в песочнице самоучителя?
Частично, и это честно проговорено в уроке. Резервный движок пакет не знает: import "net/http" даёт отказ «пакет "net/http" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)». Основной движок (yaegi) исполняет net/http честно там, где не нужна сеть: http.NewRequest и NewRequestWithContext собирают запросы, Header и SetBasicAuth работают, req.Write печатает провод, ошибки парсинга URL и метода детерминированы — все ранимые примеры урока прогнаны трижды. Сеть в песочнице нет вовсе: доставка (client.Do, http.Get) не выполняется ни одним движком, поэтому фактические запросы прогоняйте в настоящем Go — каждый пример урока там работает как есть.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией