diff --git a/connection/quic_connection.go b/connection/quic_connection.go index ac0d0149d0a..c8d0649f4b1 100644 --- a/connection/quic_connection.go +++ b/connection/quic_connection.go @@ -207,7 +207,11 @@ func (q *quicConnection) handleDataStream(ctx context.Context, stream *rpcquic.R } if err, connectResponseSent := q.dispatchRequest(ctx, stream, request); err != nil { - q.logger.Err(err).Str("type", request.Type.String()).Str("dest", request.Dest).Msg("Request failed") + if IsBenignRemoteStreamCancel(err) { + q.logger.Debug().Err(err).Str("type", request.Type.String()).Str("dest", request.Dest).Msg("Request canceled") + } else { + q.logger.Err(err).Str("type", request.Type.String()).Str("dest", request.Dest).Msg("Request failed") + } // if the connectResponse was already sent and we had an error, we need to propagate it up, so that the stream is // closed with an RST_STREAM frame diff --git a/connection/stream_cancel.go b/connection/stream_cancel.go new file mode 100644 index 00000000000..3a128e425c7 --- /dev/null +++ b/connection/stream_cancel.go @@ -0,0 +1,23 @@ +package connection + +import ( + "errors" + + "github.com/quic-go/quic-go" +) + +// IsBenignRemoteStreamCancel reports whether err (possibly wrapped) is a remote +// QUIC stream cancellation with error code 0 (NO_ERROR). +// +// Per RFC 9113 §7, HTTP/2 RST_STREAM with NO_ERROR is an intentional, +// non-erroneous stream close. Cloudflare's edge surfaces that to cloudflared as +// a quic.StreamError with Remote=true and ErrorCode=0 (for example when a +// browser tab closes a long-lived SSE or WebSocket). Logging those at ERR +// creates false positives in monitoring; treat them as debug instead. +func IsBenignRemoteStreamCancel(err error) bool { + var se *quic.StreamError + if !errors.As(err, &se) { + return false + } + return se.Remote && se.ErrorCode == 0 +} diff --git a/connection/stream_cancel_test.go b/connection/stream_cancel_test.go new file mode 100644 index 00000000000..32f2f9f9c72 --- /dev/null +++ b/connection/stream_cancel_test.go @@ -0,0 +1,25 @@ +package connection + +import ( + "fmt" + "testing" + + "github.com/pkg/errors" + "github.com/quic-go/quic-go" + "github.com/stretchr/testify/require" +) + +func TestIsBenignRemoteStreamCancel(t *testing.T) { + t.Parallel() + + benign := &quic.StreamError{StreamID: 41, ErrorCode: 0, Remote: true} + require.True(t, IsBenignRemoteStreamCancel(benign)) + require.True(t, IsBenignRemoteStreamCancel(fmt.Errorf("proxy: %w", benign))) + require.True(t, IsBenignRemoteStreamCancel(errors.Wrap(benign, "proxyHTTPRequest"))) + + require.False(t, IsBenignRemoteStreamCancel(nil)) + require.False(t, IsBenignRemoteStreamCancel(fmt.Errorf("other"))) + require.False(t, IsBenignRemoteStreamCancel(&quic.StreamError{StreamID: 41, ErrorCode: 0, Remote: false})) + require.False(t, IsBenignRemoteStreamCancel(&quic.StreamError{StreamID: 41, ErrorCode: 1, Remote: true})) + require.False(t, IsBenignRemoteStreamCancel(&quic.StreamError{StreamID: 41, ErrorCode: 0x100, Remote: true})) +} diff --git a/proxy/logger.go b/proxy/logger.go index fa202964865..29e4a6ad2ae 100644 --- a/proxy/logger.go +++ b/proxy/logger.go @@ -75,7 +75,13 @@ func logOriginHTTPResponse(logger *zerolog.Logger, resp *http.Response) { } // logRequestError logs an error for the proxied request. +// Benign remote stream cancellations (QUIC NO_ERROR) are logged at Debug and +// do not increment the request-error metric — see connection.IsBenignRemoteStreamCancel. func logRequestError(logger *zerolog.Logger, err error) { + if connection.IsBenignRemoteStreamCancel(err) { + logger.Debug().Err(err).Msg("request stream canceled by remote with NO_ERROR") + return + } requestErrors.Inc() logger.Error().Err(err).Send() } diff --git a/proxy/logger_test.go b/proxy/logger_test.go new file mode 100644 index 00000000000..d65c837c9ec --- /dev/null +++ b/proxy/logger_test.go @@ -0,0 +1,35 @@ +package proxy + +import ( + "bytes" + "strings" + "testing" + + "github.com/quic-go/quic-go" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestLogRequestErrorBenignRemoteCancelUsesDebug(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + logger := zerolog.New(&buf).Level(zerolog.DebugLevel) + + logRequestError(&logger, &quic.StreamError{StreamID: 41, ErrorCode: 0, Remote: true}) + out := buf.String() + require.Contains(t, out, `"level":"debug"`) + require.Contains(t, out, "request stream canceled by remote with NO_ERROR") + require.NotContains(t, out, `"level":"error"`) +} + +func TestLogRequestErrorRealErrorUsesError(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + logger := zerolog.New(&buf).Level(zerolog.DebugLevel) + + logRequestError(&logger, &quic.StreamError{StreamID: 41, ErrorCode: 1, Remote: true}) + out := buf.String() + require.True(t, strings.Contains(out, `"level":"error"`), out) +}