diff --git a/src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs b/src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs index 9076965334d..53b4a3c04ed 100644 --- a/src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs +++ b/src/Polly.Core/CircuitBreaker/Controller/CircuitStateController.cs @@ -95,7 +95,9 @@ public ValueTask IsolateCircuitAsync(ResilienceContext context) lock (_lock) { - SetLastHandledOutcome_NeedsLock(Outcome.FromException(new IsolatedCircuitException())); + var exception = new IsolatedCircuitException(); + _telemetry.SetTelemetrySource(exception); + SetLastHandledOutcome_NeedsLock(Outcome.FromException(exception)); OpenCircuitFor_NeedsLock(Outcome.FromResult(default), TimeSpan.MaxValue, manual: true, context, out task); _circuitState = CircuitState.Isolated; } @@ -123,7 +125,7 @@ public ValueTask CloseCircuitAsync(ResilienceContext context) { EnsureNotDisposed(); - Exception? exception = null; + BrokenCircuitException? exception = null; bool isHalfOpen = false; Task? task = null; @@ -157,6 +159,7 @@ public ValueTask CloseCircuitAsync(ResilienceContext context) if (exception is not null) { + _telemetry.SetTelemetrySource(exception); return Outcome.FromException(exception); } @@ -308,11 +311,13 @@ private void SetLastHandledOutcome_NeedsLock(Outcome outcome) private BrokenCircuitException CreateBrokenCircuitException() { TimeSpan retryAfter = _blockedUntil - _timeProvider.GetUtcNow(); - return _breakingException switch + var exception = _breakingException switch { - Exception exception => new BrokenCircuitException(BrokenCircuitException.DefaultMessage, retryAfter, exception), + Exception ex => new BrokenCircuitException(BrokenCircuitException.DefaultMessage, retryAfter, ex), _ => new BrokenCircuitException(BrokenCircuitException.DefaultMessage, retryAfter) }; + _telemetry.SetTelemetrySource(exception); + return exception; } private void OpenCircuit_NeedsLock(Outcome outcome, bool manual, ResilienceContext context, out Task? scheduledTask) diff --git a/src/Polly.Core/ExecutionRejectedException.cs b/src/Polly.Core/ExecutionRejectedException.cs index b5c8e4e0684..bffece9058e 100644 --- a/src/Polly.Core/ExecutionRejectedException.cs +++ b/src/Polly.Core/ExecutionRejectedException.cs @@ -2,6 +2,8 @@ using System.Runtime.Serialization; #endif +using Polly.Telemetry; + namespace Polly; /// @@ -49,4 +51,9 @@ protected ExecutionRejectedException(SerializationInfo info, StreamingContext co } #endif #pragma warning restore RS0016 // Add public types and members to the declared API + + /// + /// Gets the source of the strategy which has thrown the exception, if known. + /// + public virtual ResilienceTelemetrySource? TelemetrySource { get; internal set; } } diff --git a/src/Polly.Core/PublicAPI.Unshipped.txt b/src/Polly.Core/PublicAPI.Unshipped.txt index f82785466ab..df1ac3ec332 100644 --- a/src/Polly.Core/PublicAPI.Unshipped.txt +++ b/src/Polly.Core/PublicAPI.Unshipped.txt @@ -3,3 +3,5 @@ Polly.CircuitBreaker.BrokenCircuitException.BrokenCircuitException(string! messa Polly.CircuitBreaker.BrokenCircuitException.BrokenCircuitException(string! message, System.TimeSpan retryAfter, System.Exception! inner) -> void Polly.CircuitBreaker.BrokenCircuitException.BrokenCircuitException(System.TimeSpan retryAfter) -> void Polly.CircuitBreaker.BrokenCircuitException.RetryAfter.get -> System.TimeSpan? +virtual Polly.ExecutionRejectedException.TelemetrySource.get -> Polly.Telemetry.ResilienceTelemetrySource? +Polly.Telemetry.ResilienceStrategyTelemetry.SetTelemetrySource(Polly.ExecutionRejectedException! exception) -> void diff --git a/src/Polly.Core/Telemetry/ResilienceStrategyTelemetry.cs b/src/Polly.Core/Telemetry/ResilienceStrategyTelemetry.cs index 966a74312df..e793202e7ad 100644 --- a/src/Polly.Core/Telemetry/ResilienceStrategyTelemetry.cs +++ b/src/Polly.Core/Telemetry/ResilienceStrategyTelemetry.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; + namespace Polly.Telemetry; /// @@ -21,6 +23,18 @@ internal ResilienceStrategyTelemetry(ResilienceTelemetrySource source, Telemetry internal ResilienceTelemetrySource TelemetrySource { get; } + /// + /// Sets the source of the telemetry on the provided exception. + /// + /// The to-be-set exception. + [EditorBrowsable(EditorBrowsableState.Never)] + public void SetTelemetrySource(ExecutionRejectedException exception) + { + Guard.NotNull(exception); + + exception.TelemetrySource = TelemetrySource; + } + /// /// Reports an event that occurred in a resilience strategy. /// diff --git a/src/Polly.Core/Timeout/TimeoutResilienceStrategy.cs b/src/Polly.Core/Timeout/TimeoutResilienceStrategy.cs index 5b17fe2d720..98f570c87fb 100644 --- a/src/Polly.Core/Timeout/TimeoutResilienceStrategy.cs +++ b/src/Polly.Core/Timeout/TimeoutResilienceStrategy.cs @@ -74,6 +74,7 @@ protected internal override async ValueTask> ExecuteCore(timeoutException.TrySetStackTrace()); } diff --git a/src/Polly.RateLimiting/RateLimiterRejectedException.cs b/src/Polly.RateLimiting/RateLimiterRejectedException.cs index a5fc88d5c0a..d9416332c26 100644 --- a/src/Polly.RateLimiting/RateLimiterRejectedException.cs +++ b/src/Polly.RateLimiting/RateLimiterRejectedException.cs @@ -44,7 +44,8 @@ public RateLimiterRejectedException(string message) /// The message that describes the error. /// The retry after value. public RateLimiterRejectedException(string message, TimeSpan retryAfter) - : base(message) => RetryAfter = retryAfter; + : base(message) + => RetryAfter = retryAfter; /// /// Initializes a new instance of the class. @@ -63,7 +64,8 @@ public RateLimiterRejectedException(string message, Exception inner) /// The retry after value. /// The inner exception. public RateLimiterRejectedException(string message, TimeSpan retryAfter, Exception inner) - : base(message, inner) => RetryAfter = retryAfter; + : base(message, inner) + => RetryAfter = retryAfter; /// /// Gets the amount of time to wait before retrying again. @@ -84,10 +86,10 @@ public RateLimiterRejectedException(string message, TimeSpan retryAfter, Excepti private RateLimiterRejectedException(SerializationInfo info, StreamingContext context) : base(info, context) { - var value = info.GetDouble("RetryAfter"); - if (value >= 0.0) + var retryAfter = info.GetDouble(nameof(RetryAfter)); + if (retryAfter >= 0.0) { - RetryAfter = TimeSpan.FromSeconds(value); + RetryAfter = TimeSpan.FromSeconds(retryAfter); } } @@ -96,14 +98,7 @@ public override void GetObjectData(SerializationInfo info, StreamingContext cont { Guard.NotNull(info); - if (RetryAfter.HasValue) - { - info.AddValue("RetryAfter", RetryAfter.Value.TotalSeconds); - } - else - { - info.AddValue("RetryAfter", -1.0); - } + info.AddValue(nameof(RetryAfter), RetryAfter.HasValue ? RetryAfter.Value.TotalSeconds : -1.0); base.GetObjectData(info, context); } diff --git a/src/Polly.RateLimiting/RateLimiterResilienceStrategy.cs b/src/Polly.RateLimiting/RateLimiterResilienceStrategy.cs index ec29f4c0ee1..f7431e571f6 100644 --- a/src/Polly.RateLimiting/RateLimiterResilienceStrategy.cs +++ b/src/Polly.RateLimiting/RateLimiterResilienceStrategy.cs @@ -65,7 +65,11 @@ protected override async ValueTask> ExecuteCore(exception.TrySetStackTrace()); } diff --git a/test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs b/test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs index 7a623544629..091de464e3e 100644 --- a/test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs +++ b/test/Polly.Core.Tests/CircuitBreaker/Controller/CircuitStateControllerTests.cs @@ -52,8 +52,9 @@ public async Task IsolateAsync_Ok() called.Should().BeTrue(); var outcome = await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get()); - outcome.Value.Exception.Should().BeOfType() - .And.Subject.As().RetryAfter.Should().BeNull(); + var exception = outcome.Value.Exception.Should().BeOfType().Subject; + exception.RetryAfter.Should().BeNull(); + exception.TelemetrySource.Should().NotBeNull(); // now close it await controller.CloseCircuitAsync(ResilienceContextPool.Shared.Get()); @@ -119,8 +120,9 @@ public async Task OnActionPreExecute_CircuitOpenedByValue() using var controller = CreateController(); await OpenCircuit(controller, Outcome.FromResult(99)); - var error = (BrokenCircuitException)(await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get())).Value.Exception!; - error.Should().BeOfType().And.Subject.As().RetryAfter.Should().NotBeNull(); + var exception = (BrokenCircuitException)(await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get())).Value.Exception!; + exception.RetryAfter.Should().NotBeNull(); + exception.TelemetrySource.Should().NotBeNull(); GetBlockedTill(controller).Should().Be(_timeProvider.GetUtcNow() + _options.BreakDuration); } @@ -149,6 +151,7 @@ await OpenCircuit( stacks.Add(e.StackTrace!); e.Message.Should().Be("The circuit is now open and is not allowing calls."); e.RetryAfter.Should().NotBeNull(); + e.TelemetrySource.Should().NotBeNull(); if (innerException) { @@ -206,9 +209,10 @@ public async Task OnActionPreExecute_CircuitOpenedByException() using var controller = CreateController(); await OpenCircuit(controller, Outcome.FromException(new InvalidOperationException())); - var error = (BrokenCircuitException)(await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get())).Value.Exception!; - error.InnerException.Should().BeOfType(); - error.RetryAfter.Should().NotBeNull(); + var exception = (BrokenCircuitException)(await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get())).Value.Exception!; + exception.InnerException.Should().BeOfType(); + exception.RetryAfter.Should().NotBeNull(); + exception.TelemetrySource.Should().NotBeNull(); } [Fact] @@ -261,9 +265,11 @@ public async Task OnActionPreExecute_HalfOpen() // act await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get()); var error = (await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get())).Value.Exception; - error.Should().BeOfType().And.Subject.As().RetryAfter.Should().NotBeNull(); // assert + var exception = error.Should().BeOfType().Subject; + exception.RetryAfter.Should().NotBeNull(); + exception.TelemetrySource.Should().NotBeNull(); controller.CircuitState.Should().Be(CircuitState.HalfOpen); called.Should().BeTrue(); } @@ -465,7 +471,9 @@ public async Task OnActionFailureAsync_VoidResult_EnsureBreakingExceptionNotSet( // assert controller.LastException.Should().BeNull(); var outcome = await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get()); - outcome.Value.Exception.Should().BeOfType().And.Subject.As().RetryAfter.Should().NotBeNull(); + var exception = outcome.Value.Exception.Should().BeOfType().Subject; + exception.RetryAfter.Should().NotBeNull(); + exception.TelemetrySource.Should().NotBeNull(); } [Fact] @@ -501,8 +509,9 @@ public async Task Flow_Closed_HalfOpen_Open_HalfOpen_Closed() TimeSpan advanceTimeRejected = TimeSpan.FromMilliseconds(1); AdvanceTime(advanceTimeRejected); var outcome = await controller.OnActionPreExecuteAsync(ResilienceContextPool.Shared.Get()); - outcome.Value.Exception.Should().BeOfType() - .And.Subject.As().RetryAfter.Should().Be(_options.BreakDuration - advanceTimeRejected); + var exception = outcome.Value.Exception.Should().BeOfType().Subject; + exception.RetryAfter.Should().Be(_options.BreakDuration - advanceTimeRejected); + exception.TelemetrySource.Should().NotBeNull(); // wait and try, transition to half open AdvanceTime(_options.BreakDuration + _options.BreakDuration); diff --git a/test/Polly.Core.Tests/Telemetry/ResilienceStrategyTelemetryTests.cs b/test/Polly.Core.Tests/Telemetry/ResilienceStrategyTelemetryTests.cs index 90b58338be1..743271b6c07 100644 --- a/test/Polly.Core.Tests/Telemetry/ResilienceStrategyTelemetryTests.cs +++ b/test/Polly.Core.Tests/Telemetry/ResilienceStrategyTelemetryTests.cs @@ -1,4 +1,5 @@ using Polly.Telemetry; +using Polly.Timeout; namespace Polly.Core.Tests.Telemetry; @@ -94,4 +95,25 @@ public void Report_NoListener_ShouldNotThrow() .Should() .NotThrow(); } + + [Fact] + public void SetTelemetrySource_Ok() + { + var sut = new ResilienceStrategyTelemetry(_source, null); + var exception = new TimeoutRejectedException(); + + sut.SetTelemetrySource(exception); + + exception.TelemetrySource.Should().Be(_source); + } + + [Fact] + public void SetTelemetrySource_ShouldThrow() + { + ExecutionRejectedException? exception = null; + + _sut.Invoking(s => s.SetTelemetrySource(exception!)) + .Should() + .Throw(); + } } diff --git a/test/Polly.Core.Tests/Timeout/TimeoutResilienceStrategyTests.cs b/test/Polly.Core.Tests/Timeout/TimeoutResilienceStrategyTests.cs index 1af944ab350..1d6c8e0a8ae 100644 --- a/test/Polly.Core.Tests/Timeout/TimeoutResilienceStrategyTests.cs +++ b/test/Polly.Core.Tests/Timeout/TimeoutResilienceStrategyTests.cs @@ -173,6 +173,27 @@ public async Task Execute_Timeout_EnsureStackTrace() } } + [Fact] + public async Task Execute_Timeout_EnsureTelemetrySource() + { + SetTimeout(TimeSpan.FromSeconds(2)); + var sut = CreateSut(); + + var outcome = await sut.ExecuteOutcomeAsync( + async (c, _) => + { + var delay = _timeProvider.Delay(TimeSpan.FromSeconds(4), c.CancellationToken); + _timeProvider.Advance(TimeSpan.FromSeconds(2)); + await delay; + + return Outcome.FromResult("dummy"); + }, + ResilienceContextPool.Shared.Get(), + "state"); + + outcome.Exception.Should().BeOfType().Subject.TelemetrySource.Should().NotBeNull(); + } + [Fact] public async Task Execute_Cancelled_EnsureNoTimeout() { diff --git a/test/Polly.RateLimiting.Tests/RateLimiterRejectedExceptionTests.cs b/test/Polly.RateLimiting.Tests/RateLimiterRejectedExceptionTests.cs index b615e322bce..4239db541ca 100644 --- a/test/Polly.RateLimiting.Tests/RateLimiterRejectedExceptionTests.cs +++ b/test/Polly.RateLimiting.Tests/RateLimiterRejectedExceptionTests.cs @@ -4,19 +4,67 @@ namespace Polly.Core.Tests.Timeout; public class RateLimiterRejectedExceptionTests { + private readonly string _message = "dummy"; + private readonly TimeSpan _retryAfter = TimeSpan.FromSeconds(4); + [Fact] public void Ctor_Ok() { - var retryAfter = TimeSpan.FromSeconds(4); + var exception = new RateLimiterRejectedException(); + exception.InnerException.Should().BeNull(); + exception.Message.Should().Be("The operation could not be executed because it was rejected by the rate limiter."); + exception.RetryAfter.Should().BeNull(); + exception.TelemetrySource.Should().BeNull(); + } + + [Fact] + public void Ctor_RetryAfter_Ok() + { + var exception = new RateLimiterRejectedException(_retryAfter); + exception.InnerException.Should().BeNull(); + exception.Message.Should().Be($"The operation could not be executed because it was rejected by the rate limiter. It can be retried after '00:00:04'."); + exception.RetryAfter.Should().Be(_retryAfter); + exception.TelemetrySource.Should().BeNull(); + } + + [Fact] + public void Ctor_Message_Ok() + { + var exception = new RateLimiterRejectedException(_message); + exception.InnerException.Should().BeNull(); + exception.Message.Should().Be(_message); + exception.RetryAfter.Should().BeNull(); + exception.TelemetrySource.Should().BeNull(); + } + + [Fact] + public void Ctor_Message_RetryAfter_Ok() + { + var exception = new RateLimiterRejectedException(_message, _retryAfter); + exception.InnerException.Should().BeNull(); + exception.Message.Should().Be(_message); + exception.RetryAfter.Should().Be(_retryAfter); + exception.TelemetrySource.Should().BeNull(); + } + + [Fact] + public void Ctor_Message_InnerException_Ok() + { + var exception = new RateLimiterRejectedException(_message, new InvalidOperationException()); + exception.InnerException.Should().BeOfType(); + exception.Message.Should().Be(_message); + exception.RetryAfter.Should().BeNull(); + exception.TelemetrySource.Should().BeNull(); + } - new RateLimiterRejectedException().Message.Should().Be("The operation could not be executed because it was rejected by the rate limiter."); - new RateLimiterRejectedException().RetryAfter.Should().BeNull(); - new RateLimiterRejectedException("dummy").Message.Should().Be("dummy"); - new RateLimiterRejectedException("dummy", new InvalidOperationException()).Message.Should().Be("dummy"); - new RateLimiterRejectedException(retryAfter).RetryAfter.Should().Be(retryAfter); - new RateLimiterRejectedException(retryAfter).Message.Should().Be($"The operation could not be executed because it was rejected by the rate limiter. It can be retried after '{retryAfter}'."); - new RateLimiterRejectedException("dummy", retryAfter).RetryAfter.Should().Be(retryAfter); - new RateLimiterRejectedException("dummy", retryAfter, new InvalidOperationException()).RetryAfter.Should().Be(retryAfter); + [Fact] + public void Ctor_Message_RetryAfter_InnerException_Ok() + { + var exception = new RateLimiterRejectedException(_message, _retryAfter, new InvalidOperationException()); + exception.InnerException.Should().BeOfType(); + exception.Message.Should().Be(_message); + exception.RetryAfter.Should().Be(_retryAfter); + exception.TelemetrySource.Should().BeNull(); } #if !NETCOREAPP diff --git a/test/Polly.RateLimiting.Tests/RateLimiterResilienceStrategyTests.cs b/test/Polly.RateLimiting.Tests/RateLimiterResilienceStrategyTests.cs index febb350bcf5..3032d3e3676 100644 --- a/test/Polly.RateLimiting.Tests/RateLimiterResilienceStrategyTests.cs +++ b/test/Polly.RateLimiting.Tests/RateLimiterResilienceStrategyTests.cs @@ -70,13 +70,13 @@ public async Task Execute_LeaseRejected(bool hasEvents, bool hasRetryAfter) var context = ResilienceContextPool.Shared.Get(cts.Token); var outcome = await strategy.ExecuteOutcomeAsync((_, _) => Outcome.FromResultAsValueTask("dummy"), context, "state"); - outcome.Exception + RateLimiterRejectedException exception = outcome.Exception .Should() - .BeOfType().Subject - .RetryAfter - .Should().Be((TimeSpan?)metadata); + .BeOfType().Subject; - outcome.Exception!.StackTrace.Should().Contain("Execute_LeaseRejected"); + exception.RetryAfter.Should().Be((TimeSpan?)metadata); + exception.StackTrace.Should().Contain("Execute_LeaseRejected"); + exception.TelemetrySource.Should().NotBeNull(); eventCalled.Should().Be(hasEvents);