go / http clients

I prefer a small client of my own to a vendor SDK. I made the same argument for Ruby.

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))
}

SDK costs

An SDK needs upgrades for security patches and dependency conflicts. It can become unmaintained. It is one more interface to learn.

My own client covers only the endpoints the app calls. Most of an SDK is code the app never uses.

Shared shape

All my clients share one helper for retries, 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 calls the build closure once per attempt. The retry schedule is a lookup table. Each client picks its own transient codes and delays.

Tests point the HTTP client at an httptest.NewServer. Higher layers take one-method interfaces such as Uploader and inject fakes.

Narrow responses

A response struct holds only the fields a caller reads. A vendor's person record can have a hundred fields. If my code reads six, the struct has six, and encoding/json ignores the rest.

A field that nothing reads is still code. It is a name that a reader checks for callers, and a type that breaks when the vendor changes it. I went through six vendor clients and deleted each field with no reader. One client held a full employment history that another vendor's data had replaced.

I decode a nullable vendor object straight into its zero value, not through a pointer envelope:

type Organization struct {
	Name   string `json:"name"`
	Domain string `json:"primary_domain"`
}

type personResponse struct {
	Person struct {
		Title        string       `json:"title"`
		Organization Organization `json:"organization"`
	} `json:"person"`
}

A JSON null leaves the struct at its zero value, so the caller checks Organization.Domain == "" once. A *Organization makes every caller check nil first, and a missed check panics.

Clients

I built these clients this way:

Libraries I do use

A narrow, audited library for one job, such as JWT verification, is a fair trade. I avoid the vendor SDK that pulls a large dependency tree to wrap endpoints I can call in a dozen lines.

← All articles