Skip to content

Commit 8d99a2a

Browse files
dtunikovclaude
andauthored
Classify mid-CDC SSH tunnel failures as connectivity errors (PeerDB-io#4443)
## What When an SSH tunnel's keepalive fails mid-CDC, the Postgres connector proactively closes the replication connection. That surfaces in the CDC loop as a generic `ReceiveMessage failed` error which — depending on the underlying type — could fall through to the unclassified `OTHER` bucket (`NotifyTelemetry`) and **page us**, even though tunnel failures are retriable and almost always customer-side. SSH *setup* failures were already classified as `NOTIFY_CONNECTIVITY`; this closes the gap for failures that happen *during* CDC while the tunnel is open. ## Changes - New `SSHTunnelConnectionError` exception type. - CDC `PullCdcRecords` wraps the read error in it when `p.ssh.IsBad()`. - Classifier maps it to `NOTIFY_CONNECTIVITY` (notify user, no page), placed before the generic `net.*` matchers so the tunnel context isn't lost. - `SSHTunnel.badTunnel` is now `atomic.Bool` (read from the CDC loop) + nil-safe `IsBad()` accessor. - Added classifier unit test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e345d70 commit 8d99a2a

6 files changed

Lines changed: 60 additions & 8 deletions

File tree

flow/alerting/classifier.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,14 @@ func GetErrorClass(ctx context.Context, err error) (ErrorClass, ErrorInfo) {
418418
}
419419
}
420420

