Skip to content

Миграция с net/http

Уже знакомы с net/http? Это руководство отображает ваш опыт со стандартной библиотекой на HTTPC пункт за пунктом: какие конструкции заменяются механически, где изменилась семантика и какие поведения по умолчанию требуют пересмотра. Все описания поведения сверены с исходным кодом.

Обзор совместимости: построен поверх net/http

HTTPC — не замена net/http, а слой улучшений поверх его транспортного уровня. Движком по-прежнему служат http.Client и http.Transport — переиспользование соединений, согласование HTTP/2, TLS-сессии и прокси-туннели полностью выполняются стандартной библиотекой; HTTPC добавляет поверх проверки безопасности, движок повторов, цепочку middleware и преобразование в Result (подробнее — в FAQ «Как HTTPC соотносится с net/http?»).

Что не меняется при миграции:

  • Поведение транспортного уровня — переиспользование пула соединений, согласование HTTP/2 и возобновление TLS-сессий идентичны стандартной библиотеке, характеристики производительности не деградируют;
  • Типы используются напрямуюhttp.Cookie, tls.Config, context.Context, io.Reader, http.Header применяются как есть, слой адаптации не нужен;
  • Ментальная модель — функциям уровня пакета соответствуют http.Get, экземпляру Client — http.Client, способ передачи контекста тот же (см. двухуровневую архитектуру API в Основных концепциях).

Что миграция добавляет:

  • принудительный TLS 1.2+, защиту от SSRF, проверку CRLF-инъекций и лимит размера тела ответа (безопасность по умолчанию);
  • интеллектуальные повторы с экспоненциальным откатом (уважают Retry-After, бюджет таймаута общий для всех повторов);
  • цепочку middleware в луковой модели (логирование/метрики/аудит/ID запроса);
  • универсальную обёртку Result — жизненный цикл тела ответа управляется автоматически, Close() не нужен.

Один и тот же запрос до и после миграции:

go
package main

import (
    "fmt"
    "io"
    "net/http"
)

func main() {
    resp, err := http.Get("https://httpbin.org/get")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close() // закрывать нужно вручную, иначе утечка соединений

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }

    fmt.Println(resp.StatusCode) // 200
    fmt.Println(len(body))       // количество байт ответа
}
go
package main

import (
    "fmt"

    "github.com/cybergodev/httpc"
)

func main() {
    result, err := httpc.Get("https://httpbin.org/get")
    if err != nil {
        panic(err) // только ошибки сетевого уровня
    }

    fmt.Println(result.StatusCode())  // 200
    fmt.Println(len(result.RawBody())) // количество байт ответа (уже в памяти, закрывать не нужно)
}

После миграции код короче, а по умолчанию уже включены политика TLS, защита от SSRF и до трёх интеллектуальных повторов.

Таблица соответствия API

Клиент и запрос

Запись в net/httpЭквивалент HTTPCКлючевые отличия
http.Get(url)httpc.Get(url)Аналогично: функция уровня пакета + общий экземпляр по умолчанию (ленивая инициализация)
http.Post(url, ct, body)httpc.Post(url, httpc.WithJSON(data))Тело запроса задаётся декларативно опциями With*, Content-Type проставляется автоматически
http.PostForm(url, values)httpc.Post(url, httpc.WithForm(m))WithForm принимает map[string]string; для url.Values используйте WithBody(values, httpc.BodyForm)
http.Head(url)httpc.Head(url)Прямое соответствие; аналогично Put/Patch/Delete/Options
client := &http.Client{...}httpc.New(cfg) / httpc.NewDefault()Возвращает интерфейс Client; владеет пулом соединений, для освобождения требуется Close()
http.DefaultClientВнутренний клиент по умолчанию в функциях уровня пакетаЛенивый синглтон; SetDefaultClient подменяет, после CloseDefaultClient автоматически пересоздаётся
http.NewRequest + client.Do(req)client.Get(url, opts...) и другие метод-глаголыКонструировать *http.Request не нужно; метод и URL передаются напрямую
http.NewRequestWithContext + Doclient.Request(ctx, method, url, opts...)Универсальная форма для любой строки метода
req.Header.Set(k, v)httpc.WithHeader(k, v) / WithHeaderMap(m)Ключи и значения заголовков проходят проверку на CRLF-инъекции, некорректные значения возвращают ErrInvalidHeader
req.Header.Set("User-Agent", ua)httpc.WithUserAgent(ua)Значение по умолчанию на уровне экземпляра — cfg.Defaults.UserAgent
req.SetBasicAuth(u, p)httpc.WithBasicAuth(u, p)С проверкой формата
req.AddCookie(&http.Cookie{...})httpc.WithCookie(http.Cookie{...})Принимает значение (не указатель); для набора — WithCookies/WithCookieMap/WithCookieString
req.URL.Query() + склейка строки запросаhttpc.WithQuery(k, v) / WithQueryMap(m)Значения поддерживают распространённые скаляры и fmt.Stringer, автоматически кодируются и сливаются с URL
jar, _ := cookiejar.New(nil) на client.Jarcfg.Connection.EnableCookies = trueИли DomainClient с автоматическим ведением Cookie и общих заголовков (см. Доменный клиент и сессии)
client.CheckRedirect = func(...)cfg.Defaults.FollowRedirects / MaxRedirectsНа уровне запроса — WithFollowRedirects(false); белый список доменов перенаправлений — Security.RedirectWhitelist (см. Перенаправления)
Прокси: Transport.Proxycfg.Connection.ProxyURL / ProxyPool / EnableSystemProxyТри способа применяются по приоритету, подробнее — в Прокси и пул прокси

