detached 8823050

fix(proxy): handle client-aborted streaming requests

commit
88230506441012d6df8e1b4f5504ceefd6ed03a1
author
brice · · 2026-06-02 18:04 UTC
committer
brice · · 2026-06-02 18:14 UTC
parent
020cc00
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +134 -21
M internal/adapters/in/http/middleware/logging.go +10 -0
@@ -5,6 +5,7 @@ import (
55 "crypto/rand"
66 "encoding/hex"
77 "encoding/json"
8+ "errors"
89 "fmt"
910 "net"
1011 "net/http"
@@ -163,6 +164,10 @@ func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
163164 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
164165 defer func() {
165166 if err := recover(); err != nil {
167+ if isAbortHandlerPanic(err) {
168+ panic(http.ErrAbortHandler)
169+ }
170+
166171 log.Error().
167172 Str(zerowrap.FieldLayer, "adapter").
168173 Str(zerowrap.FieldAdapter, "http").
@@ -183,6 +188,11 @@ func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
183188 }
184189 }
185190
191+func isAbortHandlerPanic(v any) bool {
192+ err, ok := v.(error)
193+ return ok && errors.Is(err, http.ErrAbortHandler)
194+}
195+
186196 // CORS middleware adds permissive CORS headers.
187197 // NOTE: This is intentionally NOT used on the proxy chain (backends control their own CORS).
188198 // Available for use on specific endpoints (e.g., registry API) where CORS is needed.
@@ -5,6 +5,7 @@ import (
5 "crypto/rand"5 "crypto/rand"
6 "encoding/hex"6 "encoding/hex"
7 "encoding/json"7 "encoding/json"
8+ "errors"
8 "fmt"9 "fmt"
9 "net"10 "net"
10 "net/http"11 "net/http"
@@ -163,6 +164,10 @@ func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
163 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {164 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
164 defer func() {165 defer func() {
165 if err := recover(); err != nil {166 if err := recover(); err != nil {
167+ if isAbortHandlerPanic(err) {
168+ panic(http.ErrAbortHandler)
169+ }
170+
166 log.Error().171 log.Error().
167 Str(zerowrap.FieldLayer, "adapter").172 Str(zerowrap.FieldLayer, "adapter").
168 Str(zerowrap.FieldAdapter, "http").173 Str(zerowrap.FieldAdapter, "http").
@@ -183,6 +188,11 @@ func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
183 }188 }
184 }189 }
185 190
191+func isAbortHandlerPanic(v any) bool {
192+ err, ok := v.(error)
193+ return ok && errors.Is(err, http.ErrAbortHandler)
194+}
195+
186 // CORS middleware adds permissive CORS headers.196 // CORS middleware adds permissive CORS headers.
187 // NOTE: This is intentionally NOT used on the proxy chain (backends control their own CORS).197 // NOTE: This is intentionally NOT used on the proxy chain (backends control their own CORS).
188 // Available for use on specific endpoints (e.g., registry API) where CORS is needed.198 // Available for use on specific endpoints (e.g., registry API) where CORS is needed.
A internal/adapters/in/http/middleware/logging_test.go +25 -0
@@ -0,0 +1,25 @@
1+package middleware
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestPanicRecovery_RethrowsReverseProxyAbort(t *testing.T) {
12+ log := testLogger()
13+
14+ handler := PanicRecovery(log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+ panic(http.ErrAbortHandler)
16+ }))
17+
18+ req := httptest.NewRequest(http.MethodGet, "/stream", nil)
19+ rw := httptest.NewRecorder()
20+
21+ assert.PanicsWithValue(t, http.ErrAbortHandler, func() {
22+ handler.ServeHTTP(rw, req)
23+ })
24+ assert.NotEqual(t, http.StatusInternalServerError, rw.Code)
25+}
@@ -0,0 +1,25 @@
1+package middleware
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestPanicRecovery_RethrowsReverseProxyAbort(t *testing.T) {
12+ log := testLogger()
13+
14+ handler := PanicRecovery(log)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+ panic(http.ErrAbortHandler)
16+ }))
17+
18+ req := httptest.NewRequest(http.MethodGet, "/stream", nil)
19+ rw := httptest.NewRecorder()
20+
21+ assert.PanicsWithValue(t, http.ErrAbortHandler, func() {
22+ handler.ServeHTTP(rw, req)
23+ })
24+ assert.NotEqual(t, http.StatusInternalServerError, rw.Code)
25+}
M internal/adapters/in/http/proxy/forwarder.go +63 -21
@@ -2,6 +2,7 @@
22 package proxy
33
44 import (
5+ "context"
56 "errors"
67 "fmt"
78 "io"
@@ -102,16 +103,15 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target
102103 transport: transport,
103104 incomingReq: r,
104105 trustedNets: h.trustedNets,
105- errorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
106- var maxBytesErr *http.MaxBytesError
107- if errors.As(proxyErr, &maxBytesErr) {
108- log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")
109- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
110- return
111- }
112- log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")
113- proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
114- },
106+ errorHandler: newProxyErrorHandler(
107+ log.WithField("target", targetURL.String()),
108+ proxyErrorHandlerConfig{
109+ requestTooLargeLog: "proxy error: request body too large",
110+ clientDisconnectLog: "proxy request canceled by client",
111+ upstreamErrorLog: "proxy error: connection failed",
112+ upstreamErrorBody: "Service Unavailable",
113+ },
114+ ),
115115 modifyResponse: modifyResponse(maxResponseSize),
116116 })
117117
@@ -152,16 +152,15 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
152152 pr.Out.Host = targetURL.Host
153153 },
154154 Transport: h.registryTransport,
155- ErrorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
156- var maxBytesErr *http.MaxBytesError
157- if errors.As(proxyErr, &maxBytesErr) {
158- log.Warn().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error: request body too large")
159- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
160- return
161- }
162- log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error")
163- proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
164- },
155+ ErrorHandler: newProxyErrorHandler(
156+ log.WithField("registry_port", registryPort),
157+ proxyErrorHandlerConfig{
158+ requestTooLargeLog: "registry proxy error: request body too large",
159+ clientDisconnectLog: "registry proxy request canceled by client",
160+ upstreamErrorLog: "registry proxy error",
161+ upstreamErrorBody: "Registry Unavailable",
162+ },
163+ ),
165164 // Strip browser-oriented security headers from upstream registry responses
166165 // to avoid conflicts with Gordon's own headers on browser-facing routes.
167166 ModifyResponse: func(resp *http.Response) error {
@@ -181,6 +180,34 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
181180 proxy.ServeHTTP(w, r)
182181 }
183182
183+type proxyErrorHandlerConfig struct {
184+ requestTooLargeLog string
185+ clientDisconnectLog string
186+ upstreamErrorLog string
187+ upstreamErrorBody string
188+}
189+
190+func newProxyErrorHandler(log zerowrap.Logger, cfg proxyErrorHandlerConfig) func(http.ResponseWriter, *http.Request, error) {
191+ return func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
192+ switch {
193+ case isRequestBodyTooLargeError(proxyErr):
194+ log.Warn().Err(proxyErr).Msg(cfg.requestTooLargeLog)
195+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
196+ case isClientDisconnectError(proxyErr):
197+ log.Debug().Err(proxyErr).Msg(cfg.clientDisconnectLog)
198+ clientClosedRequest(w)
199+ default:
200+ log.Error().Err(proxyErr).Msg(cfg.upstreamErrorLog)
201+ proxyError(w, cfg.upstreamErrorBody, http.StatusServiceUnavailable)
202+ }
203+ }
204+}
205+
206+func isRequestBodyTooLargeError(err error) bool {
207+ _, ok := errors.AsType[*http.MaxBytesError](err)
208+ return ok
209+}
210+
184211 type reverseProxyOptions struct {
185212 targetURL *url.URL
186213 hostHeader string
@@ -305,10 +332,25 @@ func (l *limitedReadCloser) Close() error {
305332 return l.ReadCloser.Close()
306333 }
307334
335+const statusClientClosedRequest = 499
336+
337+func isClientDisconnectError(err error) bool {
338+ return errors.Is(err, context.Canceled) || errors.Is(err, http.ErrAbortHandler)
339+}
340+
341+func clientClosedRequest(w http.ResponseWriter) {
342+ setProxyGeneratedResponseHeaders(w)
343+ w.WriteHeader(statusClientClosedRequest)
344+}
345+
308346 // proxyError writes an error response with security headers appropriate for
309347 // proxy-generated error pages.
310348 func proxyError(w http.ResponseWriter, msg string, code int) {
349+ setProxyGeneratedResponseHeaders(w)
350+ http.Error(w, msg, code)
351+}
352+
353+func setProxyGeneratedResponseHeaders(w http.ResponseWriter) {
311354 w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
312355 w.Header().Set("Cache-Control", "no-store")
313- http.Error(w, msg, code)
314356 }
@@ -2,6 +2,7 @@
2 package proxy2 package proxy
3 3
4 import (4 import (
5+ "context"
5 "errors"6 "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
@@ -102,16 +103,15 @@ func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target
102 transport: transport,103 transport: transport,
103 incomingReq: r,104 incomingReq: r,
104 trustedNets: h.trustedNets,105 trustedNets: h.trustedNets,
105- errorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {106+ errorHandler: newProxyErrorHandler(
106- var maxBytesErr *http.MaxBytesError107+ log.WithField("target", targetURL.String()),
107- if errors.As(proxyErr, &maxBytesErr) {108+ proxyErrorHandlerConfig{
108- log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")109+ requestTooLargeLog: "proxy error: request body too large",
109- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)110+ clientDisconnectLog: "proxy request canceled by client",
110- return111+ upstreamErrorLog: "proxy error: connection failed",
111- }112+ upstreamErrorBody: "Service Unavailable",
112- log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")113+ },
113- proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)114+ ),
114- },
115 modifyResponse: modifyResponse(maxResponseSize),115 modifyResponse: modifyResponse(maxResponseSize),
116 })116 })
117 117
@@ -152,16 +152,15 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
152 pr.Out.Host = targetURL.Host152 pr.Out.Host = targetURL.Host
153 },153 },
154 Transport: h.registryTransport,154 Transport: h.registryTransport,
155- ErrorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {155+ ErrorHandler: newProxyErrorHandler(
156- var maxBytesErr *http.MaxBytesError156+ log.WithField("registry_port", registryPort),
157- if errors.As(proxyErr, &maxBytesErr) {157+ proxyErrorHandlerConfig{
158- log.Warn().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error: request body too large")158+ requestTooLargeLog: "registry proxy error: request body too large",
159- proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)159+ clientDisconnectLog: "registry proxy request canceled by client",
160- return160+ upstreamErrorLog: "registry proxy error",
161- }161+ upstreamErrorBody: "Registry Unavailable",
162- log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error")162+ },
163- proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)163+ ),
164- },
165 // Strip browser-oriented security headers from upstream registry responses164 // Strip browser-oriented security headers from upstream registry responses
166 // to avoid conflicts with Gordon's own headers on browser-facing routes.165 // to avoid conflicts with Gordon's own headers on browser-facing routes.
167 ModifyResponse: func(resp *http.Response) error {166 ModifyResponse: func(resp *http.Response) error {
@@ -181,6 +180,34 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
181 proxy.ServeHTTP(w, r)180 proxy.ServeHTTP(w, r)
182 }181 }
183 182
183+type proxyErrorHandlerConfig struct {
184+ requestTooLargeLog string
185+ clientDisconnectLog string
186+ upstreamErrorLog string
187+ upstreamErrorBody string
188+}
189+
190+func newProxyErrorHandler(log zerowrap.Logger, cfg proxyErrorHandlerConfig) func(http.ResponseWriter, *http.Request, error) {
191+ return func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
192+ switch {
193+ case isRequestBodyTooLargeError(proxyErr):
194+ log.Warn().Err(proxyErr).Msg(cfg.requestTooLargeLog)
195+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
196+ case isClientDisconnectError(proxyErr):
197+ log.Debug().Err(proxyErr).Msg(cfg.clientDisconnectLog)
198+ clientClosedRequest(w)
199+ default:
200+ log.Error().Err(proxyErr).Msg(cfg.upstreamErrorLog)
201+ proxyError(w, cfg.upstreamErrorBody, http.StatusServiceUnavailable)
202+ }
203+ }
204+}
205+
206+func isRequestBodyTooLargeError(err error) bool {
207+ _, ok := errors.AsType[*http.MaxBytesError](err)
208+ return ok
209+}
210+
184 type reverseProxyOptions struct {211 type reverseProxyOptions struct {
185 targetURL *url.URL212 targetURL *url.URL
186 hostHeader string213 hostHeader string
@@ -305,10 +332,25 @@ func (l *limitedReadCloser) Close() error {
305 return l.ReadCloser.Close()332 return l.ReadCloser.Close()
306 }333 }
307 334
335+const statusClientClosedRequest = 499
336+
337+func isClientDisconnectError(err error) bool {
338+ return errors.Is(err, context.Canceled) || errors.Is(err, http.ErrAbortHandler)
339+}
340+
341+func clientClosedRequest(w http.ResponseWriter) {
342+ setProxyGeneratedResponseHeaders(w)
343+ w.WriteHeader(statusClientClosedRequest)
344+}
345+
308 // proxyError writes an error response with security headers appropriate for346 // proxyError writes an error response with security headers appropriate for
309 // proxy-generated error pages.347 // proxy-generated error pages.
310 func proxyError(w http.ResponseWriter, msg string, code int) {348 func proxyError(w http.ResponseWriter, msg string, code int) {
349+ setProxyGeneratedResponseHeaders(w)
350+ http.Error(w, msg, code)
351+}
352+
353+func setProxyGeneratedResponseHeaders(w http.ResponseWriter) {
311 w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")354 w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
312 w.Header().Set("Cache-Control", "no-store")355 w.Header().Set("Cache-Control", "no-store")
313- http.Error(w, msg, code)
314 }356 }
M internal/adapters/in/http/proxy/forwarder_test.go +36 -0
@@ -1,6 +1,7 @@
11 package proxy
22
33 import (
4+ "context"
45 "fmt"
56 "net"
67 "net/http"
@@ -20,6 +21,12 @@ import (
2021 "github.com/bnema/gordon/internal/domain"
2122 )
2223
24+type roundTripFunc func(*http.Request) (*http.Response, error)
25+
26+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
27+ return f(r)
28+}
29+
2330 func TestForwardToTarget_H2CEndToEnd(t *testing.T) {
2431 var h2cProtos http.Protocols
2532 h2cProtos.SetUnencryptedHTTP2(true)
@@ -185,6 +192,35 @@ func TestForwardToTarget_RouteHostPreservedForManagedTarget(t *testing.T) {
185192 assert.Equal(t, "app.example.com", receivedForwardedHost)
186193 }
187194
195+func TestForwardToTarget_ContextCanceled_Returns499(t *testing.T) {
196+ proxySvc := inmocks.NewMockProxyService(t)
197+
198+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
199+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
200+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
201+ Host: "127.0.0.1",
202+ Port: 8080,
203+ ContainerID: "c-sse",
204+ Scheme: "http",
205+ }, nil)
206+ proxySvc.EXPECT().TrackInFlight("c-sse").Return(func() {})
207+
208+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
209+ handler.appTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
210+ return nil, context.Canceled
211+ })
212+
213+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/api/notifications/sent-events", nil)
214+ req.Host = "app.example.com"
215+ w := httptest.NewRecorder()
216+ handler.ServeHTTP(w, req)
217+
218+ assert.Equal(t, statusClientClosedRequest, w.Code)
219+ assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
220+ assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", w.Header().Get("Content-Security-Policy"))
221+ assert.Empty(t, w.Body.String())
222+}
223+
188224 func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
189225 proxySvc := inmocks.NewMockProxyService(t)
190226
@@ -1,6 +1,7 @@
1 package proxy1 package proxy
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "net"6 "net"
6 "net/http"7 "net/http"
@@ -20,6 +21,12 @@ import (
20 "github.com/bnema/gordon/internal/domain"21 "github.com/bnema/gordon/internal/domain"
21 )22 )
22 23
24+type roundTripFunc func(*http.Request) (*http.Response, error)
25+
26+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
27+ return f(r)
28+}
29+
23 func TestForwardToTarget_H2CEndToEnd(t *testing.T) {30 func TestForwardToTarget_H2CEndToEnd(t *testing.T) {
24 var h2cProtos http.Protocols31 var h2cProtos http.Protocols
25 h2cProtos.SetUnencryptedHTTP2(true)32 h2cProtos.SetUnencryptedHTTP2(true)
@@ -185,6 +192,35 @@ func TestForwardToTarget_RouteHostPreservedForManagedTarget(t *testing.T) {
185 assert.Equal(t, "app.example.com", receivedForwardedHost)192 assert.Equal(t, "app.example.com", receivedForwardedHost)
186 }193 }
187 194
195+func TestForwardToTarget_ContextCanceled_Returns499(t *testing.T) {
196+ proxySvc := inmocks.NewMockProxyService(t)
197+
198+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
199+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
200+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
201+ Host: "127.0.0.1",
202+ Port: 8080,
203+ ContainerID: "c-sse",
204+ Scheme: "http",
205+ }, nil)
206+ proxySvc.EXPECT().TrackInFlight("c-sse").Return(func() {})
207+
208+ handler := NewHandler(proxySvc, nil, zerowrap.Default())
209+ handler.appTransport = roundTripFunc(func(*http.Request) (*http.Response, error) {
210+ return nil, context.Canceled
211+ })
212+
213+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/api/notifications/sent-events", nil)
214+ req.Host = "app.example.com"
215+ w := httptest.NewRecorder()
216+ handler.ServeHTTP(w, req)
217+
218+ assert.Equal(t, statusClientClosedRequest, w.Code)
219+ assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
220+ assert.Equal(t, "default-src 'none'; frame-ancestors 'none'", w.Header().Get("Content-Security-Policy"))
221+ assert.Empty(t, w.Body.String())
222+}
223+
188 func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {224 func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
189 proxySvc := inmocks.NewMockProxyService(t)225 proxySvc := inmocks.NewMockProxyService(t)
190 226

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close