Skip to content

Commit 2e9120b

Browse files
committed
Map proxy errors to specific status codes instead of always 502
httputil.ReverseProxy's ErrorHandler is invoked for several distinct failure modes, but Thruster collapsed all of them into a 502 (logged at info level as "Unable to proxy request"). The most damaging case is client disconnection: when a client closes its connection before the response is ready (an aborted fetch, a Turbo Frame swapping its src, a navigation away, an upstream load balancer giving up), the proxy is called with a context.Canceled error. The client is already gone so the 502 is written to a closed socket and discarded, but the access log still records status=502, so ordinary client cancellations show up as server errors and pollute 5xx dashboards. Distinguish the error cases, mirroring the handling in kamal-proxy: * client disconnect (context.Canceled) -> 499 Client Closed Request (nginx convention), logged at debug rather than info * upstream timeout (net.Error with Timeout()) -> 504 Gateway Timeout * malformed chunked encoding from the client -> 400 Bad Request * everything else (connection refused, EOF, ...) -> 502 as before Request-entity-too-large continues to return 413. Only genuine upstream failures now land in the 5xx bucket.
1 parent aaeecd4 commit 2e9120b

2 files changed

Lines changed: 233 additions & 0 deletions

File tree

internal/proxy_handler.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
package internal
22

33
import (
4+
"context"
45
"errors"
56
"log/slog"
7+
"net"
68
"net/http"
79
"net/http/httputil"
810
"net/url"
911
"os"
1012
)
1113

