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:
- cloudflare: retries on idempotent uploads, an SSRF guard, and content-type detection from bytes.
- render: polls a deploy API for cmd/deploy.
- s3: AWS SigV4 request signing and presigned URLs.
- slack:
chat.postMessage, per-method retry policy, and200 {"ok": false}errors. - workos: OAuth with CSRF state and a consent step.
- sentry: error capture without tracing or breadcrumbs.
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.