Skip to content

HTTP Client

See also: adapters/nethttp on pkg.go.dev

Runnable demo: examples/adapters-nethttp-client

The same adapters/nethttp package that drives the server provides a typed HTTP client that reuses the same Route definition, codecs, and parameter constraints. No duplication between client and server.

Two entry points exist:

  • rest.Client.Call (bound via Client.Attach(nethttp.NewClientTransport(...))) — the single-workflow entry point (Decision 6), and the RECOMMENDED pattern for every call. Build a rest.Client once, attach a nethttp.NewClientTransport(...) to an *http.Client + baseURL, then call client.Call(ctx, route, req) with a rest.Route value directly (the SAME value the server registers) — no separate "build a client copy" step needed at all.
  • rest.CallWithTransport — the lower-level, handle-based primitive the client transport wraps internally. Stays public for callers that already have a *rest.RouteHandle but no rest.Route value, or that need finer per-call control ClientCallOptions doesn't expose — e.g. ports.Pattern's REST binding machinery (DrainCallAdapter/CallAdapter), which owns its own transport via PortOptions, or adapters/mcprest's REST-to-MCP bridge. Client.Call itself is full-featured (path/query/ header/cookie params, security/credential ClientMW, per-call format override, error-pattern decoding) — there is no capability CallWithTransport has that Client.Call lacks.

The package's former Caller/NewCaller/Call[Req,Resp] (a two-tier value-based convenience predating the transport-based Attach workflow) are now an unexported internal caller/newCaller/call[Req,Resp], reachable only through Client.Attach.

Credential fulfillment is declared per-route via Route.ClientMW, paired against the SAME middleware.Middleware value the route's security requirement was declared with — there is no per-call credential override; a caller needing a different credential for one specific call builds a DIFFERENT Route value via a fresh .ClientMW(...) call.

Two usage patterns

Pattern 1 — Shared contract (import pattern)

Define routes, codecs, and types in a shared Go package. Both server and client import it. The compiler enforces the contract: any change breaks compilation on both sides immediately — no stale YAML, no code generation.

contract/
  contract.go   ← shared Route specs, codecs, types
server/main.go  ← imports contract/, registers routes, calls builder.Attach(...)+Serve
client/main.go  ← imports contract/, calls via rest.Client.Call
// contract/contract.go
var CreateUser = rest.NewRoute[CreateUserReq, User](
    "POST", "/users", createUserReqCodec, userCodec,
    rest.RouteMeta{OperationID: "createUser"},
)

// server.go — declare the handler, register, and wire the whole builder
err := contract.CreateUser.WithHandler(myHandler).Register(builder)
err = builder.Attach(nethttp.NewServerTransport(nethttp.ServerTransportOptions{Mux: mux, Addr: addr}))
go func() { _ = builder.Serve(ctx) }()

// client.go — reuse the SAME Route value directly, no separate registration
client := rest.NewClient()
err = client.Attach(nethttp.NewClientTransport(nethttp.ClientTransportOptions{HTTPClient: http.DefaultClient, BaseURL: "https://api.example.com"}))

userAny, err := client.Call(ctx, contract.CreateUser,
    CreateUserReq{Name: "Alice", Email: "alice@example.com"})
user := userAny.(User)

Pattern 2 — Client-only (ClientHandle / no builder)

When the client has no server role, rest.Client.Call still works directly with a rest.Route value — no Builder, no server-side Register call needed at all. Internally, the reflection shim derives the handle via Route.ClientHandle().

var getUser = rest.NewRoute[GetUserReq, User]("GET", "/users/{id}",
    getReqCodec, userCodec,
    rest.NewPathParam("id", uuidCodec,
        func(r GetUserReq) string { return r.ID },
        func(r *GetUserReq, v string) { r.ID = v }),
)

client := rest.NewClient()
err := client.Attach(nethttp.NewClientTransport(nethttp.ClientTransportOptions{HTTPClient: http.DefaultClient, BaseURL: "https://api.example.com"}))
userAny, err := client.Call(ctx, getUser, GetUserReq{ID: userID})
user := userAny.(User)

Note the path value is derived from req.ID automatically via the declared merge field (rest.NewPathParam) — the reflection shim always auto-derives path/query/header/cookie values from a route's declared merge fields; there is no manual vars map[string]string escape hatch. A route intended for client use must declare a merge field for every path/query/header/cookie value it needs. Client.Call is full-featured (JSON body, per-call format override, security/credential handling all supported) — use rest.CallWithTransport directly only when you need a pre-built *rest.RouteHandle or finer per-call control ClientCallOptions doesn't expose.

Auto-registration vs. explicit Client.RegisterRoute

A rest.Route value passed to client.Call(ctx, route, req) is auto-registered into client's own internal route registry the first time it's called — no extra code needed; this is the DEFAULT, and sufficient for the overwhelming majority of callers (every example above relies on it implicitly).

A caller may instead call client.RegisterRoute(route) explicitly — typically once, right after client.Attach(...) — to declare a Client's full set of known routes up front, in one place:

