skip to content

Wrapping ResponseWriter

Logging middleware wraps http.ResponseWriter to capture the status code, and a naive wrapper silently hides Flusher and Hijacker from the handler. http.ResponseController is the answer they want.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

Why does a wrapper struct embedding http.ResponseWriter fail optional-interface assertions the underlying writer passes?

level: middleimportance: must knowfreq 52%

answer

  1. the handler now holds your type
  2. embedding an interface promotes three methods
  3. comma-ok fails quietly, nothing errors
  4. one method lets the controller see through
  5. Unwrap returns http.ResponseWriter

basics

~20 s

Embedding the http.ResponseWriter interface promotes only its three methods, so the wrapper's own type satisfies nothing else. Assertions for optional capabilities silently take the false branch. Give the wrapper an Unwrap method returning http.ResponseWriter and let callers use http.NewResponseController.

solid answer

~40 s

`net/http`'s own response writer implements more than `http.ResponseWriter` — optional interfaces such as `http.Flusher`, `http.Hijacker` and `io.ReaderFrom` are discovered by type assertion. When a middleware substitutes `&recorder{ResponseWriter: w}`, the value the handler holds has the dynamic type `*recorder`, which by embedding gained `Header`, `Write` and `WriteHeader` and nothing more, so `if f, ok := v.(http.Flusher); ok` is now `false`. Nothing errors: the comma-ok form is designed to degrade quietly, so the capability just disappears and the behaviour change surfaces much later. The modern fix is `http.ResponseController`: your wrapper implements `Unwrap() http.ResponseWriter`, and callers do `rc := http.NewResponseController(w)` and call methods on it, which walks the `Unwrap` chain to a writer that supports the operation and returns an error matching `http.ErrNotSupported` when none does.

code

go · 7 lines
go
type recorder struct {
	http.ResponseWriter
	status int
}

// Without this, http.NewResponseController cannot see past the wrapper.
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }

go deeper

for a junior

Know that the value the handler receives is the middleware's own struct type, and that a type assertion for anything beyond Header, Write and WriteHeader will not match it.

for a middle

Explain why the failure is silent under comma-ok, write the one-line Unwrap method, and describe how http.NewResponseController walks the chain and reports http.ErrNotSupported.

for a senior

Show that you would treat adding a wrapper as a behavioural change: audit what callers assert on the writer, decide what to forward, and prove the capability set with a test rather than assuming.

for a principal

Own the migration: whether the organisation moves callers to http.ResponseController or carries exact-capability forwarding in the shared layer, and who absorbs the cost of handlers you cannot edit.

