Course outline

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.

About 5 min readContent reviewed
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.

go A directly invoked health handler
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())
}
Expected output
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.

go A self-closing local HTTP round trip
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))
}
Expected output
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.

Official references