Help us shape the future of Go! Take the 2026 Go Developer Survey

httpclient

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 21 Imported by: 0

README

HTTP client

Send external HTTP requests using small, tagged operation structs. The client builds URLs, encodes bodies and runs middleware over native net/http. It works independently of application configuration, modules, databases and workers.

Send your first request

Define the operation once. Put it in the integration that owns the external API.

type GetProductRequest struct {
    httpclient.Request `method:"GET" path:"products/{id}"`

    ID     string   `path:"id"`
    Fields []string `query:"fields,omitempty"`
}

type ProductResponse struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

Create and reuse a client:

client, err := httpclient.New(httpclient.Config{
    BaseURL: "https://api.example.com/v1/",
    Timeout: 15 * time.Second,
    Header: http.Header{
        "Accept": {"application/json"},
    },
})
if err != nil {
    return err
}
defer client.CloseIdleConnections()

response, err := client.Do(ctx, GetProductRequest{
    ID: "123", Fields: []string{"name", "price"},
})
if err != nil {
    return err
}
if err := response.CheckStatus(); err != nil {
    return err
}

var product ProductResponse
if err := response.DecodeJSON(&product); err != nil {
    return err
}

Try the compiled local example in examples/httpclient/main.go:

go run ./examples/httpclient

It starts a disposable local provider with sample catalog data. No real credentials, internet service or application infrastructure are needed.

Define paths, query parameters and headers

Declaration Meaning
Embedded httpclient.Request Required operation marker; declares method and path
path:"id" Substitute {id} with one escaped value
query:"tag" Encode a query parameter; slices become repeated keys
header:"Accept-Language" Set a request header; replaces matching client defaults
query:"tag,omitempty" Omit empty values
header:"Accept-Language,omitempty" Omit empty values and retain any client default
body:"json" Encode this field as the JSON body

Tags belong on direct, exported fields. Embed one marker; no registration or request methods are required. Untagged fields are ignored. Parameter groups are not recursively flattened. Missing placeholders, duplicate parameter declarations, conflicting tags and unsupported encodings fail before sending. Tag spelling is not compiler-checked; exercise new operations with a local test server.

The base URL is an API prefix. Both products and /products append to https://api.example.com/v1/. Request paths cannot switch hosts, contain query strings or fragments, or escape the prefix using dot segments. Use query tags for query parameters and DoRequest for a fully specified native URL.

type ListProductsRequest struct {
    httpclient.Request `method:"GET" path:"products"`

    Tags     []string `query:"tag,omitempty"`
    Active   *bool   `query:"active,omitempty"`
    Language string  `header:"Accept-Language,omitempty"`
}

Tags: []string{"sale", "new"} produces tag=sale&tag=new. A nil Active omits the parameter; a pointer to false sends active=false. Non-pointer zero values are sent unless omitempty is present. Parameters support scalar values, slices and encoding.TextMarshaler, including time.Time.

Path values must be nonempty; slashes inside values are escaped. Path, query and header tags describe serialization, not business validation. Keep validation in the integration or calling use case. Incoming HTTP validation remains owned by Huma; this package does not depend on it.

Send JSON and XML

Put the payload in a separate field, conventionally named Body. Only that field is serialized. Ordinary json and xml tags and custom marshalers retain their standard library behavior.

type UpdateProductRequest struct {
    httpclient.Request `method:"PATCH" path:"products/{id}"`

    ID      string `path:"id"`
    IfMatch string `header:"If-Match,omitempty"`
    Body    struct {
        Name      *string `json:"name,omitempty"`
        Published *bool   `json:"published,omitempty"`
    } `body:"json"`
}

For partial updates, a nil pointer omits a field, while a pointer to false, zero or an empty string explicitly sends that value. A named payload struct works too.

type SubmitOrderRequest struct {
    httpclient.Request `method:"POST" path:"orders"`
    Body struct {
        XMLName xml.Name `xml:"order"`
        ID      string   `xml:"id"`
    } `body:"xml"`
}