## Two method sets, one substitution `net/http` hands a handler a value whose concrete type implements far more than the `http.ResponseWriter` interface says. That extra behaviour is exposed as **optional interfaces**, discovered by asserting on the value at runtime: - `http.Flusher` — a `Flush()` method. - `http.Hijacker` — a `Hijack()` method returning the raw connection. - `io.ReaderFrom` — a `ReadFrom(io.Reader)` method the standard library uses as a fast write path. When middleware wraps the writer, the handler no longer holds `net/http`'s value; it holds yours. And a struct that embeds an **interface** gets exactly the methods that interface declares — three of them — promoted onto its own method set. `*recorder` therefore does not implement `http.Flusher`, no matter what the value stored inside it can do. ## Why it is silent Capability detection in Go is the comma-ok assertion: ```go if f, ok := w.(http.Flusher); ok { f.Flush() } ``` That is deliberately non-fatal — the whole point is to work with writers that lack the capability. So a wrapper inserted anywhere in the chain turns a working code path into a skipped one with no error, no log line and no compile failure. This is the single most common regression caused by response-writer middleware, and it is why reviewers treat a new wrapper as a behavioural change rather than a cosmetic one. Note the direction of the damage: the wrapper does not *break* the underlying writer, which is still perfectly capable. It hides it. ## The Unwrap convention and http.ResponseController The standard library's answer is `http.ResponseController`. A caller writes: ```go rc := http.NewResponseController(w) err := rc.SetWriteDeadline(time.Now().Add(30 * time.Second)) ``` `ResponseController` exposes the optional operations as ordinary methods that return an `error`. To find something able to perform each one, it inspects the writer it was given and, if that writer cannot do the job, looks for a method: ```go Unwrap() http.ResponseWriter ``` and repeats on whatever comes back. So a middleware wrapper's obligation is one line: ```go func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } ``` With that in place, any number of wrappers can be stacked and a caller using `ResponseController` still reaches a writer that can serve the request. If nothing in the chain can, the method returns an error matching `http.ErrNotSupported` — an explicit failure the caller can log or convert into a 500, instead of a silently skipped branch. Handle that error; do not discard it. ## What Unwrap does *not* do This is the part candidates most often get wrong. `Unwrap` is a convention that `ResponseController` knows about. It does **not** change your wrapper's method set, so a handler doing a direct `w.(http.Flusher)` assertion still fails. Implementing `Unwrap` fixes the callers who migrate to `ResponseController`; it does nothing for code you did not change, including library code you import and cannot edit. Nor does `ResponseController` cover every optional interface — it has no method for `io.ReaderFrom`, so a wrapper that wants to keep that fast path has to declare `ReadFrom` itself. ## Forwarding by hand, and why it gets ugly The alternative is to declare the extra methods on the wrapper so its own type satisfies the interfaces again. That restores direct assertions, but two problems follow. First, **it can lie**. If the wrapper unconditionally declares `Hijack`, every probe now succeeds even when the writer underneath cannot hijack — for example on an HTTP/2 connection — and a compile-time-looking capability check becomes a runtime error deep inside a handler. Forwarding honestly means constructing a wrapper whose type matches the exact capability set of the writer it wraps, which is a switch over every combination of the interfaces you care about: two interfaces mean four types, three mean eight. That is why such code is usually generated rather than written. Second, **it is a moving target**. Each optional interface added to `net/http` is one more combination to handle, whereas an `Unwrap` method keeps working. ## What to do in practice Always implement `Unwrap` — it costs one line and has no downside. Point handlers you control at `http.ResponseController` and handle its error. Only if you must keep unmigrated or third-party handlers working do you take on exact-capability forwarding, and then you write it once, in the shared layer, with tests that assert the wrapper's capability set equals the wrapped writer's rather than a fixed list.

  • Does implementing Unwrap fix a handler that does a direct type assertion on the writer?
    No. `Unwrap` is a convention `http.ResponseController` follows; it does not add methods to your wrapper's type, so `w.(http.Flusher)` still fails. Only callers rewritten to use `http.NewResponseController` benefit. Code you import and cannot edit keeps taking the silent false branch, which is exactly why rolling a wrapper out across handlers you do not own needs a migration plan.
  • What happens when no writer in the Unwrap chain supports the requested operation?
    The `http.ResponseController` method returns a non-nil error matching `http.ErrNotSupported` rather than panicking or silently doing nothing. That is the main ergonomic win over comma-ok probing: the failure is a value you can log, surface as a 500, or fall back from, instead of a branch that quietly never ran.
  • Why is unconditionally declaring the optional methods on the wrapper risky?
    Because the wrapper then claims a capability it may not have. A probe that used to correctly report "this connection cannot be hijacked" now succeeds, and the failure moves from a cheap check into a runtime error inside a handler. Honest forwarding means matching the exact capability set of the writer you wrapped, which multiplies types combinatorially.

Putting a plain envelope around a letter does not make the envelope waterproof just because the letter inside was printed on waterproof paper. Anyone checking the outside sees only what the envelope itself can do.

saying these in an interview costs you the question

  • Thinks embedding an interface promotes the dynamic type's extra methods
  • Expects a compile error when a wrapper drops a capability
  • Believes Unwrap alone repairs direct type assertions
  • Claims http.ResponseController covers io.ReaderFrom
  • Declares Hijack on every wrapper regardless of what it wraps
  • Ignores the error returned by http.ResponseController methods
open as a page

An http.ResponseWriter wrapper logs status 0 for many requests. Why, and how do you fix it?

level: middleimportance: must knowfreq 58%

basics

~20 s

Those handlers never called WriteHeader, so the wrapper's status field kept the int zero value while net/http sent an implicit 200. Seed the field with http.StatusOK when constructing the wrapper, or set it on the first Write.

open as a page

Why must access-log middleware wrap http.ResponseWriter to record a response's status code?

level: juniorimportance: should knowfreq 48%

basics

~20 s

http.ResponseWriter is write-only: Header, Write and WriteHeader, with no getter for what was sent. Middleware passes the handler a struct that embeds the writer and overrides those methods, so it can record the status code and the byte count.

open as a page

Your platform team owns the http.ResponseWriter wrapper every service runs behind: how do you decide whether it forwards optional interfaces?

level: principalimportance: should knowfreq 30%

basics

~20 s

Decide from what handlers you cannot edit actually assert on the writer. Always implement Unwrap, since it costs nothing. Then choose between exact-capability forwarding, which preserves behaviour at the cost of generated complexity, and a migration to http.ResponseController that other teams have to fund.

open as a page

A byte-counting http.ResponseWriter wrapper made io.Copy of a large file to the client slower. Why?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

net/http's own response writer implements io.ReaderFrom, and io.Copy uses that fast path. The wrapper's type does not, so io.Copy fell back to a 32 KiB buffered loop of Write calls. Declare ReadFrom on the wrapper, delegate, and add the returned count.

open as a page