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.

A replacement covers only the endpoints the app calls. Most of an SDK is code the app never uses.

Shared shape

Each client wraps only the endpoints I use. All 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.

Clients

Clients built 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