client := rest.NewClient()
err := client.Attach(nethttp.NewClientTransport(nethttp.ClientTransportOptions{HTTPClient: http.DefaultClient, BaseURL: "https://api.example.com"}))
_ = client.RegisterRoute(getUser)
_ = client.RegisterRoute(createUser)
// ... later, anywhere ...
userAny, err := client.Call(ctx, getUser, GetUserReq{ID: userID})

Both are fully valid, interchangeable styles for an ordinary route — RegisterRoute is a documented no-op when the SAME route has already been (or will later be) auto-registered via Call. Explicit registration is purely a matter of preference: it mirrors events.Client's own explicit Subscriber.Register(client) step, so a caller who prefers declaring a Client's full capability set up front (rather than relying on an implicit, call-time side effect) can do so consistently across both rest.Client and events.Client.

RegisterRoute is REQUIRED (not just stylistic) for exactly one case: a route that is only ever reached as a redirect target, never called directly — see REST redirects for the full explanation and a worked example.

nethttp.NewClientTransport

func NewClientTransport(opts ClientTransportOptions) rest.ClientTransport

NewClientTransport returns a rest.ClientTransport (via an internal, unexported caller/newCaller — a pure (client, baseURL) holder, NOT a spec-accumulating Builder equivalent) configured with opts.HTTPClient+opts.BaseURL. Pass it to client.Attach(...) to give client its Call(ctx, route, req) call shape. Credential fulfillment lives on the Route itself via ClientMW, not on the internal caller.

rest.CallWithTransport

func CallWithTransport[Req, Resp any](
    ctx       context.Context,
    transport rest.ClientTransport,
    handle    *rest.RouteHandle[Req, Resp],
    req       Req,
    opts      ...rest.ClientCallOptions,
) (Resp, error)

CallWithTransport derives vars/QueryParams/HeaderParams/CookieParams from req automatically via the route's declared merge fields, and merges any response merge fields (e.g. a declared response header) back into the returned value — the full request+response, single-call story, with full type safety (no any cast) and access to every ClientCallOptions field. rest.Client.Call (via Attach) performs the identical derivation internally for the common case, trading some of these features for a uniform Call(ctx, route, req) shape across REST/pub-sub. transport is built once via nethttp.NewClientTransport(nethttp.ClientTransportOptions{HTTPClient, BaseURL}) (or chi's equivalent) and reused for every call.

What Call does before sending the request: 1. BuildPath(vars) — derives path values from merge fields, validates each against its codec 2. ValidateQuery(opts.QueryParams) — validates each query param against its codec 3. ValidateCookies(opts.CookieParams) — validates each cookie against its codec 4. ValidateHeaders(opts.HeaderParams) — validates each header against its codec 5. Resolves the route's declared Security requirements → runs every attached credential-providing implementation declared via Route.ClientMW, GATED by Satisfies vs. the route's declared requirements, to obtain Authorization (or other credential) headers 6. Encodes request body (POST/PUT/PATCH only) 7. Executes client.Do

