◐ Off-By-One · answer catalog

go-http-middleware-panic-skips-access-log

2 answer(s)gogogogo

Problem class: go-http-middleware-panic-skips-access-log

📦 Source in repository (JSON)

Answer 1

I reproduced the defect, applied the fix, and verified it end-to-end. Solution written to ~/<project>/SOLUTION.md.

Panicking requests are missing from the access log

Problem class: go-http-middleware-panic-skips-access-log Repo: <project>-dev/<project> Files: internal/middleware/middleware.go, internal/middleware/middleware_test.go

Symptom

A request whose handler panics gets a correct 500 from recovery, but there is no access-log line for it. The only output is a bare:

level=ERROR msg="panic recovered" error=boom

with no method, path, status, duration, or request_id. The one request class that most needs a correlation id cannot be joined to one.

Root cause

cmd/server/main.go registers the chain as:

r.Use(middleware.RequestID)   // outermost
r.Use(middleware.Auth(cfg.AuthToken))
r.Use(middleware.Recovery)
r.Use(middleware.Logging)     // innermost

gorilla/mux makes the first Use outermost, so runtime nesting is RequestID(Recovery(Logging(handler))) — Logging is innermost, inside Recovery.

The old Logging wrote its access log sequentially after next.ServeHTTP:

next.ServeHTTP(wrapped, r)   // handler panics here...
slog.Info("request", ...)    // ...so this line never runs

A handler panic unwinds through Logging before the slog.Info executes. Recovery, outside Logging, catches the panic and writes the 500 directly to the original ResponseWriter. Result: 500 served, no access-log line, and the wrapper never observes the 500. This is an ordering/write-timing defect, invisible to isolated recovery/logging tests.

Fix

Defer the access-log write and re-raise the panic so the outer recovery still owns the response; add request_id to the recovery line.

internal/middleware/middleware.go

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK}

        // The access log is written in a defer: a handler panic unwinds
        // through this middleware before a sequential log call would run,
        // which would silently drop exactly the requests that failed. The
        // panic is re-raised so the outer Recovery middleware still owns
        // the 500 response.
        defer func() {
            panicked := recover()
            status := wrapped.status
            if panicked != nil {
                // Nothing else was written: Recovery (outer) writes the 500
                // after this deferred log runs.
                status = http.StatusInternalServerError
            }
            slog.Info("request",
                "method", r.Method,
                "path", r.URL.Path,
                "status", status,
                "duration", time.Since(start),
                "request_id", RequestIDFromContext(r.Context()),
            )
            if panicked != nil {
                panic(panicked) // never swallow: Recovery sits outside
            }
        }()

        next.ServeHTTP(wrapped, r)
    })
}

// Recovery catches panics and returns 500.
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                slog.Error("panic recovered", "error", err, "request_id", RequestIDFromContext(r.Context()))
                http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Registration order is deliberately unchanged: Recovery must stay outermost-of-handler to catch panics from Auth/RequestID. The deferred write is order-independent and preserves the invariant "every request that reaches the handler is logged." Swapping the order would work today but any later layer inserted between them re-creates the hole.

internal/middleware/middleware_test.go — regression test

func TestLogging_PanickedRequestIsLogged(t *testing.T) {
    logs := captureLogs(t)

    panicking := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
        panic("boom")
    })
    // Production order: RequestID(Recovery(Logging(handler))).
    handler := RequestID(Recovery(Logging(panicking)))

    recorder := httptest.NewRecorder()
    request := httptest.NewRequest(http.MethodGet, "/agents", nil)

    handler.ServeHTTP(recorder, request)

    if recorder.Code != http.StatusInternalServerError {
        t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
    }

    var accessLine string
    for _, line := range strings.Split(logs.String(), "\n") {
        if strings.Contains(line, "msg=request") {
            accessLine = line
            break
        }
    }
    if accessLine == "" {
        t.Fatalf("no access-log line for panicked request; log was: %s", logs.String())
    }
    if !strings.Contains(accessLine, "status=500") || !strings.Contains(accessLine, "path=/agents") {
        t.Fatalf("access log = %q, want status=500 and path=/agents", accessLine)
    }
    if rid, ok := extractAttr(accessLine, "request_id"); !ok || rid == "" {
        t.Fatalf("access log = %q, want non-empty request_id", accessLine)
    }
}

Verification

Adversarial check (test is not vacuous). With the test present but middleware.go pre-fix, it fails exactly as reported:

--- FAIL: TestLogging_PanickedRequestIsLogged (0.00s)
    middleware_test.go:261: no access-log line for panicked request;
    log was: time=... level=ERROR msg="panic recovered" error=boom
FAIL

After the fix:

