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.
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.
| 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.