diff --git a/modules/caddyhttp/reverseproxy/client_disconnect_test.go b/modules/caddyhttp/reverseproxy/client_disconnect_test.go new file mode 100644 index 000000000..a56478e56 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/client_disconnect_test.go @@ -0,0 +1,54 @@ +package reverseproxy + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +// TestClientDisconnectRecordsStatus verifies that when the downstream client +// disconnects (its request context is canceled) before the upstream sends any +// response headers, the recorded status is 499 ("client closed request") +// rather than 0. +func TestClientDisconnectRecordsStatus(t *testing.T) { + // backend that blocks until the client goes away, so it never gets + // the chance to send response headers + gotRequest := make(chan struct{}) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(gotRequest) + <-r.Context().Done() + })) + defer backend.Close() + + h := minimalHandler(0, &Upstream{ + Host: new(Host), + Dial: backend.Listener.Addr().String(), + }) + + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil).WithContext(ctx) + req = prepareTestRequest(req) + + rec := caddyhttp.NewResponseRecorder(httptest.NewRecorder(), nil, nil) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error { + return nil + })) + }() + + <-gotRequest + cancel() + wg.Wait() + + if got := rec.Status(); got != 499 { + t.Errorf("expected status 499 after client disconnect, got %d", got) + } +} diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 4f6fdaed2..81a1ef1eb 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -699,9 +699,15 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h // proxy the request to that upstream proxyErr = h.reverseProxy(w, r, origReq, repl, dialInfo, next) - if proxyErr == nil || errors.Is(proxyErr, context.Canceled) { - // context.Canceled happens when the downstream client - // cancels the request, which is not our failure + if proxyErr == nil { + return true, nil + } + if errors.Is(proxyErr, context.Canceled) { + // context.Canceled happens when the downstream client cancels the + // request, which is not our failure; don't retry or ding the upstream. + // Record a 499 (client closed request) so the access log reflects the + // disconnect instead of a misleading 0 status (see #7396). + w.WriteHeader(499) return true, nil }