Под http.Handler подходит любой тип с методом ServeHTTP(w, r); http.HandlerFunc превращает обычную функцию в такой тип; http.ServeMux служит хендлером, который по паттерну направляет каждый запрос в другой хендлер. Дальше его обслуживает http.ListenAndServe(addr, mux).
HandlerFunc объявлен как type HandlerFunc func(ResponseWriter, *Request) с методом ServeHTTP, который просто вызывает саму функцию. У голой функции методов нет, поэтому mux.Handle("/x", fn) не компилируется, а mux.HandleFunc делает преобразование за вас.
Когда хендлеру нужны зависимости, например база данных, сделайте его структурой с ServeHTTP или методом структуры, которая их хранит.
http.HandleFunc и ListenAndServe(addr, nil) используют глобальный http.DefaultServeMux, куда любой импортированный пакет может добавить свои маршруты; именно так делает net/http/pprof. Создавайте свой mux через http.NewServeMux().
ListenAndServe не задаёт никаких таймаутов; в продакшене настраивайте http.Server.
GoMiddle
HTTP-сервисы и REST API
net/http, паттерны ServeMux, middleware, таймауты, graceful shutdown
Закрыто вопросов
0 / 23
- 0Повторить
- 1Часто спрашивают
Вопросы
Значения пути берутся из r.PathValue("id"), query-параметры из r.URL.Query().Get("q"), заголовки из r.Header.Get(...), а тело приходит потоком, который декодируют через json.NewDecoder(r.Body). Для ответа задайте Content-Type, затем статус, затем закодируйте тело.
Всё приходит строками: разбирайте и проверяйте значения сами (strconv.Atoi) и отвечайте 400, если они неверны.
Query().Get возвращает "" для отсутствующего ключа; используйте r.URL.Query().Has("q"), когда «нет параметра» и «пустой параметр» значат разное.
Имена заголовков не зависят от регистра: Get приводит ключ к каноническому виду, поэтому x-request-id находит X-Request-Id.
r.Body закрывает сам сервер; хендлер его только читает, один раз.
r.FormValue предназначен для HTML-форм (application/x-www-form-urlencoded), а не для JSON-тела.
Клиент получит 200 OK с Content-Type: text/plain; charset=utf-8. Первый Write отправляет строку статуса и заголовки, поэтому последующие WriteHeader и Header().Set уже ничего не меняют.
Ответ пишется в строгом порядке: заполнить w.Header(), вызвать WriteHeader(status), затем писать тело. Как только записан хотя бы байт тела, заголовки уже ушли в сеть.
Write без предшествующего WriteHeader сам вызывает WriteHeader(200).
Если Content-Type не задан, net/http угадывает его по первым 512 байтам через http.DetectContentType, и JSON определяется как text/plain.
Поздний вызов не возвращает ничего, что можно проверить; сервер лишь пишет в лог http: superfluous response.WriteHeader call.
Исправление: задать заголовок, вызвать w.WriteHeader(http.StatusCreated), затем кодировать тело.
Безопасные методы (GET, HEAD, OPTIONS) не меняют состояние сервера; идемпотентные дают одинаковый эффект, отправлены они один раз или десять: это безопасные плюс PUT и DELETE. POST и PATCH по умолчанию ни то ни другое, поэтому повторять их рискованно.
**PUT заменяет ресурс целиком телом запроса; поля, которые вы не передали, сбрасываются. PATCH меняет только переданные поля (распространённый формат: JSON Merge Patch, RFC 7396).
Успех**: 200 OK с телом, 201 Created плюс заголовок Location после POST, 204 No Content, когда возвращать нечего, 202 Accepted, когда работа поставлена в очередь.
Ошибки клиента: 400 некорректный запрос, 422 корректный по форме, но не прошёл валидацию, 404 ресурса нет, 409 конфликт с текущим состоянием (занятый email, устаревшая версия), 429 превышен лимит запросов.
**401 и 403**: 401 означает «кто вы?»: учётных данных нет или они неверны. 403 означает «я знаю, кто вы, но вам нельзя».
**5xx**: сбой на стороне сервера. Ответ 500 на плохие входные данные провоцирует ретраи и поднимает ваши алерты из-за ошибки клиента.
Теперь паттерн может содержать метод и wildcard-сегменты, например GET /items/{id}, а хендлер читает значение через r.PathValue("id"). Если подходят несколько паттернов, побеждает самый специфичный; два пересекающихся паттерна, из которых ни один не специфичнее, вызывают панику при регистрации.
{id} соответствует одному сегменту пути, {path...} захватывает весь остаток пути, а {$} фиксирует конец: просто / по-прежнему ловит любой путь, а /{$} только корень.
GET обслуживает и HEAD. Если путь совпал, а метод нет, mux сам отвечает 405 Method Not Allowed с заголовком Allow.
«Специфичнее» значит «подходит строгому подмножеству запросов»: /items/new побеждает /items/{id}, а GET /items побеждает /items. /items/{id} и /{kind}/latest оба подходят под /items/latest, поэтому регистрация обоих паникует при старте, а не путает маршруты потом.
Wildcard-сегменты не типизированы ({id} совпадёт и с abc), так что разбирайте и проверяйте значение в хендлере.
Чего по-прежнему нет: групп маршрутов и middleware на отдельный маршрут. Оборачивайте отдельные хендлеры при регистрации или берите chi. GODEBUG=httpmuxgo121=1 возвращает старый матчер на время миграции.
Сервер обслуживает каждое соединение в отдельной горутине, а в HTTP/2 отдельную горутину получает ещё и каждый поток запроса, так что один хендлер выполняется параллельно для множества запросов. Всё, что хендлеры делят между собой, должно быть безопасно для конкурентного доступа.
Поле структуры сервера или map на уровне пакета, которые хендлер изменяет, приводят к гонке данных (data race). Защитите их sync.Mutex, для счётчиков возьмите atomic.Int64 или храните состояние в том, что уже безопасно для конкурентного доступа: пул *sql.DB, Redis.
Два запроса, одновременно пишущие в обычную map, роняют процесс с fatal error: concurrent map writes. Это не паника, поэтому никакой recovery-middleware её не перехватит.
Данные запроса принадлежат запросу: держите их в локальных переменных или в r.Context(), а не в общем поле «текущий пользователь».
Сервер не ограничивает параллелизм. Всплеск медленных запросов превращается в тысячи горутин и обращений к базе; ограничивайте его middleware с семафором, netutil.LimitListener или размером пула.
net/http перехватывает панику в горутине хендлера, пишет её в лог и закрывает это соединение. Горутины, которые запустил сам хендлер, такой защиты не получают.
go test -race с параллельными запросами находит такие гонки раньше продакшена.
Middleware представляет собой функцию func(http.Handler) http.Handler, которая оборачивает следующий хендлер и выполняет код до и после него. Чтобы залогировать статус, нужна обёртка над ResponseWriter, а ID запроса передаётся по цепочке в r.Context().
У ResponseWriter нет геттера статуса, поэтому оберните его и запоминайте то, что получает WriteHeader; если хендлер его не вызвал, статус равен 200.
Дайте обёртке метод Unwrap() http.ResponseWriter, чтобы http.NewResponseController по-прежнему мог добраться до Flush и дедлайнов записи настоящего writer.
Для context.WithValue используйте неэкспортируемый тип ключа, например type ctxKey struct{}, чтобы ни один другой пакет не столкнулся с вашим ключом.
Возвращайте ID в заголовке ответа и передавайте его в исходящих запросах: тогда один ID связывает клиента, ваши логи и сервисы ниже по цепочке.
Доверяйте входящему X-Request-Id, только если его выставил ваш собственный прокси; из интернета это просто пользовательский ввод.
Самая внешняя обёртка выполняется первой на входе и последней на выходе: в a(b(c(mux))) слой a видит запрос раньше b и c. Поэтому каждый слой должен стоять снаружи тех слоёв, которые от него зависят.
Request ID первым: логгеру и всем ответам об ошибках ниже по цепочке ID нужен уже в контексте.
Логирование, затем recovery: recovery превращает панику в 500. Если логирование снаружи, access-лог запишет этот 500; если внутри, то строка лога просто не будет записана.
CORS перед auth: браузер не даёт странице читать ответы чужого origin, пока сервер не разрешит это через Access-Control-Allow-Origin. Перед запросом с JSON-телом или заголовком Authorization браузер шлёт preflight-запрос OPTIONS без учётных данных, и auth, стоящий перед CORS, отвечает на него 401. Mux из Go 1.22 ответил бы на него 405 для маршрута GET, так что CORS должен ответить на preflight и раньше mux.
Auth, затем лимит на пользователя: лимиту на пользователя нужно знать пользователя; лимит по IP можно поставить снаружи, чтобы сбрасывать нагрузку до того, как auth начнёт работу.
Код после next.ServeHTTP выполняется на обратном пути, когда внутренние слои уже отработали.
Middleware отправляет http.Error, хотя хендлер уже записал часть тела, поэтому 500 не заменит зафиксированный 200: сервер пишет superfluous response.WriteHeader call, а internal error дописывается к недописанному JSON.
Хуже самого мусора: ответ при этом завершается штатно, клиент видит обычный 200 с обрезанным телом и может счесть его полным.
Причина в том, что middleware не знает, было ли уже что-то записано; нужна тонкая обёртка над ResponseWriter, которая запоминает первый Write или WriteHeader.
Исправление: если ничего не записано, отвечаем 500, как раньше; если записано, делаем повторную панику с http.ErrAbortHandler: сервер тихо оборвёт соединение, и клиент получит ошибку транспорта, а не ложный успех. Саму http.ErrAbortHandler всегда пробрасывайте дальше.
Ловится тестом: хендлер пишет префикс и паникует, запрос идёт через httptest.NewServer, и проверяем, что чтение на клиенте завершается ошибкой, а не возвращает тело с 200.
io.ReadAll буферизует всё, что прислал клиент, так что одно огромное тело может исчерпать память, а проигнорированная ошибка скрывает оборванную загрузку. Ограничьте тело через http.MaxBytesReader, декодируйте через json.NewDecoder и превращайте каждый вид ошибки в правильный 4xx.
net/http не ограничивает размер тела; Server.MaxHeaderBytes касается только заголовков. При превышении лимита MaxBytesReader возвращает *http.MaxBytesError (отвечайте 413) и велит серверу закрыть соединение, а не дочитывать остаток.
io.EOF от первого Decode означает пустое тело; *json.SyntaxError и *json.UnmarshalTypeError указывают на плохой JSON: отвечайте 400 со смещением в байтах или именем поля.
dec.DisallowUnknownFields() превращает поле с опечаткой в ошибку, а не в молча проигнорированный ключ.
Decode читает одно JSON-значение, поэтому {"a":1} {"b":2} проходит; второй Decode должен вернуть io.EOF.
Используйте поле-указатель вроде *int, когда нужно отличать «поле отсутствует» от «поле равно нулю».
Возвращайте стабильное JSON-тело ошибки в стандартной форме RFC 9457 problem details (application/problem+json) со статусом, который говорит, чья это вина. http.Error пишет text/plain, а с err.Error() ещё и раскрывает внутренности вроде SQL и имён таблиц.
http.Error выставляет Content-Type: text/plain; charset=utf-8 и X-Content-Type-Options: nosniff, так что JSON-клиент не сможет разобрать ошибку.
В problem details есть type, title, status и detail, плюс поля-расширения, например map errors по полям, которую форма может показать рядом с каждым инпутом.
Валидируйте после декодирования, собирайте все ошибочные поля, а не останавливайтесь на первом, и отвечайте 422 (или 400; выберите одно и используйте везде).
Для нескольких полей хватит ручных проверок; go-playground/validator добавляет теги структур вроде validate:"required,email".
Сопоставляйте доменные ошибки со статусами в одной функции (errors.Is(err, ErrNotFound) даёт 404), логируйте причину каждого 5xx вместе с ID запроса и отдавайте клиенту только безопасное сообщение.
Хендлеры занимаются только HTTP, сервисный слой держит бизнес-правила, а репозиторий прячет базу данных. Зависимости направлены внутрь и передаются как поля структур, которые один раз собираются в main, без глобальных переменных пакета, поэтому каждый тест может собрать свою копию.
Хендлер: декодирует, валидирует, вызывает сервис, превращает результат или ошибку в статус. SQL здесь нет.
Сервис: бизнес-правила и транзакции без типов из net/http, поэтому одна и та же логика может обслуживать HTTP, gRPC или CLI.
Репозиторий: SQL и преобразование строк в доменные типы. Его интерфейс маленький, объявлен потребителем (сервисом), а тип для Postgres реализует его неявно.
Хендлеры делают методами структуры, которая хранит зависимости. Глобальный var db *sql.DB скрывает, что нужно хендлеру, зависит от порядка init() и заставляет параллельные тесты гоняться за одним значением.
Держите все маршруты в одном методе routes(), чтобы тест мог прогнать настоящий роутер, middleware и хендлеры через httptest.
Для маленького CRUD-сервиса три слоя могут оказаться лишней церемонией; выделяйте сервис, когда появляется настоящая бизнес-логика.
Моделируйте существительные как ресурсы (/posts, /posts/{id}, /posts/{id}/comments), пусть действие передаёт HTTP-метод, возвращайте честные коды ответа, пагинируйте каждый список и версионируйте API так, чтобы существующие клиенты никогда не ломались.
Ресурсы и методы
GET /posts (список), POST /posts (создание с ответом 201 и Location: /posts/42), GET /posts/{id} (чтение), PATCH /posts/{id} (обновление), DELETE /posts/{id} (удаление с ответом 204).
Никаких глаголов в путях вроде /getPosts. Действие, которое не укладывается в CRUD, становится подресурсом: POST /posts/{id}/publish.
Фильтрация, сортировка и поиск идут в query-строку: GET /posts?author=7&sort=-created_at.
Пагинация
?limit=20&offset=40 прост, но дальние страницы тормозят: база всё равно читает и выбрасывает пропущенные строки, а если строки вставляются, пока пользователь листает, элементы пропускаются или повторяются.
Курсорная (keyset) пагинация продолжает с последней увиденной строки: WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20. Она использует индекс, стабильна при записи и возвращает непрозрачный next_cursor, например эти два значения в base64.
Всегда ограничивайте limit на сервере.
Версионирование и развитие
Аддитивные изменения, например новое поле или эндпоинт, не требуют новой версии, если клиенты игнорируют неизвестные поля.
Ломающее изменение уходит в /v2/... (или в заголовок версии), и обе версии работают, пока клиенты не переедут.
Один формат ошибок везде и временные метки в RFC 3339 в UTC.
Начиная с Go 1.22 стандартный mux умеет методы и параметры пути, так что чистого net/http или chi, если нужны группы маршрутов и middleware на маршрут, хватает большинству сервисов. Gin и echo добавляют свой тип контекста и удобства; fiber быстр на бумаге, но работает на fasthttp, вне экосистемы net/http.
**net/http: ноль зависимостей, стабильный API, с ним работают все middleware и инструменты. Нет групп маршрутов, middleware на отдельный маршрут и биндинга запросов.
chi**: роутер, который остаётся чистым http.Handler: группы маршрутов, r.With(mw), вложенные роутеры и готовый набор middleware. Самый дешёвый шаг вперёд от стандартного mux.
gin / echo: хендлеры принимают *gin.Context или echo.Context, биндинг, валидация и рендеринг встроены. Большие экосистемы, но хендлеры привязаны к фреймворку, а стандартным middleware нужны адаптеры.
fiber: API в стиле Express поверх fasthttp: нет HTTP/2, а middleware для net/http и интеграциям OpenTelemetry нужны адаптеры. fasthttp переиспользует объекты запроса, поэтому значения из fiber.Ctx (*fiber.Ctx в v2) нельзя хранить после возврата из хендлера, если не скопировать их.
Роутинг редко бывает узким местом (им бывают база и кодирование JSON), поэтому совместимость с экосистемой важнее бенчмарков роутеров.
Держите token bucket на каждого клиента (его даёт golang.org/x/time/rate) и проверяйте его в middleware до хендлера. При превышении лимита отвечайте 429 Too Many Requests с заголовком Retry-After и не делайте никакой другой работы.
Token bucket: rate.NewLimiter(10, 20) пополняет 10 токенов в секунду и пропускает всплески до 20; Allow() забирает токен или отказывает без блокировки.
Ключ: ID пользователя после auth или IP клиента. За балансировщиком r.RemoteAddr содержит адрес балансировщика, поэтому берите IP из заголовка прокси, и только от прокси, которому вы доверяете.
Память: map лимитеров растёт с каждым новым IP; удаляйте неактивные записи по таймеру или используйте LRU.
Несколько реплик: лимиты внутри процесса умножаются на число реплик. Для глобального лимита держите счётчики в Redis (скрипт со скользящим окном) или ограничивайте на API-шлюзе.
Фиксированное окно самое дешёвое, но на границе окна пропускает до двойной нагрузки; скользящее окно или token bucket это сглаживают.
http.ListenAndServe создаёт сервер со всеми таймаутами, равными нулю, поэтому клиент, который шлёт заголовки по байту в минуту (Slowloris), держит соединение и горутину вечно. Создайте свой http.Server и задайте как минимум ReadHeaderTimeout.
**ReadHeaderTimeout: пока не прочитаны заголовки запроса. Они маленькие, так что 5–10 секунд безопасно; это и есть защита от Slowloris.
ReadTimeout**: заголовки плюс всё тело. Маленькое значение ломает большие загрузки, поэтому делайте его щедрым, а размер ограничивайте через http.MaxBytesReader.
**WriteTimeout: в HTTP/1.x отсчёт начинается после чтения заголовков, так что фактически это бюджет на весь хендлер, и он убивает длинные стримы, если те не продлевают его на каждую запись.
IdleTimeout**: сколько keep-alive-соединение может ждать следующего запроса. При нуле используется ReadTimeout, а если оба нулевые, простаивающие соединения не закрываются никогда.
Эти таймауты закрывают соединение, но не останавливают хендлер. Задавайте хендлерам бюджет через context.WithTimeout или http.TimeoutHandler, который отвечает 503, когда время вышло; он буферизует ответ, поэтому не ставьте его на стриминговые маршруты.
Поймайте сигнал через signal.NotifyContext, затем вызовите srv.Shutdown(ctx) с новым контекстом с таймаутом: он перестаёт принимать соединения, закрывает простаивающие и ждёт завершения активных запросов. srv.Close(), наоборот, рвёт живые соединения, отчего и обрываются текущие запросы.
ListenAndServe возвращает http.ErrServerClosed, как только начинается Shutdown. Это не ошибка, и main должен продолжать ждать, пока не вернётся сам Shutdown.
Передайте Shutdown новый context.WithTimeout(context.Background(), ...), потому что контекст сигнала уже отменён. Если этот таймаут истёк, вызовите Close, чтобы оборвать остальное.
Shutdown не ждёт захваченные (hijacked) соединения вроде WebSocket и горутины, запущенные вашими хендлерами; отслеживайте их через RegisterOnShutdown и sync.WaitGroup.
Закрывайте пул базы и сбрасывайте телеметрию только после возврата Shutdown, иначе запросы, которые ещё завершаются, упадут на выходе.
В Kubernetes сначала проваливайте readiness-пробу и выждите несколько секунд, чтобы балансировщик перестал слать трафик, и уложите всю последовательность в terminationGracePeriodSeconds (по умолчанию 30 с).
Горутина переживает запрос: r.Context() отменяется, как только хендлер возвращается, поэтому экспорт обычно обрывается с context canceled, а писать в w после этого момента нельзя. Вдобавок паника внутри горутины роняет весь сервер.
ResponseWriter нельзя использовать после возврата из ServeHTTP. Ответ 202 уже ушёл, соединение может обслуживать следующий запрос, и поздний Write гоняется с ним; go test -race это покажет.
net/http перехватывает панику в собственной горутине хендлера, но не в горутинах, которые хендлер запустил; каждой из них нужен свой defer recover().
Скопируйте из r всё нужное до запуска горутины и отвяжите контекст через context.WithoutCancel(r.Context()) (Go 1.21+) с собственным таймаутом: он сохраняет значения вроде trace ID, но не отмену.
Отслеживайте горутину через sync.WaitGroup, чтобы graceful shutdown её дождался. Работу, которая должна пережить рестарт, кладите в очередь или таблицу задач, которую разбирает воркер.
Отвечайте ID задачи и Location: /exports/{id}, который клиент может опрашивать; результат доставляется туда, а не в этот ответ.
Создайте один *http.Client при старте с настроенным Transport и переиспользуйте его везде, потому что пул соединений живёт в Transport. Дедлайн каждому вызову задавайте через контекст запроса, производный от r.Context(), чтобы отключение клиента отменяло и запрос к апстриму.
У http.DefaultClient и http.Get нет таймаута вообще: один зависший апстрим навсегда держит горутины и сокеты.
Новый Client или Transport на каждый вызов выбрасывает пул, и каждый вызов платит за TCP- и TLS-рукопожатие. Оба типа безопасны для конкурентного использования.
Client.Timeout ограничивает весь обмен (соединение, TLS, редиректы и чтение тела) для каждого запроса. Считайте его страховкой; бюджет конкретного вызова задают через context.WithTimeout плюс http.NewRequestWithContext.
Ошибки приходят обёрнутыми в *url.Error: errors.Is(err, context.DeadlineExceeded) значит, что истёк ваш дедлайн (отвечайте 504), а context.Canceled значит, что отключился ваш собственный клиент (вызывающая сторона).
Начинайте с http.DefaultTransport.(*http.Transport).Clone(), чтобы сохранить поддержку прокси, таймауты соединения и HTTP/2.
Пишите события в формате text/event-stream и сбрасывайте каждое через http.NewResponseController(w).Flush(). События, приходящие одним куском, застряли в буфере (в буфере net/http или прокси), а стрим умирает по WriteTimeout, потому что в HTTP/1.x он покрывает весь ответ.
Буферизация
Ответ проходит через bufio.Writer на 4 КБ; без Flush события ждут, пока он заполнится или хендлер вернётся.
Middleware, которое оборачивает ResponseWriter без Unwrap() http.ResponseWriter, прячет Flush, и тогда rc.Flush() возвращает ошибку, совпадающую с http.ErrNotSupported. Gzip-middleware и http.TimeoutHandler тоже буферизуют, поэтому не ставьте их на стриминговые маршруты.
Прокси тоже буферизуют: отправляйте X-Accel-Buffering: no для nginx и задайте FlushInterval: -1 в httputil.ReverseProxy на Go.
Таймауты и время жизни
Продлевайте дедлайн перед каждым событием через rc.SetWriteDeadline(...), а не отключайте WriteTimeout для всего сервера.
Шлите комментарий : ping каждые 15–30 секунд, чтобы простаивающие прокси и балансировщики не рвали соединение, и останавливайтесь, когда срабатывает r.Context().Done(): клиент ушёл.
SSE или WebSocket
SSE даёт односторонний канал от сервера к клиенту поверх обычного HTTP; браузерный EventSource сам переподключается и продолжает с Last-Event-ID.
WebSocket двусторонний и требует библиотеки вроде github.com/coder/websocket или gorilla/websocket; выбирайте его, когда клиент тоже шлёт поток сообщений.
На ветке ошибки функция закрывает тело, не прочитав его. Transport возвращает HTTP/1.1-соединение в пул, только если тело прочитано до EOF и закрыто, поэтому каждый ранний Close выбрасывает соединение, и следующий вызов подключается заново.
Transport не может понять, где в недочитанном потоке начнётся следующий ответ, поэтому ранний Close означает закрытие соединения. В HTTP/2 сбрасывается только поток, а соединение остаётся.
Дочитывайте тело перед закрытием, но с ограничением: io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)). Прочитать 64 КБ дешевле нового рукопожатия; от намного большего тела выгоднее отказаться.
Всё равно читайте тело ошибки: апстрим обычно объясняет причину именно там.
json.Decoder останавливается после одного значения и может оставить байты непрочитанными, так что дочитывайте и на успешной ветке.
Симптомы: рукопожатие на каждый запрос в трейсах, сокеты, копящиеся в TIME_WAIT, и в итоге connect: cannot assign requested address, когда кончаются эфемерные порты.
Увеличьте MaxIdleConnsPerHost: по умолчанию он равен 2, поэтому при параллельной нагрузке почти каждое соединение после использования закрывается, а не возвращается в пул, и каждый запрос платит за новое TCP- и TLS-рукопожатие. Начните с клона http.DefaultTransport и подберите размер пула под устойчивый параллелизм.
Пул HTTP/1.1
Одно соединение несёт один запрос за раз, так что 200 параллельных запросов требуют 200 соединений, а по умолчанию простаивать после них могут только 2 на хост.
MaxIdleConns (100 в DefaultTransport) ограничивает простаивающие соединения ко всем хостам; держите его выше значения на хост.
MaxConnsPerHost ограничивает все соединения к хосту. Сверх лимита запросы ждут свободного соединения, а не падают, поэтому каждому вызову нужен дедлайн в контексте.
Держите IdleConnTimeout меньше собственного keep-alive-таймаута апстрима, иначе вы переиспользуете соединение, которое сервер как раз закрывает, и получаете периодические EOF или connection reset.
HTTP/2
Одно соединение мультиплексирует много параллельных потоков (лимит задаёт сервер, часто 100–250), поэтому настройки пула простаивающих соединений значат гораздо меньше.
Голый &http.Transport{TLSClientConfig: cfg} незаметно остаётся на HTTP/1.1: собственный TLS-конфиг отключает HTTP/2, если ForceAttemptHTTP2 не true, а клон DefaultTransport его сохраняет.
HTTP/2 убирает head-of-line blocking между запросами, но один потерянный TCP-пакет всё равно останавливает все потоки соединения; HTTP/3 поверх QUIC убирает и это, но требует стороннюю библиотеку вроде quic-go; net/http его не поддерживает.
Проверяйте, а не гадайте
httptrace.ClientTrace сообщает Reused в GotConn; считайте его рядом с ConnectStart, чтобы увидеть реальную долю переиспользования.
Отправляйте один Idempotency-Key на операцию, общий для всех её повторов, а сервер пусть хранит результат под этим ключом, чтобы повтор возвращал сохранённый ответ, а не списывал деньги снова. Кроме того, Transport сам повторяет некоторые запросы, когда соединение из пула умирает.
Что net/http повторяет сам
Только на переиспользованном соединении из пула, которое успело простоять без дела, которое упало до прихода ответа: сервер мог закрыть его ровно в момент отправки запроса. Никогда на новом соединении, поэтому зациклиться он не может.
Запрос должен быть перематываемым: без тела или с заданным GetBody. http.NewRequest задаёт его для *bytes.Reader, *bytes.Buffer и *strings.Reader.
И идемпотентным: GET, HEAD, OPTIONS, TRACE или любой метод с заголовком Idempotency-Key или X-Idempotency-Key. Обычный POST повторяется, только если ошибка доказывает, что ничего не было отправлено.
Безопасный цикл ретраев
Новый *http.Request на каждую попытку, один и тот же ключ и дочитанное и закрытое тело предыдущего ответа.
Повторяйте сетевые ошибки, 429, 502, 503 и 504 с экспоненциальной задержкой и джиттером, учитывайте Retry-After и останавливайтесь, когда контекст вызывающего завершён. Никогда не повторяйте 400 или 422: ответ не изменится. 409 из правила про выполняющийся запрос стоит повторить один раз после короткой паузы; 409 из-за настоящего конфликта повторять не нужно.
Сторона сервера
Храните ключ, статус и тело ответа под уникальным ограничением в той же транзакции, что и списание. Дубликат, пришедший, пока первая попытка ещё выполняется, получает 409; ключи истекают примерно через сутки.