The encoder supplies Content-Type. Explicit compatible types are accepted, including application/merge-patch+json and UTF-8 charset parameters. Conflicting types fail before sending. Set Accept separately to describe the response you want.

Submit forms

type CreateContactRequest struct {
    httpclient.Request `method:"POST" path:"contacts"`
    Body struct {
        Email string   `form:"email"`
        Tags  []string `form:"tag,omitempty"`
    } `body:"form"`
}

The content type is application/x-www-form-urlencoded. Use url.Values as the body for dynamically named fields. Form payloads use form tags; untagged fields are ignored. form:"-" excludes a field explicitly.

Upload files with multipart

type UploadDocumentRequest struct {
    httpclient.Request `method:"POST" path:"documents"`
    Body struct {
        Title string          `form:"title"`
        File  httpclient.File `form:"file"`
    } `body:"multipart"`
}

file, err := os.Open("invoice.pdf")
if err != nil {
    return err
}
defer file.Close() // Also covers a validation failure before the client owns it.

request := UploadDocumentRequest{}
request.Body.Title = "Invoice"
request.Body.File = httpclient.File{
    Filename: "invoice.pdf", ContentType: "application/pdf", Content: file,
}
response, err := client.Do(ctx, request)

File, *File and []File support one, optional or multiple file parts. The client streams file contents and generates the multipart boundary. Do not supply your own boundary. Filename is metadata; the client never opens filesystem paths for you.

Build does not read files or start a producer. Sending starts the stream. Closing the request body releases the pipe and closable file readers. Readers without a Close method must arrange to unblock their own reads on cancellation. Supply fresh readers for another upload; multipart streams are not automatically replayable.

Send text, bytes or a stream

type ImportCSVRequest struct {
    httpclient.Request `method:"POST" path:"imports"`

    ContentType string    `header:"Content-Type"`
    Body        io.Reader `body:"raw"`
}

response, err := client.Do(ctx, ImportCSVRequest{
    ContentType: "text/csv",
    Body: strings.NewReader("name,email\nAlex,alex@example.com\n"),
})

Raw bodies accept string, []byte or io.Reader. Set the content type explicitly for text, CSV, protobuf or another format; otherwise it defaults to application/octet-stream. Closable raw readers follow native HTTP body ownership. Known byte/string readers retain native content length and replay support. The package adds no retry loop; provider idempotency and stream replay remain explicit.

Write middleware

Middleware is one function receiving the native request and next. Headers are already cloned, so ordinary header edits need no transport wrapper or cloning code.

func addRequestID(request *http.Request, next httpclient.Next) (*http.Response, error) {
    request.Header.Set("X-Request-ID", "example-request")
    return next(request)
}

client, err := httpclient.New(httpclient.Config{
    BaseURL: "https://api.example.com/v1/",
    Middleware: []httpclient.Middleware{addRequestID},
})

Run code before and after next for timing, logging or response-header inspection:

func observe(request *http.Request, next httpclient.Next) (*http.Response, error) {
    started := time.Now()
    response, err := next(request)
    logging.FromContext(request.Context()).DebugContext(request.Context(),
        "outbound HTTP exchange",
        "headers_duration", time.Since(started),
        "failed", err != nil,
    )
    return response, err
}

First registered is first entered and last exited. The chain is assembled on configuration and shared by concurrent callers. Middleware runs for each exchange, including redirect hops when you explicitly enable them. Call next synchronously, normally once. Middleware state must be concurrency-safe. Static headers belong in Config.Header; dynamic authentication must restrict credentials to its intended origin.

Both Do and DoRequest run middleware; Build does not. Middleware surrounds transport execution through response headers. Body consumption and JSON decoding happen later. Do not read/close a successful response body in middleware unless you replace it with an equivalent readable body. Replacing a request body also makes the middleware responsible for the old body and correct replay/length metadata.

Read responses and handle failures

