Service Clients

go.putnami.dev/client runs the clients Putnami generates from a provider declaration. The provider owns the schemas, the typed errors, the security profiles, the transports and the resilience policy; the consumer registers one binding and calls typed methods.

The happy path has four steps, and none of them is transport code:

declare the provider  →  generate  →  register the binding  →  call
   api.Endpoint(...)     putnami       client.Services()       items.GetItems(ctx, in)
   api.Clients(...)      clientgen     RegisterItemsClient

go/samples/service-to-service runs all four end to end.

1. Declare the provider

Endpoints are declared once, on the api plugin. MayThrow names the framework error codes the operation can return, so they arrive at the consumer as typed errors instead of a formatted string:

apiPlugin.Register(api.Endpoint("GET", "/items/{id}").
    Params(api.Type[GetItemParams]()).
    Returns(api.Type[Item]()).
    MayThrow(perrors.CodeNotFound).
    Handle(getItem))

api.WithClientService declares the service identity and the credential profiles the generated clients must satisfy. Profiles are names and header placements; credential values stay in consumer configuration and never enter generated source:

apiPlugin := api.New(httpServer, api.WithClientService(api.ClientServiceOptions{
    Service: clientcontract.Service{ID: "items", Audience: "urn:putnami:items"},
    Credentials: map[string]clientcontract.CredentialProfile{
        "workload":    {Kind: clientcontract.CredentialServiceToken},
        "catalog-key": {Kind: clientcontract.CredentialAPIKey, Header: CatalogKeyHeader},
    },
}))

Client(api.ClientOperationOptions{...}) carries the client-facing policy the route cannot express: which credential alternatives satisfy the operation, the resilience policy, the transport preference order, and whether a server stream may be continued.

apiPlugin.Register(api.Endpoint("GET", "/items/{id}/history").
    Params(api.Type[GetItemParams]()).
    Returns(api.StreamOf[ItemRevision]()).
    Client(api.ClientOperationOptions{
        Transports: []clientcontract.TransportProtocol{
            clientcontract.TransportWebSocket, clientcontract.TransportSSE,
        },
        Resume: true,
    }).
    MayThrow(perrors.CodeNotFound).
    Handle(api.ServerStream(itemHistory)))
Declaration What it produces
Returns(api.Type[T]()) A typed unary result
Returns(api.StreamOf[T]()) A server stream
Body(api.StreamOf[T]()) + Returns(api.Type[R]()) A client stream with one declared result
Body(api.StreamOf[T]()) + Returns(api.StreamOf[R]()) A bidirectional stream
Body(api.Binary(mediaType, maxBytes)) A raw octet request body, bounded
Returns(api.Binary(mediaType, maxBytes)) A raw octet response, bounded
MayThrow(code) A typed client error carrying that stable code
Transports: [...] The dispatch order every generated client follows
ConnectEncodings: [...] The Connect payload encoding order a generated client dispatches
Resume: true A server stream a broken socket may continue

The bound on api.Binary is mandatory. An unbounded octet body is the one shape generation refuses outright, because no consumer could size a buffer for it.

Finally, declare the targets to emit:

clients := api.Clients(api.ClientsOptions{
    Targets: []string{"go", "ts"},
    Go:      api.GoClientOptions{PackageName: "itemsclient", ClientName: "ItemsClient"},
    TS:      api.TSClientOptions{Output: "clients/ts", PackageName: "@example/items-client"},
}).From(apiPlugin)

2. Generate

putnami clientgen --projects items-provider

Generation is strict for a provider-owned contract: a semantic the shared client IR cannot carry fails with the operation named and a remedy, rather than emitting a client that silently loses it. The workspace commands, the drift guard and the full diagnostic list are in Generated clients.

3. Register the binding

module := app.NewModule("items-consumer")
module.Use(client.Services())
itemsclient.RegisterItemsClient(module)

Deployment values arrive through the typed clients config block, so the generated package holds no URL, token or key:

clients:
  clientId: orders.api
  services:
    items:
      url: https://items.internal
      credentials:
        service:
          source: gcp-id-token

An operation that declares a credential is never sent anonymously. When no declared alternative is satisfiable, or the client has a descriptor but no registered binding, the call fails with client.credential before any byte reaches the network.

client.Services() implements app.Stopper: the application's stop phase closes the registry, cancels credential refreshes in flight and closes every tracked stream. Two applications in the same process share nothing.

4. Call

item, err := items.GetItems(ctx, itemsclient.GetItemsInput{
    Path: itemsclient.GetItemsPath{Id: "123"},
})