421+
// An SSH-tunneled connection that goes bad (e.g. keepalive failure) is wrapped explicitly.
422+
if _, ok := errors.AsType[*exceptions.SSHTunnelConnectionError](err); ok {
423+
return ErrorNotifyConnectivity, ErrorInfo{
424+
Source: ErrorSourceSSH,
425+
Code: "TUNNEL_CONNECTION_LOST",
426+
}
427+
}
428+
421429
// Other connection reset errors can mostly be ignored
422430
if errors.Is(err, syscall.ECONNRESET) {
423431
return ErrorIgnoreConnTemporary, ErrorInfo{

flow/alerting/classifier_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ func TestOtherDNSErrorsShouldBeConnectivity(t *testing.T) {
5757
assert.Regexp(t, "^lookup "+hostName+"( on [\\w\\d\\.:]*)?: no such host$", errInfo.Code, "Unexpected error code")
5858
}
5959

60+
func TestSSHTunnelConnectionErrorShouldBeConnectivity(t *testing.T) {
61+
t.Parallel()
62+
63+
// Mirrors how the Postgres CDC loop wraps a read failure once the SSH tunnel has gone bad.
64+
err := fmt.Errorf("error in PullRecords: %w",
65+
exceptions.NewSSHTunnelConnectionError(fmt.Errorf("ReceiveMessage failed: %w", net.ErrClosed)))
66+
errorClass, errInfo := GetErrorClass(t.Context(), err)
67+
assert.Equal(t, ErrorNotifyConnectivity, errorClass, "Unexpected error class")
68+
assert.Equal(t, ErrorInfo{
69+
Source: ErrorSourceSSH,
70+
Code: "TUNNEL_CONNECTION_LOST",
71+
}, errInfo, "Unexpected error info")
72+
}
73+
6074
func TestNeonConnectivityErrorShouldBeConnectivity(t *testing.T) {
6175
t.Skip("Not a good idea to run this test in CI as it goes to Neon, maybe we need a better mock")
6276
config, err := pgx.ParseConfig("postgres://random-endpoint-id-here.us-east-2.aws.neon.tech:5432/db?options=endpoint%3Dtest_endpoint")

flow/connectors/postgres/cdc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,10 @@ func PullCdcRecords[Items model.Items](
722722
}
723723

724724
if err != nil {
725+
// If the SSH tunnel went bad (e.g. keepalive failure), we proactively close the replication connection.
726+
if p.ssh.IsBad() {
727+
return exceptions.NewSSHTunnelConnectionError(fmt.Errorf("ReceiveMessage failed: %w", err))
728+
}
725729
return fmt.Errorf("ReceiveMessage failed: %w", err)
726730
}
727731

flow/connectors/utils/ssh.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ type SSHTunnel struct {
2323
*ssh.Client
2424
logger *slog.Logger
2525
keepaliveChan atomic.Pointer[chan struct{}]
26-
badTunnel bool
26+
badTunnel atomic.Bool
2727
}
2828

2929
// GetSSHClientConfig returns an *ssh.ClientConfig based on provided credentials.
@@ -101,6 +101,12 @@ func NewSSHTunnel(
101101
return nil, nil
102102
}
103103

104+
// IsBad reports whether the tunnel has been marked unusable (keepalive failure or explicit close).
105+
// Safe to call on a nil tunnel, in which case it returns false.
106+
func (tunnel *SSHTunnel) IsBad() bool {
107+
return tunnel != nil && tunnel.badTunnel.Load()
108+
}
109+
104110
func (tunnel *SSHTunnel) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
105111
conn, err := tunnel.Client.DialContext(ctx, network, address)
106112
if err != nil {
@@ -114,7 +120,7 @@ func (tunnel *SSHTunnel) Close() error {
114120
if keepaliveChan := tunnel.keepaliveChan.Swap(nil); keepaliveChan != nil {
115121
close(*keepaliveChan)
116122
}
117-
tunnel.badTunnel = true
123+
tunnel.badTunnel.Store(true)
118124
return tunnel.Client.Close()
119125
}
120126
return nil
@@ -141,7 +147,7 @@ func (tunnel *SSHTunnel) runKeepaliveLoop(
141147
if keepaliveChan := tunnel.keepaliveChan.Swap(nil); keepaliveChan != nil {
142148
close(*keepaliveChan)
143149
}
144-
tunnel.badTunnel = true
150+
tunnel.badTunnel.Store(true)
145151
if onFailure != nil {
146152
onFailure()
147153
}
@@ -169,7 +175,7 @@ func (tunnel *SSHTunnel) runKeepaliveLoop(
169175
if keepaliveChan := tunnel.keepaliveChan.Swap(nil); keepaliveChan != nil {
170176
close(*keepaliveChan)
171177
}
172-
tunnel.badTunnel = true
178+
tunnel.badTunnel.Store(true)
173179
if onFailure != nil {
174180
onFailure()
175181
}
@@ -179,7 +185,7 @@ func (tunnel *SSHTunnel) runKeepaliveLoop(
179185
}
180186

181187
func (tunnel *SSHTunnel) StartKeepalive(ctx context.Context, onFailure func()) {
182-
if tunnel == nil || tunnel.Client == nil || tunnel.badTunnel {
188+
if tunnel == nil || tunnel.Client == nil || tunnel.badTunnel.Load() {
183189
return
184190
}
185191
if tunnel.keepaliveChan.Load() != nil {
@@ -195,7 +201,7 @@ func (tunnel *SSHTunnel) StartKeepalive(ctx context.Context, onFailure func()) {
195201
// returns a channel that is closed if the SSH keepalive fails,
196202
// or nil if no SSH tunnel is configured
197203
func (tunnel *SSHTunnel) GetKeepaliveChan(ctx context.Context) <-chan struct{} {
198-
if tunnel == nil || tunnel.Client == nil || tunnel.badTunnel {
204+
if tunnel == nil || tunnel.Client == nil || tunnel.badTunnel.Load() {
199205
// nil channel would be of no consequence in a select
200206
// UNLESS it's the only branch in a select, in which case it would block forever
201207
return nil

flow/connectors/utils/ssh_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ func TestSSHTunnel_GetKeepaliveChan_NilCases(t *testing.T) {
2323
require.Nil(t, tunnel.GetKeepaliveChan(context.Background()), "Tunnel with nil client should return nil channel")
2424

2525
// Bad tunnel
26-
tunnel = &SSHTunnel{Client: nil, badTunnel: true}
26+
tunnel = &SSHTunnel{Client: nil}
27+
tunnel.badTunnel.Store(true)
2728
require.Nil(t, tunnel.GetKeepaliveChan(context.Background()), "Bad tunnel should return nil channel")
2829
}
2930

@@ -39,7 +40,8 @@ func TestSSHTunnel_StartKeepalive_NilCases(t *testing.T) {
3940
tunnel.StartKeepalive(context.Background(), onFailure)
4041
require.False(t, called, "Tunnel with nil client should not call onFailure")
4142

42-
tunnel = &SSHTunnel{Client: nil, badTunnel: true}
43+
tunnel = &SSHTunnel{Client: nil}
44+
tunnel.badTunnel.Store(true)
4345
tunnel.StartKeepalive(context.Background(), onFailure)
4446
require.False(t, called, "Bad tunnel should not call onFailure")
4547
}

flow/shared/exceptions/ssh.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,21 @@ func (e *SSHTunnelSetupError) Error() string {
1717
func (e *SSHTunnelSetupError) Unwrap() error {
1818
return e.error
1919
}
20+
21+
// SSHTunnelConnectionError represents errors that surface on a connection tunneled over SSH after the
22+
// SSH tunnel has gone bad (e.g. keepalive failure causes us to close the underlying connections mid-CDC).
23+
type SSHTunnelConnectionError struct {
24+
error
25+
}
26+
27+
func NewSSHTunnelConnectionError(err error) *SSHTunnelConnectionError {
28+
return &SSHTunnelConnectionError{err}
29+
}
30+
31+
func (e *SSHTunnelConnectionError) Error() string {
32+
return "SSH Tunnel Connection Error: " + e.error.Error()
33+
}
34+
35+
func (e *SSHTunnelConnectionError) Unwrap() error {
36+
return e.error
37+
}

0 commit comments

Comments
 (0)