~/blog/writing-go-client-library

Writing a Go Client Library Worth Using

9 min read

Every time I integrate a third-party API into a production service, I end up judging the team behind it by their Go client library as much as their API design. A poorly designed SDK can make an otherwise solid API a nightmare to work with. A well-designed one gets out of your way.

I've written a handful of these libraries at this point, and integrated dozens more. Here's what I've learnt about the difference between a library that engineers happily adopt and one that ends up with a wrapper written around it on day two.


The Functional Options Pattern Is Not Optional

The worst client constructors I've seen look like this:

// Please, no.
client := NewClient("https://api.example.com", "my-token", true, false, 30, nil)
// Please, no.
client := NewClient("https://api.example.com", "my-token", true, false, 30, nil)

Seven arguments, no labels at the call site, and a boolean in position three that you have to trace back to the signature to understand. This is how you get bugs in production because someone passed false where true was meant.

Use functional options. Every time.

type Client struct {
    baseURL    string
    httpClient *http.Client
    token      string
    retries    int
    logger     Logger
}
 
type Option func(*Client)
 
func WithHTTPClient(hc *http.Client) Option {
    return func(c *Client) {
        c.httpClient = hc
    }
}
 
func WithRetries(n int) Option {
    return func(c *Client) {
        c.retries = n
    }
}
 
func WithLogger(l Logger) Option {
    return func(c *Client) {
        c.logger = l
    }
}
 
func New(baseURL, token string, opts ...Option) *Client {
    c := &Client{
        baseURL:    baseURL,
        token:      token,
        httpClient: http.DefaultClient,
        retries:    3,
        logger:     noopLogger{},
    }
    for _, opt := range opts {
        opt(c)
    }
    return c
}
type Client struct {
    baseURL    string
    httpClient *http.Client
    token      string
    retries    int
    logger     Logger
}
 
type Option func(*Client)
 
func WithHTTPClient(hc *http.Client) Option {
    return func(c *Client) {
        c.httpClient = hc
    }
}
 
func WithRetries(n int) Option {
    return func(c *Client) {
        c.retries = n
    }
}
 
func WithLogger(l Logger) Option {
    return func(c *Client) {
        c.logger = l
    }
}
 
func New(baseURL, token string, opts ...Option) *Client {
    c := &Client{
        baseURL:    baseURL,
        token:      token,
        httpClient: http.DefaultClient,
        retries:    3,
        logger:     noopLogger{},
    }
    for _, opt := range opts {
        opt(c)
    }
    return c
}

The call site now reads like documentation:

client := acme.New(
    "https://api.acme.io",
    os.Getenv("ACME_TOKEN"),
    acme.WithHTTPClient(customClient),
    acme.WithRetries(5),
    acme.WithLogger(logger),
)
client := acme.New(
    "https://api.acme.io",
    os.Getenv("ACME_TOKEN"),
    acme.WithHTTPClient(customClient),
    acme.WithRetries(5),
    acme.WithLogger(logger),
)

Sensible defaults live in New. Options are composable. You can add new options without breaking existing callers. This pattern stuck because it solves a real usability problem.


Never Own Your User's http.Client

This one breaks more libraries than anything else. If your client constructs its own http.Client internally and gives callers no way to override it, you've made several decisions on their behalf:

  • They can't inject a custom transport (no mTLS, no proxies, no custom dial timeouts).
  • They can't share connection pools across clients.
  • They can't wrap the transport for observability or testing.

Accept an *http.Client as an option and default to http.DefaultClient. That's it. Don't add a custom Timeout to http.DefaultClient either. That's global state mutation and it will bite someone.

func WithHTTPClient(hc *http.Client) Option {
    return func(c *Client) {
        if hc != nil {
            c.httpClient = hc
        }
    }
}
func WithHTTPClient(hc *http.Client) Option {
    return func(c *Client) {
        if hc != nil {
            c.httpClient = hc
        }
    }
}

For the same reason, never bundle a logger, metrics sink, or tracer as a direct dependency. Accept interfaces. Your users have their own structured logging setup and they do not want your logrus import showing up in their module graph.


context.Context Goes In Every Request Method

Not on the struct. On the method.

// Wrong: context stored on struct
type Client struct {
    ctx context.Context
    // ...
}
 
