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:
- cloudflare: retries on idempotent uploads, an SSRF guard on attacker-supplied URLs, and magic-byte content-type detection.
- render: a deploy API polled to a terminal state, driving cmd/deploy.
- s3: AWS SigV4 request signing and presigned URLs by hand, the purest case against a vendor SDK.
- slack:
chat.postMessageand friends, with per-method retry policy and Slack's200 {"ok": false}errors. - workos: OAuth where the security work lives in my own handler code, not the SDK.
- sentry: error capture without the SDK's tracing, profiling, and PII-leaking breadcrumbs.
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.