response.StatusCode                    // Numeric status
response.Successful()                  // Is the HTTP status 2xx?
response.Header.Get("Content-Type")    // Response header
response.ContentType()                 // Media type without charset or other parameters
response.Trailer.Get("X-Checksum")      // Complete after a successful body read
response.Body                          // Retained bytes
response.Text()                        // Retained bytes as text
response.BodyError()                   // Read/limit/close error, also returned by Do
response.DecodeJSON(&result)           // Decode without consuming Body
response.DecodeXML(&result)            // Decode XML without consuming Body

Do closes the network response body and retains at most MaxResponseBytes. Decoding can be repeated. No JSON envelope or content-type assumption is imposed on responses. Both decoders reject extra trailing values and refuse bodies whose read, size limit or close failed, even if the retained prefix looks valid. XML allows trailing whitespace, comments and processing instructions.

Successful and CheckStatus classify only the HTTP status. Always check the error from Do first: HTTP 200 with an interrupted body is still an incomplete response.

Result Meaning
Build/transport error No usable buffered response; preserve cancellation with errors.Is
HTTP 4xx/5xx Do succeeds; call CheckStatus to classify the status
*httpclient.StatusError Response remains available; error text omits private provider details
httpclient.ErrResponseTooLarge Partial response returned; body contains only the configured limit
Body read/close error Response returned with the retained bytes; decoders reject it
*httpclient.DecodeError Format and original cause available through errors.As / errors.Is
Empty body, including HTTP 204 Skip decoding when no payload is expected; decoding returns an error wrapping io.EOF
Provider error inside HTTP 200 Integration interprets it, for example GraphQL's errors
Decode typed or dynamic JSON

Typed response structs are the usual choice. For a dynamic payload, numbers remain json.Number instead of becoming floating-point values that could round a large ID:

var payload map[string]any
if err := response.DecodeJSON(&payload); err != nil {
    return err
}
if number, ok := payload["id"].(json.Number); ok {
    id := number.String() // Keeps the original numeric text, e.g. 9007199254740993.
    _ = id
}

Typed int64, float64 and other fields retain normal encoding/json behavior. Unknown JSON fields are accepted for forward compatibility. Use the retained bytes with a native decoder when an integration needs stricter rules or another format. Ignore the destination value when decoding fails; standard decoders may partially populate it before reporting an error.

Inspect failures without losing the response
response, err := client.Do(ctx, GetProductRequest{ID: "123"})
if err != nil {
    // response can be non-nil after a body read or size-limit failure.
    // Its bytes remain available, but DecodeJSON/DecodeXML will refuse them.
    return err
}
if err := response.CheckStatus(); err != nil {
    var status *httpclient.StatusError
    if errors.As(err, &status) && status.Response.StatusCode == http.StatusNotFound {
        return ErrProductNotFound // Your integration's error.
    }
    return err
}
var product ProductResponse
if err := response.DecodeJSON(&product); err != nil {
    var mismatch *json.UnmarshalTypeError
    if errors.As(err, &mismatch) {
        // The provider changed a field's type; the original cause is preserved.
    }
    return err
}

StatusError and DecodeError messages omit response content. Their inspectable response and underlying cause can still contain private provider data.

Read a retry hint
if response.StatusCode == http.StatusTooManyRequests {
    if delay, ok := response.RetryAfter(); ok {
        // Supply delay to your queue or retry policy, if the operation is safe to retry.
        _ = delay
    }
}

RetryAfter supports seconds and HTTP dates. A valid past date returns zero; the boolean distinguishes that from a missing, malformed or unrepresentable value. The client never sleeps or retries automatically.

A transport error after sending does not prove the provider rejected the operation. Use provider idempotency support for operations you may retry. Headers and bodies can contain secrets; logging and tracing are opt-in and should record selected fields.

Stream responses and customize native requests

request, err := client.Build(ctx, GetProductRequest{ID: "123"})
if err != nil {
    return err
}
request.Header.Set("X-Custom", "value")

response, err := client.DoRequest(request)
if err != nil {
    return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
    return fmt.Errorf("unexpected download status: %d", response.StatusCode)
}
_, err = io.Copy(destination, response.Body)

