From 73905cca7dabb8c61164224b8e9f872fc57c396a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ngh=C4=A9a=20Nguy=E1=BB=85n=20Ng=E1=BB=8Dc?= Date: Sat, 9 May 2026 15:06:03 +0700 Subject: [PATCH] promhttp: ignore 1xx informational status codes in WriteHeader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1xx responses (e.g. 100 Continue) are not the final HTTP status. The old code recorded them as the response status and triggered observeWriteHeader, so a handler that explicitly called WriteHeader(http.StatusContinue) would report 100 as the final status code instead of 200. Fix: return early for 1xx codes after delegating to the inner ResponseWriter, without updating wroteHeader or status and without calling observeWriteHeader. This mirrors the behaviour of net/http's responseWriter (server.go). Fixes #1772 Signed-off-by: Nghĩa Nguyễn Ngọc --- prometheus/promhttp/delegator.go | 8 ++++ prometheus/promhttp/delegator_test.go | 54 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/prometheus/promhttp/delegator.go b/prometheus/promhttp/delegator.go index 315eab5f1..529590505 100644 --- a/prometheus/promhttp/delegator.go +++ b/prometheus/promhttp/delegator.go @@ -53,6 +53,14 @@ func (r *responseWriterDelegator) Written() int64 { } func (r *responseWriterDelegator) WriteHeader(code int) { + // 1xx informational responses (e.g. 100 Continue) are not the final + // status code. Delegate them to the underlying ResponseWriter but do + // not record them as the response status or call observeWriteHeader, + // mirroring the behaviour of net/http's own responseWriter. + if code >= 100 && code < 200 { + r.ResponseWriter.WriteHeader(code) + return + } if r.observeWriteHeader != nil && !r.wroteHeader { // Only call observeWriteHeader for the 1st time. It's a bug if // WriteHeader is called more than once, but we want to protect diff --git a/prometheus/promhttp/delegator_test.go b/prometheus/promhttp/delegator_test.go index 4576ae7c0..1abcd1a50 100644 --- a/prometheus/promhttp/delegator_test.go +++ b/prometheus/promhttp/delegator_test.go @@ -54,6 +54,60 @@ func (rw *responseWriter) SetReadDeadline(deadline time.Time) error { return nil } +// trackingResponseWriter records every status code passed to WriteHeader so we +// can verify that 1xx informational codes are forwarded but not recorded as the +// final status. +type trackingResponseWriter struct { + codes []int +} + +func (rw *trackingResponseWriter) Header() http.Header { return http.Header{} } +func (rw *trackingResponseWriter) Write(p []byte) (int, error) { return len(p), nil } +func (rw *trackingResponseWriter) WriteHeader(code int) { rw.codes = append(rw.codes, code) } + +// TestResponseWriterDelegatorInformationalStatusCode verifies that 1xx +// responses (e.g. 100 Continue) are forwarded to the underlying +// ResponseWriter but are NOT recorded as the final status code, mirroring +// the behaviour of net/http's own responseWriter. See GitHub issue #1772. +func TestResponseWriterDelegatorInformationalStatusCode(t *testing.T) { + var observed []int + observe := func(code int) { observed = append(observed, code) } + + inner := &trackingResponseWriter{} + rwd := &responseWriterDelegator{ + ResponseWriter: inner, + observeWriteHeader: observe, + } + + // Send 100 Continue first — should pass through to inner but not be + // recorded as the final status or trigger observeWriteHeader. + rwd.WriteHeader(http.StatusContinue) + if rwd.wroteHeader { + t.Error("wroteHeader must not be set after a 1xx informational response") + } + if rwd.status == http.StatusContinue { + t.Error("status must not be set to 100 after an informational response") + } + if len(observed) != 0 { + t.Errorf("observeWriteHeader must not be called for 1xx responses, got %v", observed) + } + if len(inner.codes) != 1 || inner.codes[0] != http.StatusContinue { + t.Errorf("100 Continue must be forwarded to the inner ResponseWriter, got %v", inner.codes) + } + + // Now send the real response. + rwd.WriteHeader(http.StatusOK) + if !rwd.wroteHeader { + t.Error("wroteHeader must be set after the final response") + } + if rwd.status != http.StatusOK { + t.Errorf("status must be 200 after the final response, got %d", rwd.status) + } + if len(observed) != 1 || observed[0] != http.StatusOK { + t.Errorf("observeWriteHeader must be called once with 200, got %v", observed) + } +} + func TestResponseWriterDelegatorUnwrap(t *testing.T) { w := &responseWriter{} rwd := &responseWriterDelegator{ResponseWriter: w}