ADR-029 — ResponseWriter Wrapper Forwarding Rules
1. Context
noxys-api uses a chain of Go HTTP middleware wrappers (Logger, Gzip, Trace, billing gate, LLM quota, etc.). Each middleware wraps http.ResponseWriter to intercept writes and capture status codes. In Go, http.ResponseWriter is the minimal interface — it exposes only Header(), Write(), and WriteHeader(). The standard library defines several optional extension interfaces on top of it:
| Interface | Go version | Purpose |
|---|---|---|
http.Hijacker | all | Protocol upgrade — WebSocket, HTTP/1 keep-alive |
http.Flusher | all | Stream partial responses — SSE, chunked transfer |
http.CloseNotifier | deprecated (Go 1.11+) | Client disconnect notification — replaced by Request.Context() |
http.Pusher | Go 1.8 (HTTP/2) | Server-push — deprecated in practice |
http.ResponseController / Unwrap() | Go 1.20 | Introspect through a wrapper chain |
The problem: A wrapper that does not forward http.Hijacker causes a runtime panic or silent failure when a downstream handler calls http.Upgrade (WebSocket endpoint /api/v1/ws). A wrapper that does not forward http.Flusher causes SSE streams to buffer instead of flush, breaking real-time log tailing and event delivery. Both bugs are invisible at compile time and surface only under production load.
Before this ADR was enforced, the Logger middleware statusWriter in middleware.go initially lacked the Hijack() passthrough — code reviewed and fixed in the same session that produced this ADR. The pattern was then codified as a permanent constraint.
2. Decision
2.1 Mandatory Unwrap() method (Go 1.20+)
Every http.ResponseWriter wrapper struct in noxys-api MUST implement:
func (w *myWrapper) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
This allows http.ResponseController (Go 1.20) and any future stdlib helpers to reach through the chain. It is a zero-cost method — a single field dereference.
2.2 Mandatory Hijack() forwarding
Every wrapper MUST implement Hijack() with a soft-fail fallback:
func (w *myWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, errHijackNotSupported
}
errHijackNotSupported is a package-level sentinel defined once in middleware.go. Do not return nil, nil, nil — that silently succeeds with a nil connection.
2.3 Mandatory Flush() forwarding
Every wrapper MUST implement Flush() with a no-op fallback:
func (w *myWrapper) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
No-op is safe: callers of http.Flusher check whether the interface is present before casting. The risk is not no-oping flush — it is hiding the interface behind a wrapper that panics when called.
2.4 CloseNotifier — do not forward
http.CloseNotifier is deprecated. Do not implement it in new wrappers. Existing code that depends on it must migrate to Request.Context().Done().
2.5 Pusher (HTTP/2 server push) — skip
HTTP/2 push is deprecated in most clients and disabled by default in Go's net/http server. Do not implement http.Pusher in new wrappers.
3. Consequences
Positive
- WebSocket upgrades work through any middleware layer without silent failures.
- SSE endpoints flush correctly regardless of which middleware stack is active.
http.ResponseControllerintroduced in Go 1.20 can introspect any wrapper in the chain.- Interface forwarding is now a code review checklist item — future wrappers cannot be merged without it.
Negative / tradeoffs
- Every new wrapper struct requires three additional small methods (
Unwrap,Hijack,Flush). This is boilerplate, but the cost is trivial compared to the debugging time a silent omission causes. - The
netpackage must be imported in files that implementHijack(). This is a minor dependency surface increase.
4. Unit test pattern
Every wrapper struct MUST have a corresponding test that verifies interface forwarding. Use the fakeHijackerRecorder and fakeFlusherRecorder doubles defined in middleware_hijack_test.go as the canonical pattern:
// Verify Hijacker is preserved through the wrapper.
func TestMyWrapper_PreservesHijacker(t *testing.T) {
inner := &fakeHijackerRecorder{ResponseRecorder: httptest.NewRecorder()}
w := &myWrapper{ResponseWriter: inner}
h, ok := http.ResponseWriter(w).(http.Hijacker)
if !ok {
t.Fatal("myWrapper must implement http.Hijacker")
}
if _, _, err := h.Hijack(); err != nil {
t.Fatalf("Hijack returned unexpected error: %v", err)
}
if !inner.hijackCalled {
t.Fatal("Hijack did not reach underlying ResponseWriter")
}
}
// Verify Flusher is preserved through the wrapper.
func TestMyWrapper_PreservesFlusher(t *testing.T) {
inner := &fakeFlusherRecorder{ResponseRecorder: httptest.NewRecorder()}
w := &myWrapper{ResponseWriter: inner}
f, ok := http.ResponseWriter(w).(http.Flusher)
if !ok {
t.Fatal("myWrapper must implement http.Flusher")
}
f.Flush()
if !inner.flushed {
t.Fatal("Flush did not reach underlying ResponseWriter")
}
}
// Verify Unwrap reaches the inner writer.
func TestMyWrapper_Unwrap(t *testing.T) {
inner := httptest.NewRecorder()
w := &myWrapper{ResponseWriter: inner}
if w.Unwrap() != inner {
t.Fatal("Unwrap must return the inner ResponseWriter")
}
}
These tests go in the same _test.go file as the wrapper implementation, in the middleware package (not middleware_test), so they access unexported types.
5. Code review checklist
When reviewing any Go HTTP middleware PR in noxys-api, verify:
- New
http.ResponseWriterwrapper struct includesUnwrap() http.ResponseWriter - New wrapper implements
Hijack()— forwards to underlying writer or returnserrHijackNotSupported - New wrapper implements
Flush()— forwards to underlying writer or no-ops -
Hijack()returnserrHijackNotSupported(notnil, nil, nil) when unsupported - Unit tests cover
PreservesHijacker,PreservesFlusher, andUnwrapfor the new wrapper -
http.CloseNotifieris NOT implemented in new code -
errHijackNotSupportedsentinel is reused frommiddleware.go— not duplicated
6. Existing compliance
As of 2026-05-23:
| Wrapper | Unwrap | Hijack | Flush | Tests |
|---|---|---|---|---|
statusWriter (Logger) | yes | yes | yes | middleware_hijack_test.go |
gzipResponseWriter | yes | yes | yes | gzip_test.go |
All new wrappers created after this ADR must comply on day one.
7. Refs
internal/middleware/middleware.go—statusWriter,errHijackNotSupportedinternal/middleware/gzip.go—gzipResponseWriter(reference implementation)internal/middleware/middleware_hijack_test.go— canonical test doubles- Go stdlib
net/httppackage —Hijacker,Flusher,ResponseController - Go 1.20 release notes —
http.ResponseControllerandUnwrap()protocol