// Right: context passed per call
func (c *Client) GetWidget(ctx context.Context, id string) (*Widget, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        fmt.Sprintf("%s/widgets/%s", c.baseURL, id), nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }
    // ...
}
// Wrong: context stored on struct
type Client struct {
    ctx context.Context
    // ...
}
 
// Right: context passed per call
func (c *Client) GetWidget(ctx context.Context, id string) (*Widget, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        fmt.Sprintf("%s/widgets/%s", c.baseURL, id), nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }
    // ...
}

The context package documentation is explicit: do not store contexts in struct types. A context has the lifetime of a single request, not a client instance. Get this wrong and cancellation signals stop propagating properly. Deadline-aware callers in well-written services will stop trusting your library.

http.NewRequestWithContext has been available since Go 1.13. There is no reason to use http.NewRequest in a new library today.


Errors That Actually Help

An HTTP client library that returns errors.New("request failed") when it gets a 429 is useless. The caller needs to know:

  • What the HTTP status was
  • What the upstream said in the response body
  • Whether the error is retryable

Define a typed error:

// Error represents an API error response.
type Error struct {
    StatusCode int
    RequestID  string // upstream request ID if present
    Message    string
    Details    []ErrorDetail
    retryable  bool
}
 
type ErrorDetail struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}
 
func (e *Error) Error() string {
    return fmt.Sprintf("acme API error %d: %s (request_id=%s)", e.StatusCode, e.Message, e.RequestID)
}
 
func (e *Error) Retryable() bool {
    return e.retryable
}
// Error represents an API error response.
type Error struct {
    StatusCode int
    RequestID  string // upstream request ID if present
    Message    string
    Details    []ErrorDetail
    retryable  bool
}
 
type ErrorDetail struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}
 
func (e *Error) Error() string {
    return fmt.Sprintf("acme API error %d: %s (request_id=%s)", e.StatusCode, e.Message, e.RequestID)
}
 
func (e *Error) Retryable() bool {
    return e.retryable
}

Then parse it consistently in your response handler:

func parseResponse(resp *http.Response, out interface{}) error {
    defer resp.Body.Close()
 
    if resp.StatusCode >= 400 {
        var apiErr Error
        apiErr.StatusCode = resp.StatusCode
        apiErr.RequestID = resp.Header.Get("X-Request-ID")
        apiErr.retryable = resp.StatusCode == http.StatusTooManyRequests ||
            resp.StatusCode >= 500
 
        if err := json.NewDecoder(resp.Body).Decode(&apiErr); err != nil {
            apiErr.Message = http.StatusText(resp.StatusCode)
        }
        return &apiErr
    }
 
    if out != nil {
        return json.NewDecoder(resp.Body).Decode(out)
    }
    return nil
}
func parseResponse(resp *http.Response, out interface{}) error {
    defer resp.Body.Close()
 
    if resp.StatusCode >= 400 {
        var apiErr Error
        apiErr.StatusCode = resp.StatusCode
        apiErr.RequestID = resp.Header.Get("X-Request-ID")
        apiErr.retryable = resp.StatusCode == http.StatusTooManyRequests ||
            resp.StatusCode >= 500
 
        if err := json.NewDecoder(resp.Body).Decode(&apiErr); err != nil {
            apiErr.Message = http.StatusText(resp.StatusCode)
        }
        return &apiErr
    }
 
    if out != nil {
        return json.NewDecoder(resp.Body).Decode(out)
    }
    return nil
}

Callers can now type-assert to *Error, inspect the status code, and make a retry decision without parsing strings. This is what enables circuit breakers, retry budgets, and meaningful error logging.


Build Retry Logic In, But Make It Overridable

Most API clients need retries. Transient 5xx responses and rate limits are facts of distributed life. But retry logic always carries opinions: backoff strategy, jitter, max attempts, and which status codes to retry. Don't force yours on the user; let them override it.

A minimal but production-ready approach:

type RetryPolicy interface {
    ShouldRetry(attempt int, resp *http.Response, err error) bool
    Backoff(attempt int) time.Duration
}
 
type defaultRetryPolicy struct {
    maxAttempts int
}
 
func (p *defaultRetryPolicy) ShouldRetry(attempt int, resp *http.Response, err error) bool {
    if attempt >= p.maxAttempts {
        return false
    }
    if err != nil {
        return true
    }
    return resp.StatusCode == http.StatusTooManyRequests ||
        resp.StatusCode >= 500
}
 
