fix: prevent duplicate log entries on request context cancellation - #2
Open
1RB wants to merge 2 commits into
Open
fix: prevent duplicate log entries on request context cancellation#21RB wants to merge 2 commits into
1RB wants to merge 2 commits into
Conversation
When a request's context is canceled (client disconnect or server shutdown), the logging middleware now emits exactly one log entry instead of two. Changes: - Add responseLogger with atomic.CompareAndSwapInt32 guard (thread-safe) - WriteLog() uses CAS to ensure only the first caller logs — prevents duplicate entries from the deferred handler unwind + cancellation watcher - Context cancellation watcher goroutine logs with 499 status - DefaultLogger maps context.Canceled/DeadlineExceeded to status 499 - Normal completion still logs with the handler's status code Test coverage (6 tests, all passing with -race): - Exactly one log entry on context cancellation - Exactly one log entry on normal completion - Canceled request logs 499 status - Normal request logs 200 status - Thread safety: 10 concurrent WriteLog calls produce exactly 1 entry - Multiple requests each produce exactly one log entry Fixes rachelealicek#1
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent duplicate request-completion log entries when an HTTP request context is canceled (e.g., client disconnects / graceful shutdown) by guarding logging so it only happens once per request.
Changes:
- Replaced the prior
main.go“hello world” with a custom logging middleware andresponseLoggerthat uses an atomic CAS guard to prevent duplicate logs. - Added a new
main_test.gowith tests asserting single-log behavior under cancellation and concurrency. - Added a
go.modfor the module.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| main.go | Adds a response-wrapping logger + middleware with an atomic guard to prevent double logging on cancellation. |
| main_test.go | Adds tests covering cancellation, normal completion, concurrency, and multiple-request scenarios. |
| go.mod | Introduces module definition and Go language version directive. |
Comments suppressed due to low confidence (4)
main_test.go:84
- This
time.Sleepis unnecessary: the deferred log call runs beforemw.ServeHTTPreturns, sologCountcan be asserted immediately. Removing sleeps keeps tests fast and reduces timing sensitivity.
time.Sleep(10 * time.Millisecond)
main_test.go:115
- This
time.Sleepis unnecessary for the same reason as above: logging happens beforeServeHTTPreturns in these tests. Consider removing it to avoid slowing down the test suite.
time.Sleep(10 * time.Millisecond)
main_test.go:139
- This
time.Sleepis unnecessary:WriteLogruns beforeServeHTTPreturns in this test, so the output can be checked immediately.
time.Sleep(10 * time.Millisecond)
main_test.go:204
- This
time.Sleepis unnecessary: each request’s deferred log runs beforemw.ServeHTTPreturns inside the loop, sologCountis already final after the loop. Removing the sleep speeds up the test and avoids timing sensitivity.
time.Sleep(50 * time.Millisecond)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+105
to
+109
| func (rl *responseLogger) Write(b []byte) (int, error) { | ||
| n, err := rl.w.Write(b) | ||
| rl.bytes += n | ||
| return n, err | ||
| } |
Comment on lines
+47
to
+49
| // Give the deferred log time to run | ||
| time.Sleep(10 * time.Millisecond) | ||
|
|
Comment on lines
+116
to
+120
| // LoggerMiddleware wraps the handler with logging that guarantees exactly | ||
| // one log entry per request, even when the request context is canceled. | ||
| func LoggerMiddleware(logger Logger, next http.Handler) http.Handler { | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| rl := newResponseLogger(w, logger, r.Method, r.URL.Path) |
Comment on lines
+87
to
+99
| func (rl *responseLogger) WriteLog(err error) { | ||
| if !atomic.CompareAndSwapInt32(&rl.logged, 0, 1) { | ||
| return // already logged — prevent duplicate | ||
| } | ||
| rl.logger.Log(LogEntry{ | ||
| Method: rl.method, | ||
| Path: rl.path, | ||
| Status: rl.status, | ||
| Bytes: rl.bytes, | ||
| Elapsed: time.Since(rl.start), | ||
| Err: err, | ||
| }) | ||
| } |
Comment on lines
+122
to
+135
| // Watch for context cancellation in a goroutine — if the client | ||
| // disconnects or server shuts down mid-handler, log the cancellation. | ||
| go func() { | ||
| <-r.Context().Done() | ||
| rl.WriteLog(r.Context().Err()) | ||
| }() | ||
|
|
||
| // Defer the normal completion log. WriteLog's atomic CAS guard | ||
| // ensures only one entry is written — whichever fires first wins. | ||
| defer func() { | ||
| // If context was canceled, use that error; otherwise nil (normal completion) | ||
| err := r.Context().Err() | ||
| rl.WriteLog(err) | ||
| }() |
| @@ -1,7 +1,139 @@ | |||
| package main | |||
| method string | ||
| path string | ||
| start time.Time | ||
| cancelMu sync.Mutex |
…e leak, remove sleeps
- Default status to 200 in WriteLog only when err==nil (handler wrote body
without WriteHeader). On context cancellation, status 0 flows to
DefaultLogger.Log which maps it to 499.
- Add sync.Mutex (rl.mu) to protect rl.status and rl.bytes reads/writes,
fixing race between cancellation goroutine and handler writes.
- Fix goroutine leak: add done channel so context-watcher exits when
handler finishes, not just when r.Context().Done() fires. Defer waits
for goroutineDone before calling WriteLog.
- Remove unused cancelMu field from responseLogger struct.
- Remove all time.Sleep calls after mw.ServeHTTP in tests — WriteLog
runs synchronously via defer before ServeHTTP returns.
- Add func main() {} back to main.go (package main requires it).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix: Prevent Duplicate Log Entries on Request Context Cancellation
Fixes #1
Problem
During graceful server shutdown or client disconnections, if a request's context is canceled while the handler is still executing, the logging middleware emits two completion log entries — one from the context-cancellation watcher and one from the deferred handler unwind. This inflates request metrics and causes log ingestion anomalies.
Solution
Introduced an atomic CAS guard (
atomic.CompareAndSwapInt32) inresponseLogger.WriteLog()that ensures exactly one log entry is written per request, regardless of how many times WriteLog is called:Both the cancellation-watcher goroutine and the deferred completion log call
WriteLog()— whichever fires first wins, the other is a no-op.Behavior
-race)Test coverage (6 tests, all passing with
-race)/attempt #1