An http.ResponseWriter wrapper logs status 0 for many requests. Why, and how do you fix it?
answer
- the handler never called that method
- an int that nobody assigned
- the server fills in a status for you
- your successful traffic is the broken bucket
- seed the field where the server would
basics
~20 sThose 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.
solid answer
~50 sA handler is not required to call `WriteHeader`. If it just calls `Write`, or returns having written nothing at all, `net/http` sends `200 OK` itself — your override never runs, and an `int` field that nobody assigned stays at its zero value, so the log and the metric say `0`. The fix is to make the wrapper's initial state match what the server will actually send: construct it with `status: http.StatusOK`, and, if you also need a `wroteHeader` latch, set the code on the first `Write` too. Guard the latch so it only takes codes of 200 and above, because `net/http` lets a handler send 1xx informational responses through `WriteHeader` before the real status. On a dashboard the symptom is unmistakable: a big bucket labelled `0` that is really your success traffic, and latency panels that look empty because they are grouped by a status nobody ever returned.
code
go · 18 linestype recorder struct {
http.ResponseWriter
status int
latched bool
}
func newRecorder(w http.ResponseWriter) *recorder {
// net/http sends 200 when a handler never calls WriteHeader,
// so 0 is not a safe starting point.
return &recorder{ResponseWriter: w, status: http.StatusOK}
}
func (r *recorder) WriteHeader(code int) {
if !r.latched && code >= 200 { // skip 1xx informational responses
r.status, r.latched = code, true
}
r.ResponseWriter.WriteHeader(code) // always forward
}go deeper
Remember that Go's int zero value is 0 and that a handler may write a body without ever calling WriteHeader; those two facts together are the whole bug.
Explain exactly when net/http sends its implicit 200 — on the first Write and on a handler that writes nothing — and show the constructor that seeds http.StatusOK.
Show how you would spot this from the metrics side rather than the code side, and how you keep the wrapper honest when a handler calls WriteHeader twice or sends a 1xx first.
Own the contract the shared layer publishes: what its status label means, what it does when nothing was written, and a test that pins it so every team's dashboard counts the same events.
## The symptom An access-log and metrics layer wraps `http.ResponseWriter`, records `status` and byte count per request, and exports a counter labelled by status. Within a day the dashboard shows a large share of requests with status `0` — a code that does not exist in HTTP — and the latency panel filtered to `status=200` is nearly empty. Nothing is broken on the wire: clients get correct responses. The defect is entirely in the recording. ## Why it happens Two facts collide. **1. `WriteHeader` is optional.** `net/http` sends the status line lazily. If the handler calls `Write` without having called `WriteHeader`, the server sends `200 OK` at that moment and sniffs a `Content-Type` if none was set. If the handler returns without writing anything at all, the server still completes the response with `200` and `Content-Length: 0`. In neither case does `WriteHeader` get called, so the wrapper's override never runs. **2. The zero value of `int` is 0.** `&recorder{ResponseWriter: w}` leaves `status` at `0`. Go has no "unset" for an `int`, and the wrapper cannot distinguish "the handler chose nothing" from "the handler chose zero" unless it says so explicitly. So every handler that takes the implicit-200 path logs `0`. The handlers that *do* log properly are the ones calling `http.Error`, `http.Redirect`, `http.NotFound` or `w.WriteHeader(http.StatusCreated)` — all of which call `WriteHeader` for you. That is why the broken bucket is usually the successful traffic and the correct-looking buckets are the errors, which is the opposite of what you would guess from the dashboard. ## The fix Seed the field with the value the server will actually send: ```go func newRecorder(w http.ResponseWriter) *recorder { return &recorder{ResponseWriter: w, status: http.StatusOK} } ``` One line, no branching, and it is correct because 200 is precisely what `net/http` will send when nobody says otherwise. Force it through a constructor rather than leaving a bare struct literal for the next person to copy, and give the field a name that does not invite `0` as a sentinel. If you also want a `wroteHeader bool` — useful to record only the code the client really received when a handler calls `WriteHeader` more than once — set it in both places: ```go func (r *recorder) WriteHeader(code int) { if !r.wroteHeader && code >= 200 { r.status = code r.wroteHeader = true } r.ResponseWriter.WriteHeader(code) } ``` Two details in that guard matter. **Always forward the call**, whatever the latch decides — the wrapper records, it never censors. And **ignore codes below 200**: `net/http` supports informational 1xx responses (`103 Early Hints`, for example) by calling `WriteHeader` with a 1xx code before the final status, so a latch that grabs the first code it sees would log `103` for a perfectly ordinary `200`. `net/http` also ignores a second *final* `WriteHeader` and writes a superfluous-`WriteHeader` warning to `Server.ErrorLog`, so latching the first final code is what keeps the log matching the wire. ## What the fix does not cover - **The client that hangs up early.** Seeding 200 is an assumption: if the connection dies before anything is flushed, you log 200 for a response nobody received. That is generally accepted, but pair the status with the byte count and the error from `Write` so a truncated response is still visible in the data. - **Handlers that write a body and then try to change the status.** The status is already on the wire; the wrapper cannot fix that and should not pretend to. - **A panic inside the handler.** The recorded status then reflects whatever went out before the panic, not the eventual outcome. ## Proving it This is trivially unit-testable and should be locked down with a test, because the failure is silent and only shows up as a wrong number on someone else's dashboard: ```go rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) w := newRecorder(rec) http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "ok") })(w, req) // w.status must be 200 even though the handler never called WriteHeader ``` Note that `httptest.NewRecorder` starts its own `Code` field at 200 for exactly the same reason, which is a decent hint that the standard library considers seeding the sane default the right answer rather than a hack. ## The general lesson A wrapper that observes another component has to model that component's **defaults**, not just its explicit calls. Anywhere the observed API has an implicit behaviour — an implicit status, an implicit content type, an implicit close — the zero value of your recording field is a lie waiting to reach a dashboard.
- Why do the error responses in the same service record their status correctly?Because `http.Error`, `http.Redirect` and `http.NotFound` all call `WriteHeader` explicitly, so the wrapper's override runs. Only the implicit-200 path skips it. That is why the dashboard looks plausible at a glance — the 4xx and 5xx buckets are right and the missing traffic is all the successes, sitting in a bucket labelled 0.
- Is seeding the wrapper with 200 ever wrong?It is an assumption, not a measurement. If the client disconnects or the handler panics before anything is flushed, you will log 200 for a response the client never got. Keep the byte count and the error returned by `Write` alongside the status so a truncated or abandoned response is still distinguishable in the data.
- Why should a status latch ignore codes below 200?A handler can send informational 1xx responses, such as 103 Early Hints, by calling `WriteHeader` with a 1xx code before the real status. A latch that keeps the first code it sees would then record 103 as the response status. Skipping anything under 200 keeps the recorded code equal to the final status line the client received.
saying these in an interview costs you the question
- Claims net/http can send an HTTP status of 0
- Calls WriteHeader(200) in the middleware before next.ServeHTTP
- Treats 0 as a valid 'no response' status on the dashboard
- Thinks a handler must always call WriteHeader
- Latches the first WriteHeader code including 1xx
- Records the status but never forwards the call