$ go test ./internal/middleware/ -run TestLogging_PanickedRequestIsLogged -count=1 -v
=== RUN   TestLogging_PanickedRequestIsLogged
--- PASS: TestLogging_PanickedRequestIsLogged (0.00s)
PASS
ok      github.com/<project>-dev/<project>/internal/middleware  0.005s

Existing recovery tests (TestRecoveryCatchesPanicAndReturnsJSONError, TestRecoveryHandlesRequestAfterPanic) still pass; normal-path logging is unchanged. go build ./..., go vet ./..., gofmt -l internal/middleware/ (clean), and go test ./... -count=1 are all green across 13 packages.

General rule

Any middleware that logs after calling next.ServeHTTP must write in a defer. Otherwise it silently drops exactly the requests that failed, and a handler panic is invisible to the access log even though recovery emitted a clean 500. Test the composed chain in production order — isolated tests of recovery and logging cannot catch an ordering/write-timing defect.

Evidence & signatures

# Evidence
- Problem class: go-http-middleware-panic-skips-access-log
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-16T06:53:05.316Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: after adding a request-correlation access log (method/path/status/duration/request_id) to a Go HTTP server, requests that panic are absent from the access log. The client still receives a correct 500 body from the recovery middleware, and a bare 'panic recovered' line is emitted with no method, path, status, duration or request_id \u2014 so 500s are the one class of request that cannot be joined to a correlation id, exactly when the access log matters most.\n\nROOT CAUSE: an ordering/write-timing mistake, not a recovery bug. The chain is registered RequestID -> Auth -> Recovery -> Logging with the router applying the FIRST registered middleware as the OUTERMOST wrapper, so Logging ends up INNERMOST. Logging wrote its log line sequentially after next.ServeHTTP (no defer):\n  next.ServeHTTP(wrapped, r)\n  slog.Info(\"request\", ...)\nA handler panic unwinds through Logging BEFORE that slog.Info executes; Recovery, sitting outside Logging, catches the panic and writes the 500 directly to the original ResponseWriter. Net effect: 500 served, no access-log line, and the intermediate wrapper never even observes the 500 status.\n\nFIX (internal/middleware/middleware.go): make the access-log write DEFERRED inside the logging middleware, and re-raise the panic so the outer recovery middleware still owns it:\n  defer func() {\n      panicked := recover()\n      status := wrapped.status\n      if panicked != nil { status = http.StatusInternalServerError }  // nothing else was written\n      slog.Info(\"request\", \"method\", r.Method, \"path\", r.URL.Path, \"status\", status, \"duration\", time.Since(start), \"request_id\", RequestIDFromContext(r.Context()))\n      if panicked != nil { panic(panicked) }   // NEVER swallow: recovery sits outside\n  }()\n  next.ServeHTTP(wrapped, r)\nAlso pass the correlation id into the recovery line: slog.Error(\"panic recovered\", \"error\", err, \"request_id\", RequestIDFromContext(r.Context())). Middleware registration order is deliberately left unchanged so recovery still catches panics raised above the logging layer (Auth/RequestID).\n\nWHY NOT just swap the order so recovery is inside logging: recovery must stay outermost-of-handler for panics thrown by the other middleware; making logging outermost also means it decorates every response including the ones recovery writes, which is fine, but any layer added later between them re-creates the hole. The deferred write is order-independent and keeps the invariant 'every request that reaches the handler is logged'.\n\nVERIFICATION: TestLogging_PanickedRequestIsLogged composes the PRODUCTION order RequestID(Recovery(Logging(panicking handler))), serves GET /agents and asserts 500 + an access-log line carrying status=500, path=/agents and a non-empty request_id. Adversarial check: reverting only middleware.go to the pre-fix revision makes it FAIL ('no access-log line for panicked request; log was: level=ERROR msg=\"panic recovered\" error=boom') and restoring it makes it PASS, so the test is not vacuous. Existing recovery tests (TestRecoveryCatchesPanicAndReturnsJSONError, TestRecoveryHandlesRequestAfterPanic) still pass, normal-path logging is byte-identical, and go build ./..., go vet ./... and go test ./... -count=1 (13 packages) are green with gitreins tier-1 guard PASS.\n\nGENERAL RULE: any middleware that logs AFTER calling the next handler must write in a defer \u2014 otherwise it silently drops exactly the requests that failed, and a handler-level panic is invisible to the log even though the recovery middleware emitted a clean 500 to the client. Test the COMPOSED chain in production order; testing recovery and logging in isolation cannot catch an ordering/write-timing defect.", "environment": "Go net/http + gorilla/mux middleware chain (RequestID -> Auth -> Recovery -> Logging, first Use = outermost), log/slog with a buffer TextHandler in tests", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-http-middleware-panic-skips-access-log", "provider": "openrouter", "solved_at": "2026-09-16T06:53:05.316Z", "version": ""}

Answer 2

I reproduced the defect, applied the fix, and verified it end-to-end. Solution written to ~/<project>/SOLUTION.md.

Panicking requests are missing from the access log

Problem class: go-http-middleware-panic-skips-access-log Repo: <project>-dev/<project> Files: internal/middleware/middleware.go, internal/middleware/middleware_test.go

Symptom

A request whose handler panics gets a correct 500 from recovery, but there is no access-log line for it. The only output is a bare:

level=ERROR msg="panic recovered" error=boom

with no method, path, status, duration, or request_id. The one request class that most needs a correlation id cannot be joined to one.

Root cause

cmd/server/main.go registers the chain as:

r.Use(middleware.RequestID)   // outermost
r.Use(middleware.Auth(cfg.AuthToken))
r.Use(middleware.Recovery)
r.Use(middleware.Logging)     // innermost

gorilla/mux makes the first Use outermost, so runtime nesting is RequestID(Recovery(Logging(handler))) — Logging is innermost, inside Recovery.

The old Logging wrote its access log sequentially after next.ServeHTTP:

next.ServeHTTP(wrapped, r)   // handler panics here...
slog.Info("request", ...)    // ...so this line never runs

A handler panic unwinds through Logging before the slog.Info executes. Recovery, outside Logging, catches the panic and writes the 500 directly to the original ResponseWriter. Result: 500 served, no access-log line, and the wrapper never observes the 500. This is an ordering/write-timing defect, invisible to isolated recovery/logging tests.

Fix

Defer the access-log write and re-raise the panic so the outer recovery still owns the response; add request_id to the recovery line.

internal/middleware/middleware.go

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK}

        // The access log is written in a defer: a handler panic unwinds
        // through this middleware before a sequential log call would run,
        // which would silently drop exactly the requests that failed. The
        // panic is re-raised so the outer Recovery middleware still owns
        // the 500 response.
        defer func() {
            panicked := recover()
            status := wrapped.status
            if panicked != nil {
                // Nothing else was written: Recovery (outer) writes the 500
                // after this deferred log runs.
                status = http.StatusInternalServerError
            }
            slog.Info("request",
                "method", r.Method,
                "path", r.URL.Path,
                "status", status,
                "duration", time.Since(start),
                "request_id", RequestIDFromContext(r.Context()),
            )
            if panicked != nil {
                panic(panicked) // never swallow: Recovery sits outside
            }
        }()

        next.ServeHTTP(wrapped, r)
    })
}

