From 15b60969b3a4c72b44283dad58cecb5ca48dd19f Mon Sep 17 00:00:00 2001 From: Yushan Lin Date: Thu, 16 Jul 2026 15:12:09 -0700 Subject: [PATCH] feat(errors): add ErrClientCancelled sentinel and IsClientCancellation helper Allows callers to distinguish client-initiated cancellations from server-side or timeout cancellations using context.WithCancelCause. Co-Authored-By: Claude Sonnet 5 --- core/errors/errors.go | 12 ++++++++++++ core/errors/errors_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/core/errors/errors.go b/core/errors/errors.go index a5d0216d..de4fbfc2 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 5ceb0a43..2bb510d5 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