DoRequest returns native *http.Response. It applies middleware, timeout, cookie and redirect policies, but does not add default headers or resolve the URL again. It also accepts a request created directly with http.NewRequestWithContext. Use native fields for Host, ContentLength, trailers and unusual protocol controls; these cannot be set through header tags. If you abandon a built request, close its body yourself. After sending successfully, close the returned response body.

Configure timeouts, transports and runtime changes

Setting Default and behavior
BaseURL Required for tagged requests; optional for native-only use
Header Copied defaults for tagged requests
Timeout Zero selects 30 seconds, including response-body reading; -1 disables the client timeout
MaxResponseBytes Zero selects 10 MiB; positive limit for Do; DoRequest streams without this cap
Middleware Ordered functions shared by both execution paths
Transport Private clone of the standard default transport; explicit custom transports are borrowed
CheckRedirect Redirects disabled; nil restores this default
Jar Optional native cookie jar

Configure TLS, proxies, dialing and connection limits on an http.Transport before using it. Reuse clients and transports. The default pool is kept across configuration updates. Custom transport owners must retire replaced pools after active users and response bodies finish; Configure does not close borrowed infrastructure.

cfg := client.Config()
cfg.Timeout = 45 * time.Second
cfg.Header.Set("Authorization", "Bearer " + newToken)
if err := client.Configure(cfg); err != nil {
    return err
}

Config returns copied headers and a copied middleware slice. Configure validates the entire replacement before publishing it; failures retain the last good settings. Each operation captures one snapshot, including its response limit. In-flight requests keep their original settings. Serialize concurrent read-modify-write configuration updates to avoid overwriting another administrator's changes. Borrowed collaborators and closure state are not deep-copied.

Use a request context for an individual operation's deadline. For long-lived response streams, choose a suitable timeout or Timeout: -1 and manage cancellation yourself. CloseIdleConnections releases idle pools without interrupting active requests. It can also affect other clients sharing an explicitly supplied transport.

Why the native client is private

Embedding *http.Client would expose mutable Timeout, Transport and Jar fields outside Configure. Editing those during requests could race and bypass validated configuration snapshots. Its promoted Get and Post methods would also return native responses without our tagged request building or buffered-body limit: Go embedding promotes methods but does not make their internal calls dispatch to the outer Do method.

Use Config for native transport, cookie and redirect controls, and DoRequest when you want the full native request/response API. An integration can embed our *httpclient.Client directly, as the catalog example below does.

Reuse the existing tracing transport without a second middleware API:

client, err := httpclient.New(httpclient.Config{
    BaseURL: "https://trusted-provider.example/v1/",
    Transport: tracing.NewHTTPTransport(nil, tracer),
})

The tracing wrapper propagates context to this deliberately trusted destination. See observability for provider ownership and shutdown.

Build an integration

internal/modules/<module>/integrations/catalog/
    client.go       Client construction and credentials
    requests.go     Tagged operations and payloads
    responses.go    Provider response types

The compiled REST example in examples/httpclient/catalog/ has just these three production files. It demonstrates a fictional catalog API, with no external account or credentials required to run the local example.

In client.go, embed the framework client and set provider defaults once:

type Client struct{ *httpclient.Client }

func New(baseURL, token string) (*Client, error) {
    if baseURL == "" {
        return nil, errors.New("catalog: base URL is required")
    }
    header := http.Header{"Accept": {"application/json"}}
    if token != "" {
        header.Set("Authorization", "Bearer "+token)
    }
    client, err := httpclient.New(httpclient.Config{
        BaseURL: baseURL,
        Header:  header,
    })
    if err != nil {
        return nil, err
    }
    return &Client{Client: client}, nil
}

Do, Build, Config, Configure, DoRequest and CloseIdleConnections are available directly. No forwarding methods or second configuration type are needed. Embedding fits this example because it adds defaults, with no extra execution policy. If a provider needs mandatory signing or custom error interpretation, enforce that in middleware or explicit integration methods.