Обработка ответа

Запись в net/httpЭквивалент HTTPCКлючевые отличия
resp.StatusCoderesult.StatusCode()Nil-безопасный аксессор; для проверки состояния — IsSuccess() / IsClientError() / IsServerError() / IsRedirect()
resp.Status / resp.Protoresult.Response.Status / result.Proto()Версия протокола, например HTTP/1.1
resp.Header.Get(k)result.Response.Headers.Get(k)По-прежнему стандартный http.Header, регистронезависимый
io.ReadAll(resp.Body)result.Body() / result.RawBody()Тело ответа уже прочитано в память и скопировано, распаковка выполняется автоматически
defer resp.Body.Close()Нет эквивалентаНе ищите точку закрытия — соединениями управляет пул, Result отдан сборщику мусора
json.NewDecoder(resp.Body).Decode(&v)result.Unmarshal(&v)Пустое тело возвращает сигнальную ошибку ErrResponseBodyEmpty (не io.EOF, см. ниже)
resp.Cookies()result.ResponseCookies() / result.GetCookie(name)Дополнительно GetRequestCookie показывает фактически отправленные Cookie
resp.ContentLengthresult.Response.ContentLength
resp.Request (итоговый запрос после перенаправлений)result.RequestСодержит URL / Method / Headers / Cookies
io.Copy(f, resp.Body) для сохранения на дискresult.SaveToFile(path)Для больших файлов используйте Download (потоково, с докачкой, см. Загрузка и скачивание файлов)
(нет эквивалента)result.MetaНовое в HTTPC: Duration / Attempts / RedirectChain / ProxyURL
resp.TrailerНет эквивалентаResult не раскрывает trailer; сценарии с зависимостью от trailer пока не поддерживаются

Различия модели ошибок

Это самое коварное место миграции. Сначала о совпадающем: ни одна из библиотек не считает 4xx/5xx ошибкой errorerr означает лишь, что запрос не удалось завершить. Настоящие различия — в форме err, жизненном цикле тела ответа и поведении повторов:

Аспектnet/httpHTTPC
Форма ошибкиИсходная транспортная ошибка, обёрнутая в *url.ErrorКлассифицированная ошибка *ClientError (12 типов в перечислении ErrorType)
КлассификацияУтверждение типа (net.Error, net.DNSError, x509.UnknownAuthorityError…) или разбор строкиИзвлечение через errors.As и чтение Code() / IsRetryable() / Attempts; сигнальные ошибки — через errors.Is
Ответ при err != nilresp может быть не nil (при ошибке от CheckRedirect прилагается последний ответ)result всегда nil, проверять ответ на nil не нужно
Ошибки чтения телаВозникают только в io.ReadAll / Decode (io.EOF, unexpected EOF)Тело прочитано на этапе запроса; сбой чтения возвращается как ClientError (ErrorTypeResponseRead) вместе с err
Пустое тело + декодирование JSONDecode возвращает io.EOFUnmarshal возвращает ErrResponseBodyEmpty
ПовторыНет — ошибка сразу уходит вызывающемуТаймауты/транспортные ошибки и 408/429/500/502/503/504 повторяются автоматически; при исчерпании сетевых ошибок возвращается error, при исчерпании повторяемых статус-кодов — последний ответ
URL в сообщении ошибкиВыводится как есть (может содержать учётные данные)Маскируется автоматически (учётные данные ***:***, чувствительные параметры [REDACTED])

