fix(coderd): downgrade heartbeat ping errors for closed connections to debug (#23025)

- `coderd/httpapi/websocket.go`: add `net.ErrClosed` +
`websocket.CloseStatus` checks; extract `heartbeatCloseWith` with
`quartz.Clock` parameter for testability
- `coderd/httpapi/websocket_internal_test.go`: new test file
This commit is contained in:
Cian Johnston
2026-03-13 10:38:39 +00:00
committed by GitHub
parent 7777072d7a
commit 8714aa4637
2 changed files with 233 additions and 7 deletions
+23 -7
View File
@@ -3,20 +3,26 @@ package httpapi
import (
"context"
"errors"
"net"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/quartz"
"github.com/coder/websocket"
)
const HeartbeatInterval time.Duration = 15 * time.Second
// HeartbeatClose loops to ping a WebSocket to keep it alive. It calls `exit` on ping
// failure.
// HeartbeatClose loops to ping a WebSocket to keep it alive.
// It calls `exit` on ping failure.
func HeartbeatClose(ctx context.Context, logger slog.Logger, exit func(), conn *websocket.Conn) {
ticker := time.NewTicker(HeartbeatInterval)
heartbeatCloseWith(ctx, logger, exit, conn, quartz.NewReal(), HeartbeatInterval)
}
func heartbeatCloseWith(ctx context.Context, logger slog.Logger, exit func(), conn *websocket.Conn, clk quartz.Clock, interval time.Duration) {
ticker := clk.NewTicker(interval, "HeartbeatClose")
defer ticker.Stop()
for {
@@ -25,11 +31,21 @@ func HeartbeatClose(ctx context.Context, logger slog.Logger, exit func(), conn *
return
case <-ticker.C:
}
err := pingWithTimeout(ctx, conn, HeartbeatInterval)
err := pingWithTimeout(ctx, conn, interval)
if err != nil {
// context.DeadlineExceeded is expected when the client disconnects without sending a close frame.
// context.Canceled is expected when the request context is canceled.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
// These errors are all expected during normal connection
// teardown and should not be logged at error level:
// - context.DeadlineExceeded: client disconnected
// without sending a close frame.
// - context.Canceled: request context was canceled.
// - net.ErrClosed: connection was already closed by
// another goroutine (e.g. handler returned).
// - websocket.CloseError: a close frame was
// received or sent.
if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, context.Canceled) ||
errors.Is(err, net.ErrClosed) ||
websocket.CloseStatus(err) != -1 {
logger.Debug(ctx, "heartbeat ping stopped", slog.Error(err))
} else {
logger.Error(ctx, "failed to heartbeat ping", slog.Error(err))