Skip to content

fix: restore original writer so unmatched routes still return 404 - #88

Open
c879873067877881111 wants to merge 3 commits into
gin-contrib:masterfrom
c879873067877881111:fix/no-route-404-swallowed-by-buffered-writer
Open

c879873067877881111 wants to merge 3 commits into
gin-contrib:masterfrom
c879873067877881111:fix/no-route-404-swallowed-by-buffered-writer

Conversation

@c879873067877881111

Copy link
Copy Markdown

Closes #87

The bug

With the middleware registered globally via Use(), any request that doesn't match a route gets 200 OK with an empty body instead of gin's 404 page not found. Gin's own logger still prints 404, so the log and the wire disagree.

$ curl -si http://127.0.0.1:8080/no/such/route
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 0

Why

Gin handles a no-route request in serveError():

https://github.com/gin-gonic/gin/blob/v1.12.0/gin.go#L764-L779

func serveError(c *Context, code int, defaultMessage []byte) {
	c.writermem.status = code
	c.Next()                     // <- the middleware runs here
	if c.writermem.Written() { return }
	if c.writermem.Status() == code {
		c.writermem.Header()["Content-Type"] = mimePlain
		_, err := c.Writer.Write(defaultMessage)   // <- c.Writer is still the timeout Writer
		...

The status goes on c.writermem before the middleware runs, and the body is written through c.Writer after it returns. The case <-finish: branch called FreeBuffer() (setting body = nil) but left c.Writer pointing at the timeout Writer, so:

  • Write hit the w.body == nil guard and returned (0, nil) — the 404 body was silently dropped
  • tw.code was 0, so no status was flushed either, and net/http sent an implicit 200
  • the Content-Type: text/plain in the response comes from c.writermem.Header(), which is why the empty reply still looks like a text response

Registering per-route (as in _example/example01) doesn't hit this, because route handlers never run for unmatched routes — which is why the examples never caught it.

The fix

Restore c.Writer to the original writer once the buffer is flushed, the same way the panic branch on line 88 already does.

HandleMethodNotAllowed goes through the same serveError path, so the 405 response was broken in exactly the same way and is fixed by the same line.

Before / after

Same test, only timeout.go stashed:

path before after
unmatched route 200 "" 404 "404 page not found"
405 method not allowed 200 "" 405 "405 method not allowed"
matched route 200 "world" unchanged
custom NoRoute handler 404 {"err":"nope"} unchanged
unmatched + timed out 408 "Request Timeout" unchanged

Custom NoRoute handlers were never affected — they write through c.Writer while the buffer is still live, so the flush picks them up.

Added TestNoRouteWithUse and TestMethodNotAllowedWithUse. go test -race ./... and go vet ./... pass.

When the middleware is registered globally via Use(), gin runs it inside
serveError(), which sets the status on c.writermem and then writes the
default body through c.Writer after the middleware returns.

The finish branch freed the buffer but left c.Writer pointing at the
timeout Writer, so that write landed in a nil body and was silently
dropped. The status was never flushed either, so net/http sent an
implicit 200 with an empty body while gin's logger reported 404.

Restore c.Writer to the original writer once the buffer is flushed, the
same way the panic branch already does. This also fixes the 405 response
for HandleMethodNotAllowed, which goes through the same path.
Comment thread timeout.go
@zHElEARN

Copy link
Copy Markdown

I ran into a related issue while using Gin's access logger with this middleware. The response was sent correctly, but BodySize was always logged as -1 for routes using the timeout middleware.

FreeBuffer resets the timeout Writer's size to -1, while c.Writer still points to that Writer when the outer logger runs. This PR fixes the normal completion path by restoring the original Writer, but the same issue remains in the actual timeout path. Restoring c.Writer after the handler goroutine finishes fixes BodySize for timeout responses as well.

FreeBuffer() sets the timeout Writer's size to -1, and the timer branch
left c.Writer pointing at it, so middleware that inspects c.Writer after
c.Next() read -1 instead of the size of the response actually written.
gin's own logger reports that as BodySize.

Restore the writer next to c.Abort(), after the wait on the handler
goroutine, since that is the point where touching c is safe.

Reported by @zHElEARN.
@c879873067877881111

Copy link
Copy Markdown
Author

You're right about the timeout path, and d09a53c fixes it. Thanks for catching it — I only checked what serveError does with c.Writer and never asked what a middleware outside this one reads after c.Next().

I put the restore next to c.Abort() rather than earlier in the branch. The timer branch cannot touch c until the wait on finish/panicChan returns, since the handler goroutine may still be reading it; that is the same reason c.index is only modified there.

One thing worth separating out, since it looks like the same bug but isn't. Measuring with an outer middleware in the same position as gin.Logger():

case before #88 with #88 with d09a53c
normal completion -1 5 5
timed out -1 -1 15
no route -1 -1 -1

The no-route -1 is not this middleware. Plain gin with no timeout middleware at all reports -1 there too, because serveError writes the default body after c.Next() has returned through every middleware — so an outer logger has already read the size by then. Leaving that alone; it is gin's behaviour, not something to paper over here.

Added TestOuterMiddlewareSeesResponseSize pinning the timeout case. go test -race ./... and go vet ./... pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unmatched routes respond 200 OK with empty body when middleware is registered globally via Use()

2 participants