A declared error arrives as a *client.RemoteError carrying the stable code, the status, the retry hint and the redacted declared details — never the raw response bytes, the request headers or the URL:

var remote *client.RemoteError
if errors.As(err, &remote) {
    log.Warn("dependency failed",
        "service", remote.Service(), "operation", remote.Operation(),
        "code", remote.Code(), "retryable", remote.Retryable())
}

The shapes a generated client exposes

Declared shape Generated entry point
Unary items.GetItems(ctx, in)
Raw octets the same method, returning status, content type and the bytes unchanged
Server stream client.OpenOperationServerStream[T] — SSE, WebSocket or Connect, in declared order
Client stream client.OpenClientStream[TIn, TOut] — WebSocket
Bidirectional stream client.OpenBidiStream[TIn, TOut] — WebSocket
Connect unary the same method; the transport is chosen from the declared order

The caller never names a transport. Credentials, deadline, retry, circuit breaker, trace propagation and metrics are the same chain on every wire. go/framework/client/README.md and AI.md hold the per-transport rules: admission, the five session phases, the four stream budgets, declared fallback and declared resume.

Rules, not gaps

These are contract decisions, and they do not change with a later release:

  • A WebSocket transport declaring proto encoding is refused at generation. The wire keeps the encoding in the contract; the Go client carries JSON messages.
  • A unary call never falls back to another transport. A server stream falls back once, before admission, and only when the provider answered that this wire is not served here.
  • A stream never continues unless the provider declared Resume on a safe server stream. A delivered message is never replayed.
  • A nullable member over connect+proto is refused: proto3 has one absence, so null and absent would become one value.

Low-level client

NewBuilder, transports and interceptors stay supported for tests and for contracts Putnami does not own. They are not part of the first-party path above, and a handwritten first-party transport fails the workspace guard.

c, err := client.NewBuilder().
    BaseURL("https://external.example.com").
    ClientID("my-service").
    Timeout(10 * time.Second).
    Build()
if err != nil {
    log.Fatal(err)
}
defer c.Close()

Builder methods

Method Description
BaseURL(url) Service base URL
ClientID(id) Client identity (sent as X-Client-Id header)
Timeout(d) Per-request timeout
TotalTimeout(d) Deadline for the whole call, across retries and backoff
Retry(config) Enable retry with exponential backoff
CircuitBreaker(config) Enable circuit breaker
Interceptors(...) Add custom interceptors
Transport(t) Use a custom transport
Build() Build the client

Making requests

resp, err := c.Do(ctx, &client.Request{
    Method:  "GET",
    Path:    "/api/users/123",
    Headers: map[string]string{"Accept": "application/json"},
    Query:   map[string]string{"include": "orders"},
})
if err != nil {
    return err
}

if resp.IsSuccess() {
    var user User
    json.Unmarshal(resp.Body, &user)
}
type Request struct {
    Method  string
    Path    string
    Headers map[string]string
    Query   map[string]string
    Body    []byte
}

type Response struct {
    StatusCode int
    Headers    map[string]string
    Body       []byte
}

resp.IsSuccess()   // true for 2xx
resp.IsRetryable() // true for 408, 429, 500, 502, 503, 504

Retry

Enable automatic retry with exponential backoff for transient failures:

c, _ := client.NewBuilder().
    BaseURL("http://users-api:3000").
    Retry(client.RetryConfig{
        MaxRetries: 3,
        BaseDelay:  100 * time.Millisecond,
        MaxDelay:   5 * time.Second,
    }).
    Build()

Retry configuration

Field Type Default Description
MaxRetries int Maximum retry attempts
BaseDelay time.Duration Initial delay between retries
MaxDelay time.Duration Maximum delay cap
RetryableFunc func(*Response, error) bool Custom retry logic

By default, the following status codes trigger retries: 408, 429, 500, 502, 503, 504.

Custom retry logic

client.RetryConfig{
    MaxRetries: 3,
    BaseDelay:  time.Second,
    RetryableFunc: func(resp *client.Response, err error) bool {
        if err != nil {
            return true // retry on network errors
        }
        return resp.StatusCode == 503 // only retry 503
    },
}

Circuit breaker

Protect against cascading failures by opening the circuit after repeated errors:

c, _ := client.NewBuilder().
    BaseURL("http://users-api:3000").
    CircuitBreaker(client.CircuitBreakerConfig{
        FailureThreshold: 5,
        ResetTimeout:     30 * time.Second,
        SuccessThreshold: 2,
    }).
    Build()

Circuit breaker configuration

