diff --git a/core/errors/errors.go b/core/errors/errors.go index a5d0216..de4fbfc 100644 --- a/core/errors/errors.go +++ b/core/errors/errors.go @@ -7,6 +7,11 @@ import ( "go.uber.org/zap" ) +// ErrClientCancelled is the sentinel cause for client-initiated cancellations. +// Pass it to context.WithCancelCause so downstream code can distinguish a client +// disconnect from other cancellation sources via IsClientCancellation. +var ErrClientCancelled = stderrs.New("client cancelled") + // ErrorCode mirrors the proto ErrorCode enum and classifies a TangoError as // a user error or an infra error (retryable or not). type ErrorCode int @@ -78,6 +83,13 @@ func newError(err error, code ErrorCode) error { } } +// IsClientCancellation reports whether ctx was cancelled with ErrClientCancelled +// as its cause. Use this to distinguish client-initiated disconnects from +// server-side or timeout cancellations. +func IsClientCancellation(ctx context.Context) bool { + return stderrs.Is(context.Cause(ctx), ErrClientCancelled) +} + // GetErrorCode extracts the ErrorCode from err. // If err is context.Canceled, ErrorCancelled is returned. // If err wraps a TangoError, its code is returned. diff --git a/core/errors/errors_test.go b/core/errors/errors_test.go index 5ceb0a4..2bb510d 100644 --- a/core/errors/errors_test.go +++ b/core/errors/errors_test.go @@ -58,6 +58,36 @@ func TestNewError_RewrappingOverwritesCode(t *testing.T) { assert.Equal(t, ErrorInfra, te.errorCode) } +func TestIsClientCancellation(t *testing.T) { + t.Run("context cancelled with ErrClientCancelled cause", func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(ErrClientCancelled) + assert.True(t, IsClientCancellation(ctx)) + }) + + t.Run("context cancelled without cause", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + assert.False(t, IsClientCancellation(ctx)) + }) + + t.Run("context cancelled with different cause", func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(stderrs.New("something else")) + assert.False(t, IsClientCancellation(ctx)) + }) + + t.Run("context not cancelled", func(t *testing.T) { + assert.False(t, IsClientCancellation(context.Background())) + }) + + t.Run("wrapped ErrClientCancelled cause", func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(fmt.Errorf("transport: %w", ErrClientCancelled)) + assert.True(t, IsClientCancellation(ctx)) + }) +} + func TestGetErrorCode(t *testing.T) { tests := []struct { name string