A validation failure aborts the call and returns the typed error — no HTTP request is sent. Note some merge-field constraint failures now surface as codex.ValidationError (caught at merge-derive time) rather than rest.PathParamError (which only fires from BuildPath's own re-validation).

rest.ClientCallOptions

rest.ClientCallOptions{
    // Codec-validated params (pre-flight) — override the value derived
    // from a merge field for the same key, or add ad-hoc params the
    // struct doesn't declare.
    QueryParams:  map[string]string{"page": "2"},
    CookieParams: map[string]string{"session_token": token},
    HeaderParams: map[string]string{"X-Tenant-ID": tenantID},

    // Extra headers — no codec validation (User-Agent, X-Request-ID, etc.)
    // — map[string][]string (not net/http's http.Header, since api/rest
    // may not import net/http; an identical underlying type).
    ExtraHeaders: map[string][]string{"User-Agent": {"my-client/1.0"}},

    // OnCredentialRejected fires on HTTP 401 when a credential-providing
    // ClientMW implementation was attached — wire
    // NewCachingCredentialFunc's returned invalidate func here for a
    // retry-once-on-401 pattern (Call never retries automatically).
    OnCredentialRejected: invalidateCred,

    // Observer — records per-call metrics (RecordRequest, RecordValidationError).
    // Status 0 = pre-flight validation failed, no HTTP call was sent.
    Observer: obs,
}

Error handling

// A response status matching a route-declared rest.ErrorPattern (default
// ErrorRespond action) is decoded automatically — check this BEFORE the
// generic UnexpectedStatusError fallback.
//
// Give each ErrorPattern its own status code: matching is status-only (no
// Go type on the wire), so two patterns sharing a status make the client
// deterministically pick the FIRST-declared one regardless of which the
// server actually sent — see the "Client-side decode" section of the REST
// API feature page for the full caveat + the shared-interface workaround.
var patternResp nethttp.ErrorPatternResponse
if errors.As(err, &patternResp) {
    conflict := patternResp.Value.(domain.EmailConflictError) // decoded typed payload
    logger.Warn("declared error pattern matched",
        "status", patternResp.StatusCode,
        "value",  conflict,
    )
}

// Non-2xx response with no matching ErrorPattern (or its body failed to
// decode) — the universal fallback, raw status + bytes.
var statusErr nethttp.UnexpectedStatusError
if errors.As(err, &statusErr) {
    logger.Error("api call failed",
        "method", statusErr.Method,
        "path",   statusErr.Path,
        "status", statusErr.StatusCode,
        "body",   string(statusErr.Body),
    )
}

// Pre-flight path/query/etc. merge-field constraint failure — no request
// was sent (caught at merge-derive time, before BuildPath ever runs)
var valErr codex.ValidationError
if errors.As(err, &valErr) {
    logger.Warn("merge field rejected (no request sent)",
        "field", valErr.Field,
        "cause", valErr.Err,
    )
}

// Pre-flight query param validation failure (from ClientCallOptions.QueryParams, not a merge field)
var qpErr rest.QueryParamError
if errors.As(err, &qpErr) {
    logger.Warn("query param rejected (no request sent)",
        "param", qpErr.Name,
        "cause", qpErr.Err,
    )
}

// A credential-providing ClientMW implementation's Fn returned an
// error — no request was sent
if errors.Is(err, tokenExpiredErr) {
    logger.Warn("credential error (no request sent)", "cause", err)
}

// A credential-providing ClientMW implementation returned a
// MALFORMED credential — the route's declared Codec rejects it LOCALLY
if errors.As(err, &credErr) { // rest.SecurityCredentialError
    logger.Error("malformed credential rejected locally (no request sent)",
        "scheme", credErr.Scheme,
        "cause",  credErr.Err,
    )
}

// Transport failures
var reqErr nethttp.RequestError
if errors.As(err, &reqErr) {
    logger.Error("transport error", "method", reqErr.Method, "cause", reqErr.Err)
}

Observer (metrics)

type CountingObserver struct{ ... }

func (o *CountingObserver) RecordRequest(method, path string, statusCode int, d time.Duration) {
    // statusCode = 0 means pre-flight validation failure — no HTTP call was sent.
    o.byStatus[statusCode]++
    o.latencies = append(o.latencies, d)
}

func (o *CountingObserver) RecordValidationError(location, constraint, field string) {
    // location: "path", "query", "cookie", "header", "body"
    o.valErrorsByLoc[location]++
}

Pass rest.ClientCallOptions{Observer: obs} to every call to collect metrics. In production, replace the in-memory counters with Prometheus or OpenTelemetry instruments. stats.WithObserver(ctx, obs) stores an observer in ctx once — every call that receives that ctx picks it up automatically when ClientCallOptions.Observer is nil.

General-purpose ClientMW hook

Route.ClientMW/SSERoute.ClientMW also recognize a SECOND Fn shape, alongside the credential shape above — a general-purpose hook that wraps the network round-trip itself (encode request → send → decode response), for cross-cutting concerns other than credentials (custom tracing, request/response transformation, retry logic):

func(next func(ctx context.Context, req Req) (Resp, error)) func(ctx context.Context, req Req) (Resp, error)

Attach it via .ClientMW(nil, fn) — nil marks it general-purpose (unpaired, runs unconditionally), mirroring how .HandleMW(nil, fn) attaches a general-purpose server-side hook:

loggingHook := func(next func(context.Context, GetUserReq) (UserResp, error)) func(context.Context, GetUserReq) (UserResp, error) {
    return func(ctx context.Context, req GetUserReq) (UserResp, error) {
        start := time.Now()
        resp, err := next(ctx, req)
        log.Printf("call took %s, err=%v", time.Since(start), err)
        return resp, err
    }
}

route := contract.GetUser().ClientMW(nil, loggingHook)

Multiple general-purpose hooks compose outermost-in, in attachment order — the first .ClientMW(nil, ...) call wraps every later one. ClientCallOptions.Observer/RecordRequest are unaffected — the observer stays a permanent per-call field, independent of this hook, exactly as before.

Only Call/CallWithTransport recognize this shape; SSE's Consume/CallSSEAdapter recognize only the credential shape (SSE's per-event dispatch doesn't match the single-call wrap signature).

Credential caching

Re-authenticating on every call is wasteful when the credential fetch does real work (an OAuth token endpoint, a registry token exchange, etc.). NewCachingCredentialFunc wraps any CredentialFunc with TTL-based caching; concurrent callers during a cache miss share the same in-flight call.

cachedFn, invalidate := nethttp.NewCachingCredentialFunc(fetchToken,
    nethttp.CachingCredentialFuncOptions{TTL: time.Hour})

securedRoute := contract.GetSecuredData(securedMw).ClientMW(&securedMw, cachedFn)

// Wire invalidate into OnCredentialRejected for a retry-once-on-401 pattern.
opts := rest.ClientCallOptions{OnCredentialRejected: invalidate}
handle := securedRoute.ClientHandle()
transport := nethttp.NewClientTransport(nethttp.ClientTransportOptions{HTTPClient: httpClient, BaseURL: baseURL})
resp, err := rest.CallWithTransport(ctx, transport, handle, struct{}{}, opts)

See also