Типичная обработка ошибок до миграции:

go
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
)

func main() {
    resp, err := http.Get("https://api.example.com/users/42")
    if err != nil {
        // *url.Error: сбои соединения, таймауты и ошибки TLS выходят сюда;
        // для классификации нужны утверждения типа или разбор строки
        panic(err)
    }
    defer resp.Body.Close()

    // 4xx/5xx — не error: обычный путь, статус проверяется вручную
    if resp.StatusCode != http.StatusOK {
        fmt.Println("HTTP-ошибка:", resp.StatusCode)
        return
    }

    var user map[string]any
    // При пустом теле Decode возвращает io.EOF — часто забываемая ветка
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil && !errors.Is(err, io.EOF) {
        panic(err)
    }
    fmt.Println(user["name"])
}
// Вывод (зависит от ответа сервера):
// HTTP-ошибка: 404

После миграции:

go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    result, err := httpc.Get("https://api.example.com/users/42")
    if err != nil {
        // Ошибка сетевого уровня: классифицирована как ClientError (12 типов),
        // с коротким кодом и признаком повторяемости
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            log.Printf("Тип ошибки: %s, повторяемая: %v, попыток: %d",
                clientErr.Code(), clientErr.IsRetryable(), clientErr.Attempts)
        }
        panic(err)
    }

    // 4xx/5xx — не error: проверяется методами состояния Result
    if !result.IsSuccess() {
        fmt.Println("HTTP-ошибка:", result.StatusCode())
        return
    }

    var user map[string]any
    // Пустое тело возвращает сигнальную ошибку ErrResponseBodyEmpty,
    // точно ловится через errors.Is
    if err := result.Unmarshal(&user); err != nil {
        if errors.Is(err, httpc.ErrResponseBodyEmpty) {
            fmt.Println("(пустое тело ответа)")
            return
        }
        panic(err)
    }
    fmt.Println(user["name"])
}
// Вывод (зависит от ответа сервера):
// HTTP-ошибка: 404

Типичные сценарии классификации ошибок (clientErr*httpc.ClientError, извлечённый через errors.As):

Что проверяемЗапись в net/httpЗапись в HTTPC
Таймаутvar ne net.Error + ne.Timeout()clientErr.Type == httpc.ErrorTypeTimeout
Сбой DNSvar de *net.DNSError + errors.Ashttpc.ErrorTypeDNS
Сбой проверки сертификатаvar ce x509.UnknownAuthorityError + errors.Ashttpc.ErrorTypeCertificate
Ошибка протокола TLSРазбор строки "tls:"httpc.ErrorTypeTLS
Отказ/сброс соединенияvar oe *net.OpError + errors.Ashttpc.ErrorTypeNetwork
Отмена/дедлайн контекстаerrors.Is(err, context.Canceled)httpc.ErrorTypeContextCanceled (никогда не повторяется)

Полное описание классификации ошибок, повторяемости и сигнальных ошибок — в Обработке ошибок и Типах ошибок.

Миграция тела запроса, заголовков и контекста

Трёхшаговая схема net/http «построить запрос → устанавливать параметры по одному → Do» в HTTPC сворачивается в «метод-глагол + декларативные опции».