14+
// StatusClientClosedRequest is a non-standard status code, following nginx
15+
// convention, used when the client disconnects before we are able to respond.
16+
// Recording it (rather than a 502) keeps client-cancelled requests out of the
17+
// 5xx bucket in access logs and metrics.
18+
const StatusClientClosedRequest = 499
19+
1220
func NewProxyHandler(targetUrl *url.URL, badGatewayPage string, forwardHeaders bool) http.Handler {
1321
return &httputil.ReverseProxy{
1422
Rewrite: func(r *httputil.ProxyRequest) {
@@ -29,13 +37,35 @@ func ProxyErrorHandler(badGatewayPage string) func(w http.ResponseWriter, r *htt
2937
}
3038

3139
return func(w http.ResponseWriter, r *http.Request, err error) {
40+
if isClientCancellation(err) {
41+
// The client disconnected before we could respond, so there is
42+
// nothing to send. We still set a status code so the request is
43+
// recorded in the access log, but as a client-closed request
44+
// rather than an upstream failure -- otherwise client cancellations
45+
// (a fetch aborted, a Turbo Frame swapped, a navigation away) show
46+
// up as 502s and pollute error dashboards.
47+
slog.Debug("Client disconnected before response", "path", r.URL.Path)
48+
w.WriteHeader(StatusClientClosedRequest)
49+
return
50+
}
51+
3252
slog.Info("Unable to proxy request", "path", r.URL.Path, "error", err)
3353

3454
if isRequestEntityTooLarge(err) {
3555
w.WriteHeader(http.StatusRequestEntityTooLarge)
3656
return
3757
}
3858

59+
if isGatewayTimeout(err) {
60+
w.WriteHeader(http.StatusGatewayTimeout)
61+
return
62+
}
63+
64+
if isChunkedEncodingError(err) {
65+
w.WriteHeader(http.StatusBadRequest)
66+
return
67+
}
68+
3969
if content != nil {
4070
w.Header().Set("Content-Type", "text/html")
4171
w.WriteHeader(http.StatusBadGateway)
@@ -64,11 +94,43 @@ func setXForwarded(r *httputil.ProxyRequest, forwardHeaders bool) {
6494
}
6595
}
6696

97+
func isClientCancellation(err error) bool {
98+
return errors.Is(err, context.Canceled)
99+
}
100+
67101
func isRequestEntityTooLarge(err error) bool {
68102
var maxBytesError *http.MaxBytesError
69103
return errors.As(err, &maxBytesError)
70104
}
71105

106+
func isGatewayTimeout(err error) bool {
107+
var netErr net.Error
108+
if errors.As(err, &netErr) {
109+
return netErr.Timeout()
110+
}
111+
return false
112+
}
113+
114+
func isChunkedEncodingError(err error) bool {
115+
if err == nil {
116+
return false
117+
}
118+
119+
// The chunked encoding support in the stdlib returns these failures as
120+
// plain errors built with errors.New, so matching them means string
121+
// matching on the error message, unfortunately.
122+
switch err.Error() {
123+
case "invalid byte in chunk length",
124+
"http chunk length too large",
125+
"malformed chunked encoding",
126+
"trailer header without chunked transfer encoding",
127+
"too many trailers":
128+
return true
129+
}
130+
131+
return false
132+
}
133+
72134
func createProxyTransport() *http.Transport {
73135
// The default transport requests compressed responses even if the client
74136
// didn't. If it receives a compressed response but the client wants

internal/proxy_handler_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package internal
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"fmt"
8+
"log/slog"
9+
"net"
10+
"net/http"
11+
"net/http/httptest"
12+
"net/url"
13+
"testing"
14+
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestProxyErrorHandler_clientCancellationReturnsClientClosedRequest(t *testing.T) {
20+
handler := ProxyErrorHandler("")
21+
22+
w := httptest.NewRecorder()
23+
r := httptest.NewRequest("GET", "/", nil)
24+
25+
handler(w, r, context.Canceled)
26+
27+
assert.Equal(t, StatusClientClosedRequest, w.Code)
28+
assert.Empty(t, w.Body.String())
29+
}
30+
31+
func TestProxyErrorHandler_wrappedClientCancellationReturnsClientClosedRequest(t *testing.T) {
32+
handler := ProxyErrorHandler("")
33+
34+
w := httptest.NewRecorder()
35+
r := httptest.NewRequest("GET", "/", nil)
36+
37+
handler(w, r, fmt.Errorf("proxying request: %w", context.Canceled))
38+
39+
assert.Equal(t, StatusClientClosedRequest, w.Code)
40+
}
41+
42+
func TestProxyErrorHandler_clientCancellationIsNotLoggedAsProxyError(t *testing.T) {
43+
var buf bytes.Buffer
44+
original := slog.Default()
45+
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})))
46+
defer slog.SetDefault(original)
47+
48+
handler := ProxyErrorHandler("")
49+
handler(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), context.Canceled)
50+
51+
assert.NotContains(t, buf.String(), "Unable to proxy request")
52+
}
53+
54+
func TestProxyErrorHandler_upstreamErrorReturnsBadGateway(t *testing.T) {
55+
handler := ProxyErrorHandler("")
56+
57+
w := httptest.NewRecorder()
58+
r := httptest.NewRequest("GET", "/", nil)
59+
60+
handler(w, r, errors.New("dial tcp [::1]:3000: connect: connection refused"))
61+
62+
assert.Equal(t, http.StatusBadGateway, w.Code)
63+
}
64+
65+
func TestProxyErrorHandler_connectionRefusedReturnsBadGateway(t *testing.T) {
66+
handler := ProxyErrorHandler("")
67+
68+
w := httptest.NewRecorder()
69+
r := httptest.NewRequest("GET", "/", nil)
70+
71+
// A real connection-refused error is a net.Error, but it is not a timeout,
72+
// so it must still be treated as a bad gateway rather than a 504.
73+
err := &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connect: connection refused")}
74+
require.False(t, err.Timeout())
75+
76+
handler(w, r, err)
77+
78+
assert.Equal(t, http.StatusBadGateway, w.Code)
79+
}
80+
81+
func TestProxyErrorHandler_upstreamTimeoutReturnsGatewayTimeout(t *testing.T) {
82+
handler := ProxyErrorHandler("")
83+
84+
w := httptest.NewRecorder()
85+
r := httptest.NewRequest("GET", "/", nil)
86+
87+
// context.DeadlineExceeded satisfies net.Error with Timeout() == true, the
88+
// same shape the transport returns when an upstream read/dial times out.
89+
handler(w, r, context.DeadlineExceeded)
90+
91+
assert.Equal(t, http.StatusGatewayTimeout, w.Code)
92+
}
93+
94+
func TestProxyErrorHandler_chunkedEncodingErrorReturnsBadRequest(t *testing.T) {
95+
handler := ProxyErrorHandler("")
96+
97+
w := httptest.NewRecorder()
98+
r := httptest.NewRequest("GET", "/", nil)
99+
100+
handler(w, r, errors.New("malformed chunked encoding"))
101+
102+
assert.Equal(t, http.StatusBadRequest, w.Code)
103+
}
104+
105+
func TestProxyErrorHandler_entityTooLargeReturnsRequestEntityTooLarge(t *testing.T) {
106+
handler := ProxyErrorHandler("")
107+
108+
w := httptest.NewRecorder()
109+
r := httptest.NewRequest("GET", "/", nil)
110+
111+
handler(w, r, &http.MaxBytesError{})
112+
113+
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
114+
}
115+
116+
// End-to-end: a client that disconnects mid-request must be recorded as a
117+
// client-closed request (499), not an upstream failure (502).
118+
func TestProxyHandler_clientDisconnectIsRecordedAsClientClosedRequest(t *testing.T) {
119+
upstreamReached := make(chan struct{})
120+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121+
close(upstreamReached)
122+
<-r.Context().Done() // block until the client goes away
123+
}))
124+
defer upstream.Close()
125+
126+
targetUrl, err := url.Parse(upstream.URL)
127+
require.NoError(t, err)
128+
129+
proxy := NewProxyHandler(targetUrl, "", false)
130+
131+
var capturedStatus int
132+
done := make(chan struct{})
133+
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
135+
proxy.ServeHTTP(recorder, r)
136+
capturedStatus = recorder.status
137+
close(done)
138+
}))
139+
defer front.Close()
140+
141+
ctx, cancel := context.WithCancel(context.Background())
142+
req, err := http.NewRequestWithContext(ctx, "GET", front.URL, nil)
143+
require.NoError(t, err)
144+
145+
clientDone := make(chan struct{})
146+
go func() {
147+
defer close(clientDone)
148+
resp, err := http.DefaultClient.Do(req)
149+
if resp != nil {
150+
_ = resp.Body.Close()
151+
}
152+
_ = err // the client cancels the request, so an error is expected
153+
}()
154+
155+
<-upstreamReached
156+
cancel()
157+
<-done
158+
<-clientDone
159+
160+
assert.Equal(t, StatusClientClosedRequest, capturedStatus)
161+
}
162+
163+
type statusRecorder struct {
164+
http.ResponseWriter
165+
status int
166+
}
167+
168+
func (r *statusRecorder) WriteHeader(status int) {
169+
r.status = status
170+
r.ResponseWriter.WriteHeader(status)
171+
}

0 commit comments

Comments
 (0)