Field Type Default Description
FailureThreshold int Consecutive failures before opening
ResetTimeout time.Duration Time before transitioning to half-open
SuccessThreshold int Successes in half-open to close
FailureStatuses []int Status codes counted as failures

Circuit breaker states

Closed  → (failures reach threshold)  → Open
Open    → (reset timeout expires)     → Half-Open
Half-Open → (successes reach threshold) → Closed
Half-Open → (any failure)              → Open
  • Closed — requests flow normally, failures are counted
  • Open — requests fail immediately without calling the downstream service
  • Half-Open — limited requests are allowed through to test recovery

Low-level interceptors

Add custom logic to the request/response pipeline:

type Interceptor func(req *Request, next func(*Request) (*Response, error)) (*Response, error)
// Logging interceptor
loggingInterceptor := func(req *client.Request, next func(*client.Request) (*client.Response, error)) (*client.Response, error) {
    start := time.Now()
    resp, err := next(req)
    duration := time.Since(start)
    fmt.Printf("%s %s%d (%dms)\n", req.Method, req.Path, resp.StatusCode, duration.Milliseconds())
    return resp, err
}

c, _ := client.NewBuilder().
    BaseURL("http://users-api:3000").
    Interceptors(loggingInterceptor).
    Build()

The interceptor chain handles client ID headers, retry with exponential backoff, and circuit breaker state management.

Low-level transports

HTTP transport (default)

client.NewBuilder().
    Transport(client.NewHTTPTransport(client.HTTPTransportConfig{})).
    Build()

Connect transport

For calling gRPC services via the Connect protocol:

client.NewBuilder().
    Transport(client.NewConnectTransport(client.ConnectTransportConfig{})).
    Build()

Custom transport

Implement the Transport interface for custom protocols:

type Transport interface {
    RoundTrip(ctx context.Context, req *Request) (*Response, error)
    Close() error
}

Low-level full example

c, err := client.NewBuilder().
    BaseURL("http://users-api:3000").
    ClientID("order-service").
    Timeout(10 * time.Second).
    Retry(client.RetryConfig{
        MaxRetries: 3,
        BaseDelay:  200 * time.Millisecond,
        MaxDelay:   5 * time.Second,
    }).
    CircuitBreaker(client.CircuitBreakerConfig{
        FailureThreshold: 5,
        ResetTimeout:     30 * time.Second,
        SuccessThreshold: 2,
    }).
    Build()
if err != nil {
    log.Fatal(err)
}
defer c.Close()

resp, err := c.Do(ctx, &client.Request{
    Method: "GET",
    Path:   "/api/users/123",
})
if err != nil {
    // Network error or circuit breaker open
    return err
}
if !resp.IsSuccess() {
    return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

Bounding the whole call

Timeout bounds a single attempt. With retries enabled, total wall-clock can reach roughly (MaxRetries+1) × Timeout plus backoffs, so use TotalTimeout when the call as a whole needs a deadline:

c, err := client.NewBuilder().
    BaseURL("http://users-api:3000").
    Timeout(2 * time.Second).       // per attempt
    TotalTimeout(5 * time.Second).  // whole call, including retries and backoff
    Build()

The interceptor chain is fixed and composed once:

ClientID → your interceptors → TotalTimeout → CircuitBreaker → Retry → Transport

Retries sit closest to the transport, so an attempt is a real network attempt. The circuit breaker sits outside them, so one logical call is one breaker observation and an open breaker rejects before any retry is planned.

Observing retries

Retries are otherwise silent. Wire OnRetry to a logger or a counter:

client.RetryConfig{
    MaxRetries: 3,
    OnRetry: func(attempt int, delay time.Duration, resp *client.Response, err error) {
        log.Warn("retrying", slog.Int("attempt", attempt), slog.Duration("delay", delay))
    },
}

It is called after the triggering attempt failed and before the backoff sleep, with the upcoming attempt number and the result that triggered it. It must not block.

Producer lineage

Every generated operation records the project and feature that produced it, so a call can be traced back to the capability on the other end:

trace := itemsclient.ItemsClientDesign.Trace("getItems")
// trace.ProducerProject, trace.ProducerFeature — empty when the producing
// module declares no feature, which is a first-class state rather than a guess.

Support and contract

go.putnami.dev/client is stable in the workspace support catalog. Its behavior, together with the client generator in go.putnami.dev/api, is defined by the typed service clients specification and its accepted decision records (go/framework/client/specs/, go/framework/client/doc/adr/, and go/framework/api/doc/adr/). Before v1.0.0 a minor 0.x release may still contain a documented breaking change; strict compatibility between every pre-1.0 minor is not promised.