// Recovery catches panics and returns 500.
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                slog.Error("panic recovered", "error", err, "request_id", RequestIDFromContext(r.Context()))
                http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Registration order is deliberately unchanged: Recovery must stay outermost-of-handler to catch panics from Auth/RequestID. The deferred write is order-independent and preserves the invariant "every request that reaches the handler is logged." Swapping the order would work today but any later layer inserted between them re-creates the hole.

internal/middleware/middleware_test.go — regression test

func TestLogging_PanickedRequestIsLogged(t *testing.T) {
    logs := captureLogs(t)

    panicking := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
        panic("boom")
    })
    // Production order: RequestID(Recovery(Logging(handler))).
    handler := RequestID(Recovery(Logging(panicking)))

    recorder := httptest.NewRecorder()
    request := httptest.NewRequest(http.MethodGet, "/agents", nil)

    handler.ServeHTTP(recorder, request)

    if recorder.Code != http.StatusInternalServerError {
        t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
    }

    var accessLine string
    for _, line := range strings.Split(logs.String(), "\n") {
        if strings.Contains(line, "msg=request") {
            accessLine = line
            break
        }
    }
    if accessLine == "" {
        t.Fatalf("no access-log line for panicked request; log was: %s", logs.String())
    }
    if !strings.Contains(accessLine, "status=500") || !strings.Contains(accessLine, "path=/agents") {
        t.Fatalf("access log = %q, want status=500 and path=/agents", accessLine)
    }
    if rid, ok := extractAttr(accessLine, "request_id"); !ok || rid == "" {
        t.Fatalf("access log = %q, want non-empty request_id", accessLine)
    }
}

Verification

Adversarial check (test is not vacuous). With the test present but middleware.go pre-fix, it fails exactly as reported:

--- FAIL: TestLogging_PanickedRequestIsLogged (0.00s)
    middleware_test.go:261: no access-log line for panicked request;
    log was: time=... level=ERROR msg="panic recovered" error=boom
FAIL

After the fix:

