Proposal Details

Go 1.22 introduced an enhanced HTTP routing. The current implementation utilizes unexported handlers for 404 and 405 responses. However, if there is a need for a custom 404 response (e.g., in a REST API with JSON responses), it is no longer possible to use a 'catch-all' pattern /, as it prevents the ability to return a 405 response. This issue is elaborated further in this discussion.

To address this challenge, one can define a custom http.ResponseWriter to intercept responses and their status codes and handle them appropriately. Nonetheless, this approach precludes the ability to return a custom 404 response based on whether the http.ServeMux couldn't locate the appropriate route or if the user's handler returned a response with a 404 status code. An example scenario is when responding to GET user/{id} for a non-existent user in the system.

Given these challenges, I believe it would be valuable to register custom 404 and 405 handlers.

Comment From: seankhliao

see also #21548

Do you have a concrete proposal for the api?

Comment From: polyscone

I just landed here whilst trying to find out if this was supported or not.

This is my current workaround using a catch-all / pattern and the http.ServeMux.Handler method.

methods := []string{
    http.MethodGet,
    http.MethodHead,
    http.MethodPost,
    http.MethodPut,
    http.MethodPatch,
    http.MethodDelete,
    http.MethodConnect,
    http.MethodOptions,
    http.MethodTrace,
}

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    method := r.Method
    _, current := mux.Handler(r)
    var allowed []string
    for _, method := range methods {
        // If we find a pattern that's different from the pattern for the
        // current fallback handler then we know there are actually other handlers
        // that could match with a method change, so we should handle as
        // method not allowed
        r.Method = method
        if _, pattern := mux.Handler(r); pattern != current {
            allowed = append(allowed, method)
        }
    }

    r.Method = method

    if len(allowed) != 0 {
        w.Header().Set("allow", strings.Join(allowed, ", "))

        http.Error(w, "Custom Method Not Allowed", http.StatusMethodNotAllowed)

        return
    }

    http.Error(w, "Custom Not Found", http.StatusNotFound)
})

I haven't thought about it too deeply, so I don't know if there are cases where this workaround would be lacking.

I don't have any strong feelings about how an API might look, or even if it's needed at all, but I wanted to show at least one solution that's working for me in my simple use-cases at the moment, and doesn't require changes to the existing serve mux API.

Comment From: ianlancetaylor

CC @jba

Comment From: denpeshkov

@polyscone At the moment, I'm using this workaround: https://github.com/denpeshkov/greenlight/blob/c68f5a2111adcd5b1a65a06595acc93a02b6380e/internal/http/middleware.go#L16-L71

However, as I mentioned earlier, this approach doesn't allow me to handle 404 errors differently depending on whether the route is actually registered or if my handler just returns a 404 (e.g., when a user with a given ID is not found).

Comment From: polyscone

@denpeshkov Thanks for the link; this is roughly what I imagined you were doing based on your original explanation.

Does the catch-all handler that I use in my own services not solve your problem?

In the workaround I suggested your handlers are free to respond in any way they need to, and the catch-all is where you can implement generic 404 and 405 responses, all without the need for any middleware that wraps http.ResponseWriter.

If you genuinely also needed to use the catch-all / route for other purposes you can also call a custom function in-between handling for 404 and 405.

Comment From: denpeshkov

@polyscone Yeah, I think your approach should be working. It just seems that you're reimplementing functionality that is already provided by ServeMux (handling 404 and 405). So I thought that adding the ability to specify custom handlers, like, for example, is done here: https://pkg.go.dev/github.com/julienschmidt/httprouter#Router, might be a good alternative.

Comment From: jba

An example scenario is when responding to GET user/{id} for a non-existent user in the system.

@denpeshkov, I don't see how this is an example of your problem. If the user is not in the system, the handler for GET user/{id} should serve whatever it wants, including custom page with a 404 status.

Comment From: jba

@denpeshkov, I read more deeply so I think I understand: you are calling the handler and using its response, but you don't know if a 404 is "I couldn't find a matching route" or "I couldn't find the user with that ID." So ignore my comment.

Comment From: jba

To summarize:

Before Go 1.22, you could produce a custom 404 page by registering it on "/". A net/http web server would never serve a 405 (unless user code did).

As of Go 1.22, you can also produce a custom 404 page using "/". Everything will work as it did pre-1.22: no 405s will be served.

So the only problem is that a custom 404 masks the new 405 behavior. How much does that matter?

Well, no one cared about 405s before Go 1.22. So maybe very little.

Also—this is conjecture and I would love a counterexample—if you're serving a custom 404 it's because your service is facing users and you want to show them a branded page of some kind. But 405s are not interesting to humans because they aren't typing raw PUT and DELETE requests to HTTP servers. They are only helpful to other computers. So maybe the use cases for custom 404s and automatic 405s are disjoint.

(I'm aware of one use case for serving a custom 404 to a computer: if you want your server to return only JSON. But that's probably unrealistic and also hopeless with the existing net/http package, because there are many places in the code that send a text response.)

If we allow people to customize 404, then we really should provide hooks for all errors, and custom error hooks have already been proposed and rejected.

So I don't see a clear way forward here, but I also don't see a burning problem. Please correct me if I'm wrong on either count.

/cc @neild @bradfitz

Comment From: denpeshkov

Hi, @jba. Yes, you are right. The problem I was facing is developing a 'JSON-only' REST API. I looked into some popular REST services (GitHub API and Stripe API), and it seems they don't send 405 responses. I think I am going to use a catch-all route as in the pre-1.22 release. Thank you for your response

Comment From: Rican7

So yea, I understand why this maybe hasn't been prioritized, but the workaround to get the desired behavior feels like a hack for sure.

I've figured out that hack, at least, in case anyone else comes across this:

package httpmux

import (
    "net/http"
)

// HeaderFlagDoNotIntercept defines a header that is (unfortunately) to be used
// as a flag of sorts, to denote to this routing engine to not intercept the
// response that is being written. It's an unfortunate artifact of an
// implementation detail within the standard library's net/http.ServeMux for how
// HTTP 404 and 405 responses can be customized, which requires writing a custom
// response writer and preventing the standard library from just writing it's
// own hard-coded response.
//
// See:
//   - https://github.com/golang/go/issues/10123
//   - https://github.com/golang/go/issues/21548
//   - https://github.com/golang/go/issues/65648
const HeaderFlagDoNotIntercept = "do_not_intercept"

type excludeHeaderWriter struct {
    http.ResponseWriter

    excludedHeaders []string
}

func (w *excludeHeaderWriter) WriteHeader(statusCode int) {
    for _, header := range w.excludedHeaders {
        w.Header().Del(header)
    }

    w.ResponseWriter.WriteHeader(statusCode)
}

type routingStatusInterceptWriter struct {
    http.ResponseWriter

    intercept404 func() bool
    intercept405 func() bool

    statusCode  int
    intercepted bool
}

func (w *routingStatusInterceptWriter) WriteHeader(statusCode int) {
    if w.intercepted {
        return
    }

    w.statusCode = statusCode

    if (w.intercept404() && statusCode == http.StatusNotFound) ||
        (w.intercept405() && statusCode == http.StatusMethodNotAllowed) {

        w.intercepted = true
        return
    }

    w.ResponseWriter.WriteHeader(statusCode)
}

func (w *routingStatusInterceptWriter) Write(data []byte) (int, error) {
    if w.intercepted {
        return 0, nil
    }

    return w.ResponseWriter.Write(data)
}

type Router struct {
    *http.ServeMux

    NotFoundHandler         http.HandlerFunc
    MethodNotAllowedHandler http.HandlerFunc
}

// ServeHTTP dispatches the request to the router.
func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    interceptor := &routingStatusInterceptWriter{
        ResponseWriter: &excludeHeaderWriter{
            ResponseWriter: w,

            excludedHeaders: []string{HeaderFlagDoNotIntercept},
        },

        intercept404: func() bool {
            return rt.NotFoundHandler != nil && w.Header().Get(HeaderFlagDoNotIntercept) == ""
        },
        intercept405: func() bool {
            return rt.MethodNotAllowedHandler != nil && w.Header().Get(HeaderFlagDoNotIntercept) == ""
        },
    }

    rt.ServeMux.ServeHTTP(interceptor, r)

    switch {
    case interceptor.intercepted && interceptor.statusCode == http.StatusNotFound:
        rt.NotFoundHandler.ServeHTTP(interceptor.ResponseWriter, r)
    case interceptor.intercepted && interceptor.statusCode == http.StatusMethodNotAllowed:
        rt.MethodNotAllowedHandler.ServeHTTP(interceptor.ResponseWriter, r)
    }
}

... which you can use like this:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
)

