Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.12.1] - 2026-03-30

### Fixed

- **router** — `probeWriter` no longer intercepts intentional 404/405 responses from user handlers; only unmatched routes (ServeMux's own 404/405) are routed through the `ErrorHandler`

## [0.12.0] - 2026-03-30

### Added
Expand Down
4 changes: 2 additions & 2 deletions router/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (g *Group) Handle(pattern string, handler http.Handler) {
if len(chain) > 0 {
handler = middleware.Chain(chain...)(handler)
}
g.router.mux.Handle(fullPattern, handler)
g.router.mux.Handle(fullPattern, markMatched(handler))
}

// HandleFunc registers an http.HandlerFunc for the given pattern.
Expand Down Expand Up @@ -133,7 +133,7 @@ func (g *Group) register(method, pattern string, handler http.Handler) {
if len(chain) > 0 {
handler = middleware.Chain(chain...)(handler)
}
g.router.mux.Handle(fullPattern, handler)
g.router.mux.Handle(fullPattern, markMatched(handler))
}

// collectMiddleware walks the parent chain from root to current group
Expand Down
24 changes: 20 additions & 4 deletions router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,23 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}

// probeWriter intercepts WriteHeader calls to detect 404/405 from ServeMux.
// If the status is 404 or 405 and no user handler has written to the body,
// If the status is 404 or 405 and no user handler has been matched,
// it suppresses the write so the router's ErrorHandler can produce a consistent response.
//
// Registered handlers set pw.matched = true via a thin wrapper so that intentional
// 404/405 responses from user handlers are forwarded correctly.
type probeWriter struct {
http.ResponseWriter
code int
intercepted bool
wroteBody bool
matched bool // true when a registered handler was matched by ServeMux
}

func (pw *probeWriter) WriteHeader(code int) {
// Only intercept if this is the first write (ServeMux's own 404/405).
// If a user handler already wrote body bytes, the handler owns the response.
if !pw.wroteBody && (code == http.StatusNotFound || code == http.StatusMethodNotAllowed) {
// Only intercept unmatched routes (ServeMux's own 404/405).
// If a registered handler was matched, forward the status as-is.
if !pw.matched && !pw.wroteBody && (code == http.StatusNotFound || code == http.StatusMethodNotAllowed) {
pw.code = code
pw.intercepted = true
return
Expand All @@ -122,6 +126,18 @@ func (pw *probeWriter) Write(b []byte) (int, error) {
return pw.ResponseWriter.Write(b)
}

// markMatched wraps a handler to set the matched flag on the probeWriter.
// This lets probeWriter distinguish ServeMux's default 404/405 from
// intentional 404/405 responses returned by user handlers.
func markMatched(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if pw, ok := w.(*probeWriter); ok {
pw.matched = true
}
h.ServeHTTP(w, r)
})
}

// Get registers an error-returning handler for GET requests.
func (r *Router) Get(pattern string, fn HandlerFunc) { r.group.Get(pattern, fn) }

Expand Down
Loading