Keep operations in requests.go and payload types in responses.go:

// requests.go
type GetProductRequest struct {
    httpclient.Request `method:"GET" path:"products/{id}"`
    ID                 string `path:"id"`
}

type CreateProductRequest struct {
    httpclient.Request `method:"POST" path:"products"`
    Body               CreateProductBody `body:"json"`
}

type CreateProductBody struct {
    Name string `json:"name"`
}

// responses.go
type ProductResponse struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

The call site stays the same for get and create operations:

client, err := catalog.New(baseURL, token)
if err != nil {
    return err
}
defer client.CloseIdleConnections()

response, err := client.Do(ctx, catalog.CreateProductRequest{
    Body: catalog.CreateProductBody{Name: "Keyboard"},
})
if err != nil {
    return err
}
if err := response.CheckStatus(); err != nil {
    return err
}
var product catalog.ProductResponse
if err := response.DecodeJSON(&product); err != nil {
    return err
}

Construct integrations in their owning module and pass them explicitly to consumers. There is no global client registry or app resource entry. Keep maintained provider SDKs when they already handle specialized protocols correctly, such as Google's identity-token validation.

Verify an integration

Use httptest.NewServer to inspect the actual URL, headers, body and error handling. Inject a native transport for a provider whose hostname must stay fixed. Package tests cover encoding, middleware order, concurrent configuration, cancellation, bounded bodies and multipart cleanup:

go test -race ./pkg/httpclient ./examples/httpclient/...

Continue with module construction, dependency ownership, logging and testing HTTP.

Documentation

Overview

Package httpclient sends declarative, tagged requests through net/http. Clients are safe for concurrent use; request values and body streams belong to their callers and must not be mutated while a call is using them.

Index

Constants

This section is empty.

Variables

View Source
var ErrResponseTooLarge = errors.New("httpclient: response exceeds MaxResponseBytes")

ErrResponseTooLarge means Do retained only MaxResponseBytes of the body.

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client reuses connections and publishes configuration changes atomically. Construct it with New. Each call captures one configuration snapshot. The native client stays private: exposing mutable fields or promoting its Get/Post methods would bypass these configuration and tagged-request contracts.

func New

func New(cfg Config) (*Client, error)

New validates configuration without sending requests or starting workers.

func (*Client) Build

func (c *Client) Build(ctx context.Context, input any) (*http.Request, error)

Build constructs a native request without running middleware or sending it. After success, the caller owns Body until handing it to DoRequest (or another HTTP client). Close Body when abandoning a built request, including multipart.

func (*Client) CloseIdleConnections

func (c *Client) CloseIdleConnections()

CloseIdleConnections releases idle connections, including the client's default pool. It does not interrupt active calls or make the client unusable. A custom transport may share a pool with other clients, as with net/http itself.

func (*Client) Config

func (c *Client) Config() Config

Config returns a snapshot with independent header and middleware containers.

func (*Client) Configure

func (c *Client) Configure(cfg Config) error

Configure replaces the complete configuration. Invalid changes leave the old snapshot intact. In-flight requests retain their original settings. Custom transports are borrowed: their owner retires them after their users finish.

func (*Client) Do

func (c *Client) Do(ctx context.Context, input any) (*Response, error)

Do builds, sends and buffers a tagged request. It closes the response body. Non-2xx statuses are responses, not execution errors; call CheckStatus explicitly. A body read error or limit violation returns both the partial response and error.

func (*Client) DoRequest

func (c *Client) DoRequest(request *http.Request) (*http.Response, error)

DoRequest sends a native request through the same middleware, timeout, cookie and redirect policies. Supply a complete URL and headers; BaseURL and default Header are not applied. The caller must close the returned response body.

type Config

type Config struct {
	BaseURL          string
	Header           http.Header
	Timeout          time.Duration // Zero selects 30s; -1 disables the client timeout.
	MaxResponseBytes int64         // Zero selects 10 MiB; applies only to Do.
	Middleware       []Middleware
	Transport        http.RoundTripper
	CheckRedirect    func(*http.Request, []*http.Request) error
	Jar              http.CookieJar
}

