Go / Intermediate: Packages and Concurrent Work / Lesson 11
HTTP Handlers and Clients
Compose bounded HTTP behavior, test handlers without external services, and define request and resource lifetimes.
On this page
Lesson goals
- Implement and directly test a Handler.
- Use bounded clients and close response bodies.
- Separate HTTP status from transport and parsing failures.
A handler translates a protocol boundary
http.Handler receives a ResponseWriter and a Request. Validate method, path, content type, size, syntax, and domain rules before committing a response or external effect. Writing a response body commits an implicit status when no status was written, so set headers and intended status first. A handler’s return ends its request handling, not the entire server process.
Go 1.22 added method-aware ServeMux patterns and related routing changes. The first example deliberately uses that baseline feature. Compatibility settings and older language/library deployments can affect routing behavior, so label the dependency rather than assuming every Go installation understands a method-prefixed pattern.
Exercise HTTP behavior without opening a port
httptest.NewRequest and NewRecorder invoke a handler directly. This verifies routing, status, headers, and body without an external network service. It does not exercise sockets, transport timeouts, or actual server shutdown. Match the test method and path to the advertised API, and add rejected-method and malformed-input cases when expanding the example.
The program prints the status and quoted body, making the final newline visible. The handler writes a fixed synthetic response and retains no goroutines. In real handlers, inspect request.Context for cancellation and avoid starting unowned background work whose lifetime becomes disconnected from the request.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, "ready\n")
})
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
fmt.Printf("%d %q\n", recorder.Code, recorder.Body.String())
}
200 "ready\n"
The method-aware route produces status 200 and a body containing ready plus newline.
Bound the client operation and close the body
Use a reusable http.Client with an intentional timeout policy, and attach the incoming context to a request. A response with status 404 is normally not a transport error from Client.Do; inspect StatusCode explicitly. After a successful response acquisition, close Body on every path. Reusing connections also depends on consuming the body appropriately within the size policy.
The next example uses an ephemeral loopback httptest server, a bounded client, a request deadline, a limited response read, and deferred closure. It contacts no external service and closes the server before return. Its known tiny response avoids an unbounded body read while making the success output deterministic.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
func main() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "hello\n")
}))
defer server.Close()
client := server.Client()
client.Timeout = 2 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
panic(err)
}
response, err := client.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 33))
if err != nil || len(body) > 32 || response.StatusCode != http.StatusOK {
panic("invalid lab response")
}
fmt.Printf("%d %q\n", response.StatusCode, string(body))
}
200 "hello\n"
The lab server returns six bytes; the bounded client validates status and closes resources.
A service needs more than a handler
A deployed http.Server needs intentional header, read, write, and idle-timeout decisions appropriate to its protocol, plus body-size limits and bounded downstream work. A universal timeout copied into every service can break streaming or long-running operations, so derive limits from requirements. Set up graceful shutdown to stop accepting new work and wait within a separate bounded cleanup context.
Do not expose debug or profiling endpoints to untrusted networks by accident. Keep authentication, authorization, and error redaction at explicit boundaries. Synthetic handler tests should not require production credentials or live databases. An in-memory adapter can verify the domain contract while integration tests exercise transport separately.
Classify failures before choosing a response
Invalid client input, a missing resource, a downstream timeout, and an internal invariant failure are different conditions. Map documented categories to stable HTTP behavior while logging useful internal context once at the owning boundary. Do not leak raw stack traces or private storage details through a response merely because an error implements String or Error.
Test rejected methods, body limits, parsing errors, cancellation, and cleanup as well as the happy path. A handler returning 200 after partially failing can be more misleading than a visible error. Once a response is committed, a later status change cannot retroactively replace bytes already sent.
Common errors and fixes
A 404 response is treated as successful domain data.
- Why it happens
- Only the transport error was inspected.
- How to fix it
- Check StatusCode before parsing under the expected success schema.
- Prevent it next time
- Test non-2xx responses explicitly.
Connections accumulate after requests.
- Why it happens
- Response bodies are not consistently closed.
- How to fix it
- Defer closure immediately after successful response acquisition.
- Prevent it next time
- Test error paths after headers arrive.
A handler tries to change status after output began.
- Why it happens
- The body write already committed a status.
- How to fix it
- Validate first and set headers/status before writing.
- Prevent it next time
- Test the first committed response, not only the final buffer text.
Check your understanding
1. Design a test proving that oversized request content is rejected without contacting a real downstream service.
Show worked solution
Invoke the handler with httptest using a synthetic body one byte over its maximum and inject a fake downstream dependency recording calls. Require the documented rejection status and zero downstream calls. Also test exactly the maximum and ensure acquired resources are released. A body limit is meaningful only if rejection happens before an unintended side effect.
2. Why does defer response.Body.Close not solve an unbounded response-body memory allocation?
Show worked solution
Closing controls resource lifetime, not how much data is read into memory. Use a maximum-size policy, read at most limit plus one to detect oversize when appropriate, and handle read errors. A request deadline bounds time separately. All three dimensions—size, time, and closure—must be designed, because satisfying one does not imply the others.
Key takeaways
- HTTP status, transport errors, and domain failures are separate layers.
- Bound time and size, then close acquired response bodies.
- Direct handler tests and local ephemeral servers verify different boundaries.