func (p *defaultRetryPolicy) Backoff(attempt int) time.Duration {
    // Exponential backoff with a cap.
    base := 100 * time.Millisecond
    max := 10 * time.Second
    d := base * time.Duration(1<<attempt)
    if d > max {
        return max
    }
    return d
}
type RetryPolicy interface {
    ShouldRetry(attempt int, resp *http.Response, err error) bool
    Backoff(attempt int) time.Duration
}
 
type defaultRetryPolicy struct {
    maxAttempts int
}
 
func (p *defaultRetryPolicy) ShouldRetry(attempt int, resp *http.Response, err error) bool {
    if attempt >= p.maxAttempts {
        return false
    }
    if err != nil {
        return true
    }
    return resp.StatusCode == http.StatusTooManyRequests ||
        resp.StatusCode >= 500
}
 
func (p *defaultRetryPolicy) Backoff(attempt int) time.Duration {
    // Exponential backoff with a cap.
    base := 100 * time.Millisecond
    max := 10 * time.Second
    d := base * time.Duration(1<<attempt)
    if d > max {
        return max
    }
    return d
}

Wire it into the request execution loop:

func (c *Client) do(ctx context.Context, req *http.Request, out interface{}) error {
    var lastErr error
    for attempt := 0; ; attempt++ {
        resp, err := c.httpClient.Do(req.Clone(ctx))
        if err != nil {
            lastErr = err
        } else {
            lastErr = parseResponse(resp, out)
        }
 
        if lastErr == nil {
            return nil
        }
 
        if !c.retryPolicy.ShouldRetry(attempt, resp, lastErr) {
            return lastErr
        }
 
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(c.retryPolicy.Backoff(attempt)):
        }
    }
}
func (c *Client) do(ctx context.Context, req *http.Request, out interface{}) error {
    var lastErr error
    for attempt := 0; ; attempt++ {
        resp, err := c.httpClient.Do(req.Clone(ctx))
        if err != nil {
            lastErr = err
        } else {
            lastErr = parseResponse(resp, out)
        }
 
        if lastErr == nil {
            return nil
        }
 
        if !c.retryPolicy.ShouldRetry(attempt, resp, lastErr) {
            return lastErr
        }
 
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(c.retryPolicy.Backoff(attempt)):
        }
    }
}

The ctx.Done() check in the sleep means the caller's cancellation deadline is respected even during backoff. This matters.


Pagination Done Right

If your API paginates, model it. Don't make callers manage cursor state themselves. A clean iterator pattern works well in Go:

type WidgetIterator struct {
    client  *Client
    ctx     context.Context
    cursor  string
    done    bool
    widgets []Widget
    err     error
}
 
func (it *WidgetIterator) Next() bool {
    if it.done || it.err != nil {
        return false
    }
    if len(it.widgets) > 0 {
        it.widgets = it.widgets[1:]
        if len(it.widgets) > 0 {
            return true
        }
    }
    page, nextCursor, err := it.client.listWidgets(it.ctx, it.cursor)
    if err != nil {
        it.err = err
        return false
    }
    it.widgets = page
    it.cursor = nextCursor
    it.done = nextCursor == ""
    return len(it.widgets) > 0
}
 
func (it *WidgetIterator) Widget() *Widget {
    return &it.widgets[0]
}
 
func (it *WidgetIterator) Err() error {
    return it.err
}
type WidgetIterator struct {
    client  *Client
    ctx     context.Context
    cursor  string
    done    bool
    widgets []Widget
    err     error
}
 
func (it *WidgetIterator) Next() bool {
    if it.done || it.err != nil {
        return false
    }
    if len(it.widgets) > 0 {
        it.widgets = it.widgets[1:]
        if len(it.widgets) > 0 {
            return true
        }
    }
    page, nextCursor, err := it.client.listWidgets(it.ctx, it.cursor)
    if err != nil {
        it.err = err
        return false
    }
    it.widgets = page
    it.cursor = nextCursor
    it.done = nextCursor == ""
    return len(it.widgets) > 0
}
 
func (it *WidgetIterator) Widget() *Widget {
    return &it.widgets[0]
}
 