func main() {
    jsonErrorHandler := func(statusCode int) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Content-Type", "application/json; charset=utf-8")
            w.WriteHeader(statusCode)

            json.NewEncoder(w).Encode(struct{ Intercepted bool }{Intercepted: true})
        }
    }

    rt := &httpmux.Router{
        ServeMux: http.NewServeMux(),

        NotFoundHandler:         jsonErrorHandler(404),
        MethodNotAllowedHandler: jsonErrorHandler(405),
    }

    rt.HandleFunc("GET /foo", func(w http.ResponseWriter, r *http.Request) {
        // Set this flag to be able to return a 404 without having it be rewritten
        w.Header().Set(httpmux.HeaderFlagDoNotIntercept, "true")

        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        w.WriteHeader(200)

        json.NewEncoder(w).Encode(struct{ Intercepted bool }{Intercepted: false})
    })

    for _, req := range []*http.Request{
        httptest.NewRequest("GET", "/foo", nil),
        httptest.NewRequest("GET", "/bar", nil),
        httptest.NewRequest("DELETE", "/foo", nil),
    } {
        w := httptest.NewRecorder()
        rt.ServeHTTP(w, req)

        resp := w.Result()
        body, _ := io.ReadAll(resp.Body)

        fmt.Printf("%s %s\n", req.Method, req.URL)
        fmt.Println(resp.StatusCode)
        fmt.Println(resp.Header.Get("Content-Type"))
        fmt.Println(string(body))
        fmt.Println("-------------------------------")
    }
}

(Check it out in the Go Playground)

Comment From: kevinbungeneers

Hi there! I was faced with the exact same issue: I wanted to have a custom 4xx or 5xx user-facing page and quickly came across this issue.

I figured something out using middleware instead of going down the catch-all route (heh, pun intended). It looks something like this:

package middleware

import (
    "bytes"
    "net/http"
)

type bufferedWriter struct {
    http.ResponseWriter
    buffer *bytes.Buffer
    code   int
}

func (b *bufferedWriter) WriteHeader(code int) {
    b.code = code
}

func (b *bufferedWriter) Write(data []byte) (int, error) {
    return b.buffer.Write(data)
}

func HttpError(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ww := bufferedWriter{ResponseWriter: w, buffer: &bytes.Buffer{}}
        next.ServeHTTP(&ww, r)

        if ww.code != 200 {
            w.Write([]byte("custom content goes here"))
        }
    })
}

It's maybe a bit of a naive solution, but it offers more flexibility imo.

Comment From: bentcoder

This is my temporary workaround for 404 and 405 for JSON APIs which assumes all response content type is JSON.

func main() {
    rtr := http.NewServeMux()
    // ...

    http.ListenAndServe(":8080", BodyOverride(rtr))
}

type BodyOverrider struct {
    http.ResponseWriter

    code     int
    override bool
}

func (b *BodyOverrider) WriteHeader(code int) {
    if b.Header().Get("Content-Type") == "text/plain; charset=utf-8" {
        b.Header().Set("Content-Type", "application/json")

        b.override = true
    }

    b.code = code
    b.ResponseWriter.WriteHeader(code)
}

func (b *BodyOverrider) Write(body []byte) (int, error) {
    if b.override {
        switch b.code {
        case http.StatusNotFound:
            body = []byte(`{"code": "route_not_found"}`)
        case http.StatusMethodNotAllowed:
            body = []byte(`{"code": "method_not_allowed"}`)
        }
    }

    return b.ResponseWriter.Write(body)
}
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Content-Length: 27

{"code": "route_not_found"}
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json; charset=utf-8
Content-Length: 30

{"code": "method_not_allowed"}

Comment From: SampsonCrowley

I'm a little confused on why this is considered a difficult proposal...

Couldn't it be as simple as 2 new attributes on the ServeMux interface?

  • NotFoundHandler http.Handler
  • MethodNotAllowedHandler http.Handler

and the default not found just checks, if NotFoundHandler != nil; call it; else continue with default? (and same for MethodNotAllowedHandler?)

It just doesn't seem like something that difficult to implement and the benefits to end users are high for those that are currently hacking around catchalls and response writers that no longer have to do that

Comment From: alexedwards

I agree with @SampsonCrowley's comment above, but with one thing to add: a nice thing about the default 405 behavior is that it automatically sets the correct Allow header.

I'd like to see the Allow header continue to automatically be set on 405 responses even if the custom MethodNotAllowedHandler is set. If, for some reason, someone ever needs to send a custom 405 response without an Allow header, I think they could always call w.Header().Del("Allow") in their custom handler to remove it.