Aller au contenu principal

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:

InterfaceGo versionPurpose
http.HijackerallProtocol upgrade — WebSocket, HTTP/1 keep-alive
http.FlusherallStream partial responses — SSE, chunked transfer
http.CloseNotifierdeprecated (Go 1.11+)Client disconnect notification — replaced by Request.Context()
http.PusherGo 1.8 (HTTP/2)Server-push — deprecated in practice
http.ResponseController / Unwrap()Go 1.20Introspect 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.ResponseController introduced 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 net package must be imported in files that implement Hijack(). 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.ResponseWriter wrapper struct includes Unwrap() http.ResponseWriter
  • New wrapper implements Hijack() — forwards to underlying writer or returns errHijackNotSupported
  • New wrapper implements Flush() — forwards to underlying writer or no-ops
  • Hijack() returns errHijackNotSupported (not nil, nil, nil) when unsupported
  • Unit tests cover PreservesHijacker, PreservesFlusher, and Unwrap for the new wrapper
  • http.CloseNotifier is NOT implemented in new code
  • errHijackNotSupported sentinel is reused from middleware.go — not duplicated

6. Existing compliance

As of 2026-05-23:

WrapperUnwrapHijackFlushTests
statusWriter (Logger)yesyesyesmiddleware_hijack_test.go
gzipResponseWriteryesyesyesgzip_test.go

All new wrappers created after this ADR must comply on day one.

7. Refs

  • internal/middleware/middleware.gostatusWriter, errHijackNotSupported
  • internal/middleware/gzip.gogzipResponseWriter (reference implementation)
  • internal/middleware/middleware_hijack_test.go — canonical test doubles
  • Go stdlib net/http package — Hijacker, Flusher, ResponseController
  • Go 1.20 release notes — http.ResponseController and Unwrap() protocol