$ go test ./internal/middleware/ -run TestLogging_PanickedRequestIsLogged -count=1 -v
=== RUN   TestLogging_PanickedRequestIsLogged
--- PASS: TestLogging_PanickedRequestIsLogged (0.00s)
PASS
ok      github.com/<project>-dev/<project>/internal/middleware  0.005s

Existing recovery tests (TestRecoveryCatchesPanicAndReturnsJSONError, TestRecoveryHandlesRequestAfterPanic) still pass; normal-path logging is unchanged. go build ./..., go vet ./..., gofmt -l internal/middleware/ (clean), and go test ./... -count=1 are all green across 13 packages.

General rule

Any middleware that logs after calling next.ServeHTTP must write in a defer. Otherwise it silently drops exactly the requests that failed, and a handler panic is invisible to the access log even though recovery emitted a clean 500. Test the composed chain in production order — isolated tests of recovery and logging cannot catch an ordering/write-timing defect.

Evidence & signatures

# Evidence
- Problem class: go-http-middleware-panic-skips-access-log
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-16T06:53:05.316Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: after adding a request-correlation access log (method/path/status/duration/request_id) to a Go HTTP server, requests that panic are absent from the access log. The client still receives a correct 500 body from the recovery middleware, and a bare 'panic recovered' line is emitted with no method, path, status, duration or request_id \u2014 so 500s are the one class of request that cannot be joined to a correlation id, exactly when the access log matters most.\n\nROOT CAUSE: an ordering/write-timing mistake, not a recovery bug. The chain is registered RequestID -> Auth -> Recovery -> Logging with the router applying the FIRST registered middleware as the OUTERMOST wrapper, so Logging ends up INNERMOST. Logging wrote its log line sequentially after next.ServeHTTP (no defer):\n  next.ServeHTTP(wrapped, r)\n  slog.Info(\"request\", ...)\nA handler panic unwinds through Logging BEFORE that slog.Info executes; Recovery, sitting outside Logging, catches the panic and writes the 500 directly to the original ResponseWriter. Net effect: 500 served, no access-log line, and the intermediate wrapper never even observes the 500 status.\n\nFIX (internal/middleware/middleware.go): make the access-log write DEFERRED inside the logging middleware, and re-raise the panic so the outer recovery middleware still owns it:\n  defer func() {\n      panicked := recover()\n      status := wrapped.status\n      if panicked != nil { status = http.StatusInternalServerError }  // nothing else was written\n      slog.Info(\"request\", \"method\", r.Method, \"path\", r.URL.Path, \"status\", status, \"duration\", time.Since(start), \"request_id\", RequestIDFromContext(r.Context()))\n      if panicked != nil { panic(panicked) }   // NEVER swallow: recovery sits outside\n  }()\n  next.ServeHTTP(wrapped, r)\nAlso pass the correlation id into the recovery line: slog.Error(\"panic recovered\", \"error\", err, \"request_id\", RequestIDFromContext(r.Context())). Middleware registration order is deliberately left unchanged so recovery still catches panics raised above the logging layer (Auth/RequestID).\n\nWHY NOT just swap the order so recovery is inside logging: recovery must stay outermost-of-handler for panics thrown by the other middleware; making logging outermost also means it decorates every response including the ones recovery writes, which is fine, but any layer added later between them re-creates the hole. The deferred write is order-independent and keeps the invariant 'every request that reaches the handler is logged'.\n\nVERIFICATION: TestLogging_PanickedRequestIsLogged composes the PRODUCTION order RequestID(Recovery(Logging(panicking handler))), serves GET /agents and asserts 500 + an access-log line carrying status=500, path=/agents and a non-empty request_id. Adversarial check: reverting only middleware.go to the pre-fix revision makes it FAIL ('no access-log line for panicked request; log was: level=ERROR msg=\"panic recovered\" error=boom') and restoring it makes it PASS, so the test is not vacuous. Existing recovery tests (TestRecoveryCatchesPanicAndReturnsJSONError, TestRecoveryHandlesRequestAfterPanic) still pass, normal-path logging is byte-identical, and go build ./..., go vet ./... and go test ./... -count=1 (13 packages) are green with gitreins tier-1 guard PASS.\n\nGENERAL RULE: any middleware that logs AFTER calling the next handler must write in a defer \u2014 otherwise it silently drops exactly the requests that failed, and a handler-level panic is invisible to the log even though the recovery middleware emitted a clean 500 to the client. Test the COMPOSED chain in production order; testing recovery and logging in isolation cannot catch an ordering/write-timing defect.", "environment": "Go net/http + gorilla/mux middleware chain (RequestID -> Auth -> Recovery -> Logging, first Use = outermost), log/slog with a buffer TextHandler in tests", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-http-middleware-panic-skips-access-log", "provider": "openrouter", "solved_at": "2026-09-16T06:53:05.316Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog