HTTP and APIs — Middle¶
At middle level, focus on this question:
Where does HTTP and APIs belong in a maintainable component, and which trade-off selects the design?
Use the smallest realistic scenario that exposes the decision and its failure behavior.¶
Core Concepts¶
1. Graceful shutdown drains, it doesn't just stop¶
srv := &http.Server{Addr: ":8080", Handler: mux}
go srv.ListenAndServe()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(ctx) // stops accepting new connections, waits for in-flight ones
Shutdown stops accepting new connections immediately but lets in-flight requests complete (up to the deadline you give it). This is what makes a rolling deploy or a Kubernetes pod termination not drop live user requests.
2. The default http.Transport pools connections — don't defeat it¶
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
}
Reusing an http.Client (and its Transport) across requests is what enables connection reuse via keep-alive. Creating a new http.Client per request defeats pooling entirely, forcing a new TCP (and TLS, if HTTPS) handshake every single call — a significant, often invisible, latency and resource cost under load.
3. Retries need backoff and jitter, and a cap¶
func doWithRetry(ctx context.Context, fn func() error) error {
backoff := 100 * time.Millisecond
for i := 0; i < 5; i++ {
err := fn()
if err == nil || !isRetryable(err) {
return err
}
jitter := time.Duration(rand.Int63n(int64(backoff)))
select {
case <-time.After(backoff + jitter):
case <-ctx.Done():
return ctx.Err()
}
backoff *= 2
}
return errors.New("retries exhausted")
}
Fixed-interval retries from many clients synchronize into a "thundering herd" against a recovering downstream service. Exponential backoff with jitter spreads retries out in time.
4. Middleware ordering matters¶
Read right-to-left for execution order on the way in: withAuth runs first (closest to the actual handler in call order but often placed to run early), withRecover should wrap everything so a panic in any inner layer, including other middleware, is caught. A common bug is placing withRecover too far inside the chain, leaving earlier middleware unprotected.
5. Structure API error responses consistently¶
A consistent envelope (not sometimes-a-string, sometimes-an-object) means every client can parse errors the same way, and adding a request_id gives users a token to reference in support tickets that support/on-call can trace directly to server-side logs.
Code Examples¶
Example 1 — Panic-recovery middleware placed correctly¶
func withRecover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Must be the OUTERMOST wrapper:
handler := withRecover(withLogging(withAuth(mux)))
Example 2 — Full graceful shutdown with active-connection tracking¶
srv := &http.Server{Addr: ":8080", Handler: handler}
idleConnsClosed := make(chan struct{})
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
close(idleConnsClosed)
}()
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
<-idleConnsClosed
Example 3 — Idempotent retry-safe request¶
Retries are only safe by default for idempotent methods (GET, PUT, DELETE); retrying a POST blindly can create duplicate resources unless the endpoint is designed to be idempotent (e.g. via an idempotency key).
Best Practices¶
- Always implement graceful shutdown for any long-running server (
http.Server.Shutdown). - Create one
http.Client(with a tunedTransport) per destination and reuse it — never per-request. - Retry only idempotent operations, with exponential backoff and jitter, and a hard cap on attempts.
- Place panic-recovery middleware as the outermost wrapper in the chain.
- Standardize the API error response envelope across every endpoint.
Edge Cases & Pitfalls¶
- A shared
http.Clientwith per-request customization needs (different timeouts per call) requireshttp.NewRequestWithContextwith a per-request context deadline, not a new client. - Retrying a non-idempotent
POSTwithout an idempotency key can create duplicate side effects. - A shutdown timeout shorter than the slowest legitimate request will forcibly cut off requests anyway — tune it to your actual P99 latency plus margin.
Common Mistakes¶
| Mistake | Fix |
|---|---|
Creating a new http.Client per request | Reuse one client (and its Transport) per destination |
| Retrying without backoff/jitter | Exponential backoff + jitter, capped attempts |
withRecover placed inside other middleware | Make it the outermost wrapper |
| No graceful shutdown | Handle SIGTERM/SIGINT, call srv.Shutdown(ctx) |
Tricky Points¶
srv.Shutdowndoes not forcibly close idle keep-alive connections until they finish or the context deadline passes — a client holding an idle keep-alive connection open can delay shutdown completion up to that deadline.- Retrying with the same deadline-bound
context.Contextacross attempts means the total retry budget is capped by the original deadline, not each individual attempt's timeout — worth being explicit about which behavior you want.
Apply it¶
- Find a real component where HTTP and APIs affects an interface or dependency.
- Write two plausible choices and the constraint that favors each one.
- Make the smallest reversible change at that boundary.
- Exercise the component alone, then exercise the integrated flow.
- Keep the decision note with the evidence that selected the option.
Verify your work¶
- A focused check proves the local behavior.
- An integrated check proves callers and dependencies still agree.
- Logs, traces, compiler output, or benchmarks expose the boundary.
- Reverting the change restores the previous behavior without unrelated edits.
Review questions¶
- Which boundary is most affected by HTTP and APIs?
- What constraint would make you choose the alternative design?
- How would you isolate a local defect from an integration defect?
- What evidence shows that the change remains maintainable?
In this topic
- junior
- middle
- senior
- professional