Config is a complete client configuration. New and Configure copy Header and Middleware; transports, cookie jars and function closures are borrowed and must be safe for concurrent use. Do not mutate a transport after using it.

type DecodeError

type DecodeError struct {
	Format string
	Err    error
}

DecodeError identifies the requested format and retains the cause for errors.Is/As without copying potentially private provider content into Error. As with standard decoders, ignore the target value when decoding fails.

func (*DecodeError) Error

func (e *DecodeError) Error() string

func (*DecodeError) Unwrap

func (e *DecodeError) Unwrap() error

type File

type File struct {
	Filename    string
	ContentType string
	Content     io.Reader
}

File is a multipart file part. Content is streamed once, never read by Build. The built request body closes Content when it implements io.Closer. Supply a fresh reader for another request. Filename is metadata, never a path to open.

type Middleware

type Middleware func(request *http.Request, next Next) (*http.Response, error)

Middleware surrounds one HTTP exchange. It may edit the provided request's headers: the client has already cloned them. Bodies remain single-use streams. Middleware must be concurrency-safe and normally call next exactly once.

type Next

type Next func(*http.Request) (*http.Response, error)

Next sends a request through the remaining middleware. Call it synchronously.

type Request

type Request struct{}

Request is an empty marker embedded in an operation struct with method and path tags. Parameter fields use path, query or header tags. A body field uses body:"json", "xml", "form", "multipart" or "raw". No registration is needed.

type GetProductRequest struct {
    httpclient.Request `method:"GET" path:"products/{id}"`
    ID string `path:"id"`
}

type Response

type Response struct {
	StatusCode int
	Status     string
	Header     http.Header
	Trailer    http.Header
	Body       []byte
	// contains filtered or unexported fields
}

Response retains the response bytes. Decoding does not consume Body. Trailer is complete only after a successful body read. Use DoRequest for streaming.

func (*Response) BodyError

func (r *Response) BodyError() error

BodyError returns the read, size-limit or close error also returned by Do. Body and Text remain available for diagnostics; decoders reject incomplete data.

func (*Response) CheckStatus

func (r *Response) CheckStatus() error

CheckStatus accepts any 2xx response. Provider-level errors in a successful HTTP response belong to the integration, for example GraphQL errors.

func (*Response) ContentType

func (r *Response) ContentType() string

ContentType returns the normalized media type without parameters, or an empty string if Content-Type is absent or malformed. Decoding is selected explicitly.

func (*Response) DecodeJSON

func (r *Response) DecodeJSON(target any) error

DecodeJSON decodes one complete JSON value without consuming Body. Numbers decoded into any/map[string]any use json.Number to avoid precision loss; typed numeric fields keep their usual behavior. Empty bodies return a DecodeError wrapping io.EOF. Content-Type does not restrict this explicit operation.

func (*Response) DecodeXML

func (r *Response) DecodeXML(target any) error

DecodeXML decodes one XML document without consuming Body. A second root or trailing non-whitespace data is rejected; comments and processing instructions in the epilog are permitted. Empty bodies return a DecodeError wrapping io.EOF.

func (*Response) RetryAfter

func (r *Response) RetryAfter() (time.Duration, bool)

RetryAfter reads a delay in seconds or an HTTP date. The boolean distinguishes a valid zero delay from an absent, malformed or unrepresentable value. Past dates yield zero. This only reads the hint; it never schedules or retries work.

func (*Response) Successful

func (r *Response) Successful() bool

Successful reports whether the HTTP status is 2xx. It does not imply the body was read completely or the provider accepted the operation; inspect Do's error and any provider-specific error envelope separately.

func (*Response) Text

func (r *Response) Text() string

Text returns the retained body as a string, without consuming it.

type StatusError

type StatusError struct{ Response *Response }

StatusError preserves the response for inspection without including its body, URL or headers in Error. Inspect it with errors.As.

func (*StatusError) Error

func (e *StatusError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL