From 37cf7c1a44609898b26c50b375d89be3e04458dc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 04:32:51 -0400 Subject: [PATCH] =?UTF-8?q?fix(esg):=20cancellation-aware=20ErrorClassifie?= =?UTF-8?q?r.IsTransient=20overload=20=E2=80=94=20caller-cancelled=20work?= =?UTF-8?q?=20can=20never=20classify=20transient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../ErrorClassifier.cs | 16 ++++++++++++++++ .../ExternalSystemClient.cs | 2 +- .../ErrorClassifierTests.cs | 9 +++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ErrorClassifier.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ErrorClassifier.cs index be1509e0..de21b912 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ErrorClassifier.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ErrorClassifier.cs @@ -26,6 +26,10 @@ public static class ErrorClassifier /// /// Determines whether an exception represents a transient failure. + /// Prefer the cancellation-aware + /// overload at call sites that hold the caller's token — this token-less overload + /// classifies every as transient, including + /// caller-requested cancellations that must never be buffered for S&F retry. /// /// The exception to classify. /// for connection/timeout/cancellation exceptions; otherwise. @@ -37,6 +41,18 @@ public static class ErrorClassifier or OperationCanceledException; } + /// + /// Cancellation-aware overload: a caller-requested cancellation is NOT transient + /// (buffering caller-abandoned work into S&F would be wrong). Prefer this overload + /// at call sites that hold the caller's token. + /// + public static bool IsTransient(Exception exception, CancellationToken cancellationToken) + { + if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested) + return false; + return IsTransient(exception); + } + /// /// Creates a TransientException for S&F buffering. /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs index 6904fb9b..9144b0e7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs @@ -340,7 +340,7 @@ public class ExternalSystemClient : IExternalSystemClient throw ErrorClassifier.AsTransient( $"Timeout calling {system.Name} after {effectiveTimeout.TotalSeconds:0.##}s", ex); } - catch (Exception ex) when (ErrorClassifier.IsTransient(ex)) + catch (Exception ex) when (ErrorClassifier.IsTransient(ex, cancellationToken)) { throw ErrorClassifier.AsTransient($"Connection error to {system.Name}: {ex.Message}", ex); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ErrorClassifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ErrorClassifierTests.cs index d61ce571..7a548c6e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ErrorClassifierTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ErrorClassifierTests.cs @@ -64,4 +64,13 @@ public class ErrorClassifierTests Assert.IsType(ex); Assert.Equal("test message", ex.Message); } + + [Fact] + public void IsTransient_CallerCancelledOce_IsNotTransient() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + Assert.False(ErrorClassifier.IsTransient(new OperationCanceledException(), cts.Token)); + Assert.True(ErrorClassifier.IsTransient(new OperationCanceledException(), CancellationToken.None)); + } }