go
// net/http: ручная сериализация, ручные заголовки, ручная сборка строки запроса
payload, _ := json.Marshal(map[string]any{"name": "test"})
req, err := http.NewRequest("POST", "https://api.example.com/orders", bytes.NewReader(payload))
if err != nil {
    log.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

q := req.URL.Query()
q.Set("page", "2")
req.URL.RawQuery = q.Encode()

resp, err := client.Do(req)
go
// HTTPC: опции и есть запрос, Content-Type проставляется автоматически,
// параметры запроса кодируются сами
result, err := client.Post("https://api.example.com/orders",
    httpc.WithJSON(map[string]any{"name": "test"}),
    httpc.WithBearerToken(token),
    httpc.WithQuery("page", 2),
)

Соответствие операций стандартной библиотеки опциям запроса:

Операция стандартной библиотекиОпция HTTPC
json.Marshal + bytes.NewReader + Content-TypeWithJSON(data) (эквивалент WithBody(data, BodyJSON))
xml.MarshalWithXML(data)
Кодирование формы url.ValuesWithForm(m) / WithBody(values, httpc.BodyForm)
Ручной multipart.Writer с границейWithFile(field, name, content) / WithFormData(form)
bytes.NewReader(raw) для сырого телаWithBody(raw) (тип определяется автоматически) / WithBinary(data, ct...)
req.Header.Set(k, v)WithHeader(k, v) / WithHeaderMap(m)
req.SetBasicAuth / ручная сборка BearerWithBasicAuth(u, p) / WithBearerToken(t)
req.AddCookieWithCookie(c) / WithCookies(cs) / WithCookieMap(m) / WithCookieString(s)
Потоковое тело io.ReaderWithBody(reader) (передаётся как есть; обходит проверку размера, оберните в io.LimitReader)

Работа с контекстом как в стандартной библиотеке — context.Context остаётся носителем таймаутов и отмены, меняется лишь место передачи:

go
// net/http: ctx вкладывается в объект запроса
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
go
// HTTPC: ctx передаётся первым аргументом напрямую
result, err := client.Request(ctx, "GET", url)

// Удобные методы (Get/Post и др.) не принимают ctx — подмените через WithContext:
result, err = client.Get(url, httpc.WithContext(ctx))

Полный список опций — в Запросах и ответах и Параметрах запроса API.

Сопоставление системы таймаутов

http.Client.Timeout — единый таймаут на весь процесс; HTTPC раскладывает его на пять независимых настроек и добавляет переопределение на уровне запроса:

net/httpПоле HTTPCПо умолчаниюОбласть действия
http.Client.TimeoutTimeouts.Request180sОбщий таймаут запроса, включая все повторы и ожидания отката
Transport.DialContext (net.Dialer{Timeout})Timeouts.Dial10sУстановка TCP-соединения
Transport.TLSHandshakeTimeoutTimeouts.TLSHandshake10sTLS-рукопожатие (только HTTPS)
Transport.ResponseHeaderTimeoutTimeouts.ResponseHeader0 (отключено)Ожидание заголовков ответа; положительное значение — жёсткий предел транспортного уровня
Transport.IdleConnTimeoutTimeouts.IdleConn90sВремя удержания простаивающих соединений
(нет переопределения на уровне запроса)WithTimeout(d)Переопределение общего бюджета для одного запроса; предел — 30 минут
go
// net/http: единый таймаут на весь процесс (без повторов)
client := &http.Client{Timeout: 30 * time.Second}
go
// HTTPC: общий бюджет на уровне экземпляра + переопределение на уровне запроса
cfg := httpc.DefaultConfig()
cfg.Timeouts.Request = 30 * time.Second // общий бюджет, включая все повторы
client, _ := httpc.New(cfg)

result, err := client.Get(url, httpc.WithTimeout(30*time.Second)) // переопределение для этого запроса

Три смысловых отличия стоит держать в уме:

  • Общий бюджет разделён всеми повторамиTimeouts.Request / WithTimeout покрывают все попытки и ожидания отката, а не отсчитываются заново на каждую попытку;
  • ResponseHeader особенный — по умолчанию 0 (отключено) и полностью управляется общим бюджетом; положительное значение действует на все запросы одного клиента и при меньшем значении перекрывает WithTimeout (эшелонированная защита от slowloris, уже настроена в SecureConfig());
  • Долгие ответы — интерфейсам с долгим ожиданием (AI API и т. п.) просто выдайте достаточный бюджет через WithTimeout; по умолчанию проблемы «таймаут заголовков обрывает медленный ответ» не существует.

Подробнее — в разделе таймаутов Запросов и ответов и в FAQ «Как выбрать таймауты?».

Миграция настройки Transport

Распространённые поля тонкой настройки http.Transport в HTTPC целиком отображаются на соответствующие подструктуры Config:

Поле http.Transport / http.ClientКонфигурация HTTPCПо умолчанию
MaxIdleConnsConnection.MaxIdleConns50
MaxConnsPerHost / MaxIdleConnsPerHostConnection.MaxConnsPerHost10
Proxy: http.ProxyFromEnvironmentConnection.EnableSystemProxyfalse
Пользовательская функция ProxyConnection.ProxyURL (одиночный прокси) / ProxyPool (ротация в пуле)пусто
TLSClientConfigSecurity.TLSConfignil
ForceAttemptHTTP2Connection.EnableHTTP2true
ResponseHeaderTimeoutTimeouts.ResponseHeader0 (отключено)
MaxResponseHeaderBytesConnection.MaxResponseHeaderBytes0 (по умолчанию стандартной библиотеки 10MB)
CheckRedirectDefaults.FollowRedirects / MaxRedirects + Security.RedirectWhitelisttrue / 10
Пользовательский DialContext (диалер)Прямого входа нетSSRF-проверки обёрнуты в слой установки соединения

Имеющиеся знания о tls.Config (mTLS, пользовательские CA, наборы шифров) переносятся без изменений:

go
package main

import (
    "crypto/tls"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    // Имеющийся tls.Config (пользовательский CA, наборы шифров,
    // клиентский сертификат mTLS) переносится как есть
    tlsCfg := &tls.Config{
        MinVersion: tls.VersionTLS12,
        MaxVersion: tls.VersionTLS13,
    }

    cfg := httpc.DefaultConfig()
    cfg.Security.TLSConfig = tlsCfg
    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://httpbin.org/get")
    if err != nil {
        log.Fatal(err) // ошибка сетевого уровня
    }
    fmt.Println(result.StatusCode()) // 200
}

Предупреждение

Как только задан Security.TLSConfig, поля MinTLSVersion / MaxTLSVersion игнорируются — политика версий TLS определяется переданным вами tls.Config (не забудьте сами задать MinVersion не ниже TLS 1.2).

Две границы нужно знать:

  • Нет точки внедрения пользовательского Transport — HTTPC сам создаёт и управляет *http.Transport (SSRF-проверки обёрнуты в функцию установки соединения, политика перенаправлений внедряется через CheckRedirect), Config не раскрывает Transport целиком. Если нужна экстремальная настройка поведения диалера, сначала проверьте, не покрывают ли уже Config.Connection / Config.Security.
  • Для подмены всей реализации есть Doer — тестовым заглушкам и заменяющим реализациям достаточно одно-методного интерфейса Doer (Request(ctx, method, url, opts...)), без стыковки с полным интерфейсом Client, см. Тестирование.

Ловушки миграции

При переходе с net/http чаще всего подводят следующие различия в поведении:

1. resp.Body не нужно (и нельзя) закрывать вручную

Result хранит уже прочитанные и скопированные байты: HTTPC сам выполняет чтение, дочитывание и закрытие, а соединениями управляет пул. При миграции удалите все defer resp.Body.Close() и не ищите точку закрытия. Подробнее см. FAQ «Нужно ли вручную закрывать тело ответа?».

2. Повторы включены по умолчанию — неидемпотентный POST может продублироваться

net/http никогда не повторяет; HTTPC по умолчанию повторяет до 3 раз при таймаутах/транспортных ошибках и кодах 408/429/500/502/503/504, не различая метод запроса. Интерфейсы заказа и списания обязательны к обработке:

go
// Опасно: по умолчанию до 3 повторов, POST тоже участвует в повторах
result, err := client.Post("https://api.example.com/orders", httpc.WithJSON(order))

// Безопасно (способ 1): отключение повторов на уровне запроса для неидемпотентного API
result, err = client.Post("https://api.example.com/orders",
    httpc.WithJSON(order),
    httpc.WithMaxRetries(0),
)

// Безопасно (способ 2): идемпотентный ключ на стороне сервера (рекомендуется)
result, err = client.Post("https://api.example.com/orders",
    httpc.WithJSON(order),
    httpc.WithHeader("Idempotency-Key", orderID),
)

Подробнее о стратегиях снижения риска — в FAQ «Могут ли повторы привести к дублированию POST?».

3. Защита от SSRF включена по умолчанию — внутренняя сеть и localhost блокируются

После миграции с net/http обращение к приватным/зарезервированным адресам вроде 127.0.0.1, 10.x, 192.168.x завершится ошибкой (в net/http такого ограничения нет). Для локальной отладки выбирайте по возрастанию радиуса воздействия:

go
// Освобождение на уровне запроса (рекомендуется, минимальный радиус воздействия)
result, err := httpc.Get("http://localhost:8080/health",
    httpc.WithAllowPrivateIPs(true),
)

// Точное освобождение CIDR на уровне клиента (например, VPC / Tailscale)
cfg := httpc.DefaultConfig()
cfg.Security.SSRFExemptCIDRs = []string{"10.0.0.0/8"}

Полная политика — в Защите от SSRF.

4. Клиент по умолчанию — ленивый синглтон, для долгоживущего сервиса создайте явный экземпляр

Функции уровня пакета делят один внутренне управляемый клиент по умолчанию (автоматически пересоздаваемый после закрытия). Продакшен-сервису следует создать явный экземпляр для контроля конфигурации и жизненного цикла; запрос после Close() возвращает ErrClientClosed:

go
// Долгоживущий сервис: явный экземпляр, общий на процесс, Close по завершении
client, err := httpc.NewDefault()
if err != nil {
    log.Fatal(err)
}
defer client.Close()

// Чтобы функции уровня пакета использовали вашу конфигурацию:
// подмена клиента по умолчанию (старый закрывается автоматически)
custom, err := httpc.New(httpc.SecureConfig())
if err != nil {
    log.Fatal(err)
}
if err := httpc.SetDefaultClient(custom); err != nil {
    log.Fatal(err)
}

5. Сигнальные ошибки ловите через errors.Is / errors.As, а не разбор строк

ErrClientClosed, ErrResponseBodyEmpty, ErrResponseBodyTooLarge, ErrInvalidHeader и другие сигнальные ошибки проверяйте через errors.Is; классифицированные ошибки извлекайте через errors.As в *ClientError. Цепочка ошибок проницаема до первопричины (Cause).

6. У тела ответа есть лимиты по умолчанию

Для обычных запросов лимит тела ответа по умолчанию 10MB, распакованного — 100MB (защита от исчерпания памяти и бомб распаковки); при превышении возвращается ошибка. Старый код http.Get + io.Copy для скачивания больших файлов переведите на Download (потоковая запись на диск, докачка, колбэк прогресса). Лимиты настраиваются через Security.MaxResponseBodySize / MaxDecompressedBodySize.

7. Поведение перенаправлений предсказуемо, но тоже имеет умолчания

По умолчанию перенаправления следуются (лимит 10). Учтите, что WithMaxRedirects(0) / MaxRedirects = 0 — сигнальное значение «не задано», а не запрет: для запрета следования используйте WithFollowRedirects(false) или Defaults.FollowRedirects = false. Подробнее — в Перенаправлениях.

Пошаговый чек-лист миграции

Выполняйте по порядку — каждый шаг проверяется независимо:

  1. Установите зависимостьgo get github.com/cybergodev/httpc, замените клиентские вызовы "net/http" на "github.com/cybergodev/httpc" (типы вроде http.Cookie по-прежнему импортируются из стандартной библиотеки).
  2. Механически замените вызовы запросовhttp.Gethttpc.Get, client.Do(req)client.Get/Post/...; литерал http.Clienthttpc.New(cfg).
  3. Удалите код управления ресурсами — уберите defer resp.Body.Close(), io.ReadAll; используйте result.Body() / result.RawBody() / result.Unmarshal(&v).
  4. Переделайте обработку ошибок — в ветке err при необходимости добавьте errors.As для извлечения ClientError; проверку статус-кода замените на семейство result.IsSuccess(); ветку пустого тела io.EOF — на errors.Is(err, httpc.ErrResponseBodyEmpty).
  5. Отобразите таймаутыhttp.Client.Timeoutcfg.Timeouts.Request; отличия на уровне запроса — WithTimeout; таймауты уровня Transport разместите по сопоставлению системы таймаутов.
  6. Перенесите настройку Transport — размеры пула, прокси, TLS и HTTP/2 разместите по таблице выше в Config.Connection / Config.Security; tls.Config перенесите в Security.TLSConfig.
  7. Обработайте безопасные умолчания — добавьте освобождение SSRF для вызовов внутренней сети/localhost; убедитесь, что лимиты тела ответа покрывают объём ответов ваших API.
  8. Оцените влияние повторов — для неидемпотентных POST добавьте идемпотентный ключ или WithMaxRetries(0); проверьте, что бюджет повторов согласован с бизнесовыми таймаутами.
  9. Завершите жизненный цикл — долгоживущему сервису явный экземпляр + defer client.Close(); убедитесь, что клиент не создаётся заново на каждом запросе.
  10. Регрессионная проверка — прогоните существующие интеграционные тесты; приоритетно покройте ошибочные пути (нет сети/таймаут/4xx/5xx) и сценарии больших тел ответов.

Что дальше