go / http clients

I would rather write and maintain a small client than depend on an SDK specific to an API. This is the Go version of the same argument I made for Ruby.

The Go case is easier to make. The standard library net/http covers most of what an SDK wraps:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	url := os.Getenv("API_URL")
	if url == "" {
		log.Fatalln("err: API_URL environment variable is not set")
	}

	reqBody, err := json.Marshal(map[string]string{"text": "hi"})
	if err != nil {
		log.Fatalf("err: %v\n", err)
	}

	resp, err := http.Post(url, "application/json", bytes.NewBuffer(reqBody))
	if err != nil {
		log.Fatalf("err: %v\n", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatalf("err: %v\n", err)
	}

	fmt.Println(string(body))
}

Why not the SDK

SDK-style libraries have costs. They may need upgrading to patch security issues or resolve competing requirements in the dependency graph. They can be slow to update or become unmaintained. They are an additional interface for the team to learn.

When a security review motivates replacing an SDK, the rewrite is bounded by the endpoints the app actually calls. Most of an SDK's weight is code the app never used.

The shared shape

Each client wraps only the endpoints I use. They share one helper for retries and transient-code handling, so a client is mostly request-building and response-mapping:

res, err := httputil.Do(ctx, func() (*http.Request, error) {
	req, err := http.NewRequestWithContext(ctx, method, url, body)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	return req, nil
}, httputil.Config{
	Client:         c.http,
	RetryDelays:    retryDelays,
	TransientCodes: transientCodes,
})

httputil.Do takes a build closure (called once per attempt) and a config. The retry schedule is a lookup table, not exponential math. Each client picks its own transient codes and delays based on what the API does under load.

Two testing habits fall out of this shape. Point the HTTP transport at an httptest.NewServer to script status codes and bodies. Take one-method interfaces (Uploader, HTTPDoer) at the call boundary so higher layers swap in fakes without a network.

Clients

Each of these is a client I built this way, with a lesson worth keeping:

When to reach for a library

The net/http client stays. For request signing I still write the crypto by hand (see s3), but a narrow, well-audited library for a single job (JWT verification, for example) is a fair trade. The line I avoid is the all-in-one vendor SDK that pulls a large dependency tree to wrap endpoints I could call in a dozen lines.

← All articles