func (it *WidgetIterator) Err() error {
    return it.err
}

Usage:

iter := client.Widgets(ctx)
for iter.Next() {
    w := iter.Widget()
    // process w
}
if err := iter.Err(); err != nil {
    // handle
}
iter := client.Widgets(ctx)
for iter.Next() {
    w := iter.Widget()
    // process w
}
if err := iter.Err(); err != nil {
    // handle
}

This pattern, borrowed shamelessly from the standard library's bufio.Scanner and database/sql rows, is immediately familiar to Go engineers and handles early termination and cancellation cleanly.


Design for Testability From Day One

If your client is a concrete struct with unexported fields and no interface, testing code that uses it requires a live API or a full HTTP mock. That's friction. Reduce it.

Export an interface that covers the methods callers actually need:

// WidgetService is the interface for widget operations.
type WidgetService interface {
    GetWidget(ctx context.Context, id string) (*Widget, error)
    CreateWidget(ctx context.Context, req CreateWidgetRequest) (*Widget, error)
    DeleteWidget(ctx context.Context, id string) error
    Widgets(ctx context.Context) *WidgetIterator
}
// WidgetService is the interface for widget operations.
type WidgetService interface {
    GetWidget(ctx context.Context, id string) (*Widget, error)
    CreateWidget(ctx context.Context, req CreateWidgetRequest) (*Widget, error)
    DeleteWidget(ctx context.Context, id string) error
    Widgets(ctx context.Context) *WidgetIterator
}

Your concrete *Client implements it. Callers depend on the interface. Their tests use a mock or a stub, which they can generate with mockery or write by hand in a dozen lines.

For testing the library itself, use httptest.NewServer. It spins up a real HTTP server in-process, no external dependencies:

func TestGetWidget(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, "/widgets/abc123", r.URL.Path)
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(Widget{ID: "abc123", Name: "Sprocket"})
    }))
    defer ts.Close()
 
    client := acme.New(ts.URL, "test-token")
    widget, err := client.GetWidget(context.Background(), "abc123")
 
    assert.NoError(t, err)
    assert.Equal(t, "Sprocket", widget.Name)
}
func TestGetWidget(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, "/widgets/abc123", r.URL.Path)
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(Widget{ID: "abc123", Name: "Sprocket"})
    }))
    defer ts.Close()
 
    client := acme.New(ts.URL, "test-token")
    widget, err := client.GetWidget(context.Background(), "abc123")
 
    assert.NoError(t, err)
    assert.Equal(t, "Sprocket", widget.Name)
}

No mocking framework, no real network, no API key required in CI.


The README Is Part of the API

A library with no usage examples in the README is asking engineers to read your source code before they decide whether to adopt it. Most won't bother. There are three other libraries that solve the same problem.

Show the happy path in the first ten lines. Show error handling. Show at least one non-trivial option. If the library has known limitations or incomplete coverage of the upstream API, say so. Engineers respect honesty; they don't respect discovering limitations at 2am.

Runnable example functions in _test.go files appear directly in godoc and stay in sync with your code because they compile. Use them.


A Note on Observability Hooks

OpenTelemetry is rapidly becoming the standard for distributed tracing and metrics. You shouldn't bundle an OTel SDK as a hard dependency. Not everyone is running an OTel collector. But you should design your transport layer so it's easy to wrap.

Because you're accepting an *http.Client, callers can inject an instrumented transport:

import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
 
instrumentedClient := &http.Client{
    Transport: otelhttp.NewTransport(http.DefaultTransport),
}
 
client := acme.New(baseURL, token, acme.WithHTTPClient(instrumentedClient))
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
 
instrumentedClient := &http.Client{
    Transport: otelhttp.NewTransport(http.DefaultTransport),
}
 
client := acme.New(baseURL, token, acme.WithHTTPClient(instrumentedClient))

If your library accepts an http.Client and passes contexts correctly, observability comes for free at the call site. That's the right model. Your library doesn't need to know anything about OpenTelemetry.


A good client library is not magic. Put context.Context on every method, use functional options for configuration, return typed errors that expose what callers need, and don't own the transport layer. Get those four things right and the rest follows. The libraries that get used and trusted, like google/go-github and digitalocean/godo, do exactly this. They're not trying to be clever. They're respectful of the engineer on the other end.