From 328ccb89f6a529b59ae5a295e56052f1275e69bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:19:19 +0000 Subject: [PATCH 1/2] feat: add built-in request timeout interceptor (ITimeoutRequest) Agent-Logs-Url: https://github.com/dailydevops/pulse/sessions/38188320-52ea-4c0b-aadd-c1c8a2f0e5c6 Co-authored-by: samtrion <3283596+samtrion@users.noreply.github.com> --- .../ITimeoutRequest.cs | 38 ++++ .../Interceptors/TimeoutRequestInterceptor.cs | 86 ++++++++ .../TimeoutMediatorConfiguratorExtensions.cs | 70 +++++++ .../TimeoutRequestInterceptorOptions.cs | 28 +++ .../RequestTimeoutTests.cs | 178 ++++++++++++++++ .../TimeoutRequestInterceptorTests.cs | 194 ++++++++++++++++++ 6 files changed, 594 insertions(+) create mode 100644 src/NetEvolve.Pulse.Extensibility/ITimeoutRequest.cs create mode 100644 src/NetEvolve.Pulse/Interceptors/TimeoutRequestInterceptor.cs create mode 100644 src/NetEvolve.Pulse/TimeoutMediatorConfiguratorExtensions.cs create mode 100644 src/NetEvolve.Pulse/TimeoutRequestInterceptorOptions.cs create mode 100644 tests/NetEvolve.Pulse.Tests.Integration/RequestTimeoutTests.cs create mode 100644 tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs diff --git a/src/NetEvolve.Pulse.Extensibility/ITimeoutRequest.cs b/src/NetEvolve.Pulse.Extensibility/ITimeoutRequest.cs new file mode 100644 index 00000000..01727332 --- /dev/null +++ b/src/NetEvolve.Pulse.Extensibility/ITimeoutRequest.cs @@ -0,0 +1,38 @@ +namespace NetEvolve.Pulse.Extensibility; + +/// +/// Marker interface for requests that enforce a per-request deadline using a . +/// Implement this interface alongside or to opt in to +/// built-in timeout enforcement without any external dependencies. +/// +/// +/// Usage: +/// When a request implements , the TimeoutRequestInterceptor +/// will create a linked using the value returned by . +/// If the handler does not complete within that deadline, a is thrown. +/// Precedence: +/// The per-request value takes precedence over any globally configured fallback timeout. +/// Distinguishing Timeout from User Cancellation: +/// The interceptor correctly distinguishes between a timeout-triggered cancellation and a caller-initiated +/// cancellation, re-throwing a only in the former case. +/// +/// +/// +/// public record ProcessOrderCommand(string OrderId) : ICommand<OrderResult>, ITimeoutRequest +/// { +/// public string? CorrelationId { get; set; } +/// public TimeSpan Timeout => TimeSpan.FromSeconds(10); +/// } +/// +/// +/// +/// +/// +public interface ITimeoutRequest +{ + /// + /// Gets the maximum allowed duration for the handler to complete before a + /// is raised. + /// + TimeSpan Timeout { get; } +} diff --git a/src/NetEvolve.Pulse/Interceptors/TimeoutRequestInterceptor.cs b/src/NetEvolve.Pulse/Interceptors/TimeoutRequestInterceptor.cs new file mode 100644 index 00000000..abf63f75 --- /dev/null +++ b/src/NetEvolve.Pulse/Interceptors/TimeoutRequestInterceptor.cs @@ -0,0 +1,86 @@ +namespace NetEvolve.Pulse.Interceptors; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse.Extensibility; + +/// +/// Built-in request interceptor that enforces a per-request deadline using a linked +/// , without any external dependencies. +/// +/// The type of request being intercepted. +/// The type of response produced by the request. +/// +/// Activation: +/// The interceptor enforces a timeout when either: +/// +/// The request implements — its value is used as the deadline. +/// A global fallback timeout is configured via — applied to all requests that do not implement . +/// +/// When neither condition is met the interceptor is a transparent pass-through. +/// Cancellation Semantics: +/// The interceptor correctly distinguishes between a timeout-triggered cancellation and a +/// caller-initiated cancellation: only when the deadline is exceeded is a +/// thrown. Caller cancellations are propagated as +/// as usual. +/// Resource Management: +/// The internally created is always disposed, even when +/// the handler throws. +/// +internal sealed class TimeoutRequestInterceptor : IRequestInterceptor + where TRequest : IRequest +{ + private readonly IOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The timeout interceptor options. + /// Thrown when is . + public TimeoutRequestInterceptor(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + } + + /// + /// + /// Thrown when the handler does not complete within the configured deadline and the original + /// has not been independently cancelled. + /// + public async Task HandleAsync( + TRequest request, + Func> handler, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(handler); + + // Determine the effective timeout for this request. + // ITimeoutRequest.Timeout takes precedence over the global fallback. + var timeout = request is ITimeoutRequest timeoutRequest ? timeoutRequest.Timeout : _options.Value.GlobalTimeout; + + // No timeout configured — transparent pass-through. + if (timeout is null) + { + return await handler(request, cancellationToken).ConfigureAwait(false); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout.Value); + + try + { + return await handler(request, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested && cts.Token.IsCancellationRequested) + { + throw new TimeoutException( + $"The request '{typeof(TRequest).Name}' timed out after {timeout.Value.TotalMilliseconds}ms." + ); + } + } +} diff --git a/src/NetEvolve.Pulse/TimeoutMediatorConfiguratorExtensions.cs b/src/NetEvolve.Pulse/TimeoutMediatorConfiguratorExtensions.cs new file mode 100644 index 00000000..012e1a48 --- /dev/null +++ b/src/NetEvolve.Pulse/TimeoutMediatorConfiguratorExtensions.cs @@ -0,0 +1,70 @@ +namespace NetEvolve.Pulse; + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Interceptors; + +/// +/// Provides fluent extension methods for registering the built-in request timeout interceptor +/// with the Pulse mediator. +/// +/// +/// +public static class TimeoutMediatorConfiguratorExtensions +{ + /// + /// Registers the built-in TimeoutRequestInterceptor that enforces per-request deadlines + /// using a linked . + /// + /// The mediator configurator. + /// + /// An optional global fallback timeout applied to all requests that do not implement + /// . + /// When (default), only requests implementing + /// are subject to a deadline. + /// + /// The configurator for method chaining. + /// Thrown when is . + /// + /// Timeout Resolution: + /// + /// If the request implements , its is used. + /// Otherwise, is applied (when provided). + /// If neither is set, the interceptor is a transparent pass-through for that request. + /// + /// Cancellation Semantics: + /// A is thrown only when the deadline is exceeded. + /// Caller-initiated cancellations propagate as as usual. + /// + /// + /// Without global timeout (only ITimeoutRequest requests are affected): + /// + /// services.AddPulse(c => c.AddRequestTimeout()); + /// + /// With global fallback timeout (all requests are affected): + /// + /// services.AddPulse(c => c.AddRequestTimeout(TimeSpan.FromSeconds(30))); + /// + /// + /// + /// + public static IMediatorConfigurator AddRequestTimeout( + this IMediatorConfigurator configurator, + TimeSpan? globalTimeout = null + ) + { + ArgumentNullException.ThrowIfNull(configurator); + + _ = configurator.Services.Configure(opts => + opts.GlobalTimeout = globalTimeout + ); + + configurator.Services.TryAddEnumerable( + ServiceDescriptor.Singleton(typeof(IRequestInterceptor<,>), typeof(TimeoutRequestInterceptor<,>)) + ); + + return configurator; + } +} diff --git a/src/NetEvolve.Pulse/TimeoutRequestInterceptorOptions.cs b/src/NetEvolve.Pulse/TimeoutRequestInterceptorOptions.cs new file mode 100644 index 00000000..46cb6c11 --- /dev/null +++ b/src/NetEvolve.Pulse/TimeoutRequestInterceptorOptions.cs @@ -0,0 +1,28 @@ +namespace NetEvolve.Pulse; + +/// +/// Options for the built-in request timeout interceptor registered via AddRequestTimeout(). +/// +/// +/// Global Timeout: +/// When is set, all requests that do not implement +/// are also subject to the global deadline. +/// Requests that implement always use their own +/// value, which takes precedence over +/// . +/// +/// +/// +/// services.AddPulse(c => c.AddRequestTimeout(TimeSpan.FromSeconds(30))); +/// +/// +/// +public sealed class TimeoutRequestInterceptorOptions +{ + /// + /// Gets or sets the global fallback timeout applied to all requests that do not implement + /// . + /// When (default), requests without an explicit timeout are not affected. + /// + public TimeSpan? GlobalTimeout { get; set; } +} diff --git a/tests/NetEvolve.Pulse.Tests.Integration/RequestTimeoutTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/RequestTimeoutTests.cs new file mode 100644 index 00000000..9a70d15b --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Integration/RequestTimeoutTests.cs @@ -0,0 +1,178 @@ +namespace NetEvolve.Pulse.Tests.Integration; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NetEvolve.Pulse.Extensibility; +using TUnit.Core; + +public sealed class RequestTimeoutTests +{ + private static ServiceCollection CreateServiceCollection() + { + var services = new ServiceCollection(); + _ = services.AddLogging().AddSingleton(TimeProvider.System); + return services; + } + + [Test] + public async Task SendAsync_WithTimeoutRequest_WhenCompletesWithinDeadline_ReturnsResult() + { + var services = CreateServiceCollection(); + _ = services + .AddPulse(config => config.AddRequestTimeout()) + .AddScoped, FastTimeoutCommandHandler>(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var command = new FastTimeoutCommand(TimeSpan.FromSeconds(5)); + var result = await mediator.SendAsync(command).ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("fast-result"); + } + + [Test] + public async Task SendAsync_WithTimeoutRequest_WhenExceedsDeadline_ThrowsTimeoutException() + { + var services = CreateServiceCollection(); + _ = services + .AddPulse(config => config.AddRequestTimeout()) + .AddScoped, SlowTimeoutCommandHandler>(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var command = new SlowTimeoutCommand(TimeSpan.FromMilliseconds(50)); + + _ = await Assert.ThrowsAsync(async () => + await mediator.SendAsync(command).ConfigureAwait(false) + ); + } + + [Test] + public async Task SendAsync_WithTimeoutRequest_WhenOriginalTokenCancelled_ThrowsOperationCanceledException() + { + var services = CreateServiceCollection(); + _ = services + .AddPulse(config => config.AddRequestTimeout()) + .AddScoped, SlowTimeoutCommandHandler>(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + + var command = new SlowTimeoutCommand(TimeSpan.FromSeconds(5)); + + var exception = await Assert.ThrowsAsync(async () => + await mediator.SendAsync(command, cts.Token).ConfigureAwait(false) + ); + + _ = await Assert.That(exception).IsNotTypeOf(); + } + + [Test] + public async Task SendAsync_WithGlobalTimeout_WhenNonTimeoutRequestCompletesWithinDeadline_ReturnsResult() + { + var services = CreateServiceCollection(); + _ = services + .AddPulse(config => config.AddRequestTimeout(TimeSpan.FromSeconds(5))) + .AddScoped, PlainCommandHandler>(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var command = new PlainCommand(); + var result = await mediator.SendAsync(command).ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("plain-result"); + } + + [Test] + public async Task SendAsync_WithGlobalTimeout_WhenNonTimeoutRequestExceedsDeadline_ThrowsTimeoutException() + { + var services = CreateServiceCollection(); + _ = services + .AddPulse(config => config.AddRequestTimeout(TimeSpan.FromMilliseconds(50))) + .AddScoped, SlowPlainCommandHandler>(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var command = new SlowPlainCommand(); + + _ = await Assert.ThrowsAsync(async () => + await mediator.SendAsync(command).ConfigureAwait(false) + ); + } + + [Test] + public async Task SendAsync_WithNoTimeoutConfigured_ForNonTimeoutRequest_PassesThrough() + { + var services = CreateServiceCollection(); + _ = services + .AddPulse(config => config.AddRequestTimeout()) + .AddScoped, PlainCommandHandler>(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var command = new PlainCommand(); + var result = await mediator.SendAsync(command).ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("plain-result"); + } + + private sealed record FastTimeoutCommand(TimeSpan Timeout) : ICommand, ITimeoutRequest + { + public string? CorrelationId { get; set; } + } + + private sealed record SlowTimeoutCommand(TimeSpan Timeout) : ICommand, ITimeoutRequest + { + public string? CorrelationId { get; set; } + } + + private sealed record PlainCommand : ICommand + { + public string? CorrelationId { get; set; } + } + + private sealed record SlowPlainCommand : ICommand + { + public string? CorrelationId { get; set; } + } + + private sealed class FastTimeoutCommandHandler : ICommandHandler + { + public Task HandleAsync(FastTimeoutCommand command, CancellationToken cancellationToken = default) => + Task.FromResult("fast-result"); + } + + private sealed class SlowTimeoutCommandHandler : ICommandHandler + { + public async Task HandleAsync(SlowTimeoutCommand command, CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); + return "slow-result"; + } + } + + private sealed class PlainCommandHandler : ICommandHandler + { + public Task HandleAsync(PlainCommand command, CancellationToken cancellationToken = default) => + Task.FromResult("plain-result"); + } + + private sealed class SlowPlainCommandHandler : ICommandHandler + { + public async Task HandleAsync(SlowPlainCommand command, CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); + return "slow-plain-result"; + } + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs new file mode 100644 index 00000000..3f002ab8 --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs @@ -0,0 +1,194 @@ +namespace NetEvolve.Pulse.Tests.Unit.Interceptors; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Interceptors; +using TUnit.Core; + +public sealed class TimeoutRequestInterceptorTests +{ + [Test] + public async Task HandleAsync_WithNullHandler_ThrowsArgumentNullException() + { + var options = Options.Create(new TimeoutRequestInterceptorOptions()); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(TimeSpan.FromSeconds(5)); + + _ = await Assert.ThrowsAsync(async () => + await interceptor.HandleAsync(command, null!).ConfigureAwait(false) + ); + } + + [Test] + public async Task HandleAsync_WithTimeoutRequest_WhenCompletesWithinDeadline_ReturnsResult() + { + var options = Options.Create(new TimeoutRequestInterceptorOptions()); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(TimeSpan.FromSeconds(5)); + + var result = await interceptor.HandleAsync(command, (_, _) => Task.FromResult("success")).ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("success"); + } + + [Test] + public async Task HandleAsync_WithTimeoutRequest_WhenExceedsDeadline_ThrowsTimeoutException() + { + var options = Options.Create(new TimeoutRequestInterceptorOptions()); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(TimeSpan.FromMilliseconds(50)); + + var exception = await Assert.ThrowsAsync(async () => + await interceptor + .HandleAsync( + command, + async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); + return "never"; + } + ) + .ConfigureAwait(false) + ); + + _ = await Assert.That(exception).IsNotNull(); + _ = await Assert.That(exception!.Message).Contains("TestTimeoutCommand"); + _ = await Assert.That(exception.Message).Contains("50"); + } + + [Test] + public async Task HandleAsync_WithOriginalTokenCancelled_ThrowsOperationCanceledException_NotTimeoutException() + { + var options = Options.Create(new TimeoutRequestInterceptorOptions()); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(TimeSpan.FromSeconds(5)); + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + + var exception = await Assert.ThrowsAsync(async () => + await interceptor + .HandleAsync( + command, + async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); + return "never"; + }, + cts.Token + ) + .ConfigureAwait(false) + ); + + _ = await Assert.That(exception).IsNotNull(); + _ = await Assert.That(exception).IsNotTypeOf(); + } + + [Test] + public async Task HandleAsync_WithNonTimeoutRequest_AndNoGlobalTimeout_PassesThrough() + { + var options = Options.Create(new TimeoutRequestInterceptorOptions()); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestCommand(); + + var result = await interceptor + .HandleAsync(command, (_, _) => Task.FromResult("passed-through")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("passed-through"); + } + + [Test] + public async Task HandleAsync_WithNonTimeoutRequest_AndGlobalTimeout_WhenCompletesWithinDeadline_ReturnsResult() + { + var options = Options.Create(new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromSeconds(5) }); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestCommand(); + + var result = await interceptor + .HandleAsync(command, (_, _) => Task.FromResult("global-success")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("global-success"); + } + + [Test] + public async Task HandleAsync_WithNonTimeoutRequest_AndGlobalTimeout_WhenExceedsDeadline_ThrowsTimeoutException() + { + var options = Options.Create( + new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromMilliseconds(50) } + ); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestCommand(); + + var exception = await Assert.ThrowsAsync(async () => + await interceptor + .HandleAsync( + command, + async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); + return "never"; + } + ) + .ConfigureAwait(false) + ); + + _ = await Assert.That(exception).IsNotNull(); + _ = await Assert.That(exception!.Message).Contains("TestCommand"); + } + + [Test] + public async Task HandleAsync_WithTimeoutRequest_TimeoutOverridesGlobalTimeout() + { + // Per-request timeout (50ms) should take precedence over global (5s), + // so the request should time out. + var options = Options.Create(new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromSeconds(5) }); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(TimeSpan.FromMilliseconds(50)); + + var exception = await Assert.ThrowsAsync(async () => + await interceptor + .HandleAsync( + command, + async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); + return "never"; + } + ) + .ConfigureAwait(false) + ); + + _ = await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task HandleAsync_DisposesLinkedCts_EvenWhenHandlerThrows() + { + var options = Options.Create(new TimeoutRequestInterceptorOptions()); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(TimeSpan.FromSeconds(5)); + + _ = await Assert.ThrowsAsync(async () => + await interceptor + .HandleAsync(command, (_, _) => throw new InvalidOperationException("handler error")) + .ConfigureAwait(false) + ); + + // If CancellationTokenSource was not disposed, a subsequent test run might detect undisposed resources. + // This test simply verifies the interceptor completes without resource-leak exceptions. + } + + private sealed record TestTimeoutCommand(TimeSpan Timeout) : ICommand, ITimeoutRequest + { + public string? CorrelationId { get; set; } + } + + private sealed record TestCommand : ICommand + { + public string? CorrelationId { get; set; } + } +} From cae0f7cede18040a89d9836929c9c2b3591acc09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 07:21:27 +0000 Subject: [PATCH 2/2] refactor: make ITimeoutRequest.Timeout nullable; non-ITimeoutRequest always passes through Agent-Logs-Url: https://github.com/dailydevops/pulse/sessions/c2973fdb-e879-427e-ab42-c3e898b2ab33 Co-authored-by: samtrion <3283596+samtrion@users.noreply.github.com> --- .../ITimeoutRequest.cs | 25 ++++-- .../Interceptors/TimeoutRequestInterceptor.cs | 23 ++++-- .../TimeoutMediatorConfiguratorExtensions.cs | 21 ++--- .../TimeoutRequestInterceptorOptions.cs | 9 ++- .../RequestTimeoutTests.cs | 78 ++++++++++++++----- .../TimeoutRequestInterceptorTests.cs | 43 ++++++---- 6 files changed, 140 insertions(+), 59 deletions(-) diff --git a/src/NetEvolve.Pulse.Extensibility/ITimeoutRequest.cs b/src/NetEvolve.Pulse.Extensibility/ITimeoutRequest.cs index 01727332..ca7661ac 100644 --- a/src/NetEvolve.Pulse.Extensibility/ITimeoutRequest.cs +++ b/src/NetEvolve.Pulse.Extensibility/ITimeoutRequest.cs @@ -8,20 +8,33 @@ namespace NetEvolve.Pulse.Extensibility; /// /// Usage: /// When a request implements , the TimeoutRequestInterceptor -/// will create a linked using the value returned by . +/// will create a linked using the effective timeout. /// If the handler does not complete within that deadline, a is thrown. -/// Precedence: -/// The per-request value takes precedence over any globally configured fallback timeout. +/// Timeout Resolution: +/// +/// If is non-, that value is used as the deadline. +/// If is , the globally configured fallback timeout is used (if set). +/// If neither is set, the interceptor is a transparent pass-through. +/// +/// Requests that do not implement are always passed through without any timeout. /// Distinguishing Timeout from User Cancellation: /// The interceptor correctly distinguishes between a timeout-triggered cancellation and a caller-initiated /// cancellation, re-throwing a only in the former case. /// /// /// +/// // Explicit per-request timeout /// public record ProcessOrderCommand(string OrderId) : ICommand<OrderResult>, ITimeoutRequest /// { /// public string? CorrelationId { get; set; } -/// public TimeSpan Timeout => TimeSpan.FromSeconds(10); +/// public TimeSpan? Timeout => TimeSpan.FromSeconds(10); +/// } +/// +/// // Defer to the global fallback configured via AddRequestTimeout(globalTimeout: ...) +/// public record GetStatusQuery(string Id) : IQuery<Status>, ITimeoutRequest +/// { +/// public string? CorrelationId { get; set; } +/// public TimeSpan? Timeout => null; /// } /// /// @@ -33,6 +46,8 @@ public interface ITimeoutRequest /// /// Gets the maximum allowed duration for the handler to complete before a /// is raised. + /// When , the globally configured fallback timeout is applied if set; + /// otherwise the interceptor is a transparent pass-through for this request. /// - TimeSpan Timeout { get; } + TimeSpan? Timeout { get; } } diff --git a/src/NetEvolve.Pulse/Interceptors/TimeoutRequestInterceptor.cs b/src/NetEvolve.Pulse/Interceptors/TimeoutRequestInterceptor.cs index abf63f75..c1e82899 100644 --- a/src/NetEvolve.Pulse/Interceptors/TimeoutRequestInterceptor.cs +++ b/src/NetEvolve.Pulse/Interceptors/TimeoutRequestInterceptor.cs @@ -14,12 +14,14 @@ namespace NetEvolve.Pulse.Interceptors; /// The type of response produced by the request. /// /// Activation: -/// The interceptor enforces a timeout when either: -/// -/// The request implements — its value is used as the deadline. -/// A global fallback timeout is configured via — applied to all requests that do not implement . +/// The interceptor only activates when the request implements . +/// Requests that do not implement are always passed through without any timeout. +/// For implementations the effective deadline is resolved as follows: +/// +/// — used when non-. +/// — used as fallback when is . +/// If neither is set, the interceptor is a transparent pass-through for that request. /// -/// When neither condition is met the interceptor is a transparent pass-through. /// Cancellation Semantics: /// The interceptor correctly distinguishes between a timeout-triggered cancellation and a /// caller-initiated cancellation: only when the deadline is exceeded is a @@ -58,9 +60,14 @@ public async Task HandleAsync( { ArgumentNullException.ThrowIfNull(handler); - // Determine the effective timeout for this request. - // ITimeoutRequest.Timeout takes precedence over the global fallback. - var timeout = request is ITimeoutRequest timeoutRequest ? timeoutRequest.Timeout : _options.Value.GlobalTimeout; + // Requests not implementing ITimeoutRequest are always passed through. + if (request is not ITimeoutRequest timeoutRequest) + { + return await handler(request, cancellationToken).ConfigureAwait(false); + } + + // Resolve effective timeout: per-request value first, global fallback second. + var timeout = timeoutRequest.Timeout ?? _options.Value.GlobalTimeout; // No timeout configured — transparent pass-through. if (timeout is null) diff --git a/src/NetEvolve.Pulse/TimeoutMediatorConfiguratorExtensions.cs b/src/NetEvolve.Pulse/TimeoutMediatorConfiguratorExtensions.cs index 012e1a48..6873c0a6 100644 --- a/src/NetEvolve.Pulse/TimeoutMediatorConfiguratorExtensions.cs +++ b/src/NetEvolve.Pulse/TimeoutMediatorConfiguratorExtensions.cs @@ -20,30 +20,33 @@ public static class TimeoutMediatorConfiguratorExtensions /// /// The mediator configurator. /// - /// An optional global fallback timeout applied to all requests that do not implement - /// . + /// An optional global fallback timeout applied to implementations + /// that return from . + /// Requests that do not implement are always passed through + /// regardless of this value. /// When (default), only requests implementing - /// are subject to a deadline. + /// with a non- are subject to a deadline. /// /// The configurator for method chaining. /// Thrown when is . /// - /// Timeout Resolution: + /// Timeout Resolution (for requests only): /// - /// If the request implements , its is used. - /// Otherwise, is applied (when provided). - /// If neither is set, the interceptor is a transparent pass-through for that request. + /// — used when non-. + /// — used as fallback when is . + /// If neither is set, the interceptor is a transparent pass-through. /// + /// Requests that do not implement are always passed through. /// Cancellation Semantics: /// A is thrown only when the deadline is exceeded. /// Caller-initiated cancellations propagate as as usual. /// /// - /// Without global timeout (only ITimeoutRequest requests are affected): + /// Without global timeout (only ITimeoutRequest requests with a non-null Timeout are affected): /// /// services.AddPulse(c => c.AddRequestTimeout()); /// - /// With global fallback timeout (all requests are affected): + /// With global fallback timeout (ITimeoutRequest requests with a null Timeout use this as deadline): /// /// services.AddPulse(c => c.AddRequestTimeout(TimeSpan.FromSeconds(30))); /// diff --git a/src/NetEvolve.Pulse/TimeoutRequestInterceptorOptions.cs b/src/NetEvolve.Pulse/TimeoutRequestInterceptorOptions.cs index 46cb6c11..9785ec37 100644 --- a/src/NetEvolve.Pulse/TimeoutRequestInterceptorOptions.cs +++ b/src/NetEvolve.Pulse/TimeoutRequestInterceptorOptions.cs @@ -20,9 +20,12 @@ namespace NetEvolve.Pulse; public sealed class TimeoutRequestInterceptorOptions { /// - /// Gets or sets the global fallback timeout applied to all requests that do not implement - /// . - /// When (default), requests without an explicit timeout are not affected. + /// Gets or sets the global fallback timeout applied to + /// implementations that return from . + /// Requests that do not implement are always passed through + /// regardless of this value. + /// When (default), requests with a + /// are not subject to any deadline. /// public TimeSpan? GlobalTimeout { get; set; } } diff --git a/tests/NetEvolve.Pulse.Tests.Integration/RequestTimeoutTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/RequestTimeoutTests.cs index 9a70d15b..084a68ac 100644 --- a/tests/NetEvolve.Pulse.Tests.Integration/RequestTimeoutTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Integration/RequestTimeoutTests.cs @@ -75,11 +75,12 @@ await mediator.SendAsync(command, cts.Token).Configu } [Test] - public async Task SendAsync_WithGlobalTimeout_WhenNonTimeoutRequestCompletesWithinDeadline_ReturnsResult() + public async Task SendAsync_WithNonTimeoutRequest_AlwaysPassesThrough_EvenWithGlobalTimeout() { var services = CreateServiceCollection(); + // GlobalTimeout of 1ms — but PlainCommand doesn't implement ITimeoutRequest, so it should pass through. _ = services - .AddPulse(config => config.AddRequestTimeout(TimeSpan.FromSeconds(5))) + .AddPulse(config => config.AddRequestTimeout(TimeSpan.FromMilliseconds(1))) .AddScoped, PlainCommandHandler>(); await using var provider = services.BuildServiceProvider(); @@ -92,56 +93,82 @@ public async Task SendAsync_WithGlobalTimeout_WhenNonTimeoutRequestCompletesWith } [Test] - public async Task SendAsync_WithGlobalTimeout_WhenNonTimeoutRequestExceedsDeadline_ThrowsTimeoutException() + public async Task SendAsync_WithTimeoutRequest_NullTimeout_AndGlobalTimeout_WhenCompletesWithinDeadline_ReturnsResult() + { + var services = CreateServiceCollection(); + _ = services + .AddPulse(config => config.AddRequestTimeout(TimeSpan.FromSeconds(5))) + .AddScoped, NullTimeoutCommandHandler>(); + + await using var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var command = new NullTimeoutCommand(); + var result = await mediator.SendAsync(command).ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("null-timeout-result"); + } + + [Test] + public async Task SendAsync_WithTimeoutRequest_NullTimeout_AndGlobalTimeout_WhenExceedsDeadline_ThrowsTimeoutException() { var services = CreateServiceCollection(); _ = services .AddPulse(config => config.AddRequestTimeout(TimeSpan.FromMilliseconds(50))) - .AddScoped, SlowPlainCommandHandler>(); + .AddScoped, SlowNullTimeoutCommandHandler>(); await using var provider = services.BuildServiceProvider(); var mediator = provider.GetRequiredService(); - var command = new SlowPlainCommand(); + var command = new SlowNullTimeoutCommand(); _ = await Assert.ThrowsAsync(async () => - await mediator.SendAsync(command).ConfigureAwait(false) + await mediator.SendAsync(command).ConfigureAwait(false) ); } [Test] - public async Task SendAsync_WithNoTimeoutConfigured_ForNonTimeoutRequest_PassesThrough() + public async Task SendAsync_WithTimeoutRequest_NullTimeout_AndNoGlobalTimeout_PassesThrough() { var services = CreateServiceCollection(); _ = services .AddPulse(config => config.AddRequestTimeout()) - .AddScoped, PlainCommandHandler>(); + .AddScoped, NullTimeoutCommandHandler>(); await using var provider = services.BuildServiceProvider(); var mediator = provider.GetRequiredService(); - var command = new PlainCommand(); - var result = await mediator.SendAsync(command).ConfigureAwait(false); + var command = new NullTimeoutCommand(); + var result = await mediator.SendAsync(command).ConfigureAwait(false); - _ = await Assert.That(result).IsEqualTo("plain-result"); + _ = await Assert.That(result).IsEqualTo("null-timeout-result"); } - private sealed record FastTimeoutCommand(TimeSpan Timeout) : ICommand, ITimeoutRequest + private sealed record FastTimeoutCommand(TimeSpan? Timeout) : ICommand, ITimeoutRequest { public string? CorrelationId { get; set; } } - private sealed record SlowTimeoutCommand(TimeSpan Timeout) : ICommand, ITimeoutRequest + private sealed record SlowTimeoutCommand(TimeSpan? Timeout) : ICommand, ITimeoutRequest { public string? CorrelationId { get; set; } } - private sealed record PlainCommand : ICommand + private sealed record NullTimeoutCommand : ICommand, ITimeoutRequest { public string? CorrelationId { get; set; } + + public TimeSpan? Timeout => null; } - private sealed record SlowPlainCommand : ICommand + private sealed record SlowNullTimeoutCommand : ICommand, ITimeoutRequest + { + public string? CorrelationId { get; set; } + + public TimeSpan? Timeout => null; + } + + private sealed record PlainCommand : ICommand { public string? CorrelationId { get; set; } } @@ -161,18 +188,27 @@ public async Task HandleAsync(SlowTimeoutCommand command, CancellationTo } } - private sealed class PlainCommandHandler : ICommandHandler + private sealed class NullTimeoutCommandHandler : ICommandHandler { - public Task HandleAsync(PlainCommand command, CancellationToken cancellationToken = default) => - Task.FromResult("plain-result"); + public Task HandleAsync(NullTimeoutCommand command, CancellationToken cancellationToken = default) => + Task.FromResult("null-timeout-result"); } - private sealed class SlowPlainCommandHandler : ICommandHandler + private sealed class SlowNullTimeoutCommandHandler : ICommandHandler { - public async Task HandleAsync(SlowPlainCommand command, CancellationToken cancellationToken = default) + public async Task HandleAsync( + SlowNullTimeoutCommand command, + CancellationToken cancellationToken = default + ) { await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); - return "slow-plain-result"; + return "slow-null-timeout-result"; } } + + private sealed class PlainCommandHandler : ICommandHandler + { + public Task HandleAsync(PlainCommand command, CancellationToken cancellationToken = default) => + Task.FromResult("plain-result"); + } } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs index 3f002ab8..04b21dad 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs @@ -87,12 +87,29 @@ await interceptor } [Test] - public async Task HandleAsync_WithNonTimeoutRequest_AndNoGlobalTimeout_PassesThrough() + public async Task HandleAsync_WithNonTimeoutRequest_AlwaysPassesThrough_RegardlessOfGlobalTimeout() { - var options = Options.Create(new TimeoutRequestInterceptorOptions()); + var options = Options.Create( + new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromMilliseconds(1) } + ); var interceptor = new TimeoutRequestInterceptor(options); var command = new TestCommand(); + // Even though GlobalTimeout is 1ms, the non-ITimeoutRequest should pass through immediately. + var result = await interceptor + .HandleAsync(command, (_, _) => Task.FromResult("passed-through")) + .ConfigureAwait(false); + + _ = await Assert.That(result).IsEqualTo("passed-through"); + } + + [Test] + public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndNoGlobalTimeout_PassesThrough() + { + var options = Options.Create(new TimeoutRequestInterceptorOptions()); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(null); + var result = await interceptor .HandleAsync(command, (_, _) => Task.FromResult("passed-through")) .ConfigureAwait(false); @@ -101,27 +118,27 @@ public async Task HandleAsync_WithNonTimeoutRequest_AndNoGlobalTimeout_PassesThr } [Test] - public async Task HandleAsync_WithNonTimeoutRequest_AndGlobalTimeout_WhenCompletesWithinDeadline_ReturnsResult() + public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndGlobalTimeout_WhenCompletesWithinDeadline_ReturnsResult() { var options = Options.Create(new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromSeconds(5) }); - var interceptor = new TimeoutRequestInterceptor(options); - var command = new TestCommand(); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(null); var result = await interceptor - .HandleAsync(command, (_, _) => Task.FromResult("global-success")) + .HandleAsync(command, (_, _) => Task.FromResult("global-fallback-success")) .ConfigureAwait(false); - _ = await Assert.That(result).IsEqualTo("global-success"); + _ = await Assert.That(result).IsEqualTo("global-fallback-success"); } [Test] - public async Task HandleAsync_WithNonTimeoutRequest_AndGlobalTimeout_WhenExceedsDeadline_ThrowsTimeoutException() + public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndGlobalTimeout_WhenExceedsDeadline_ThrowsTimeoutException() { var options = Options.Create( new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromMilliseconds(50) } ); - var interceptor = new TimeoutRequestInterceptor(options); - var command = new TestCommand(); + var interceptor = new TimeoutRequestInterceptor(options); + var command = new TestTimeoutCommand(null); var exception = await Assert.ThrowsAsync(async () => await interceptor @@ -137,11 +154,11 @@ await interceptor ); _ = await Assert.That(exception).IsNotNull(); - _ = await Assert.That(exception!.Message).Contains("TestCommand"); + _ = await Assert.That(exception!.Message).Contains("TestTimeoutCommand"); } [Test] - public async Task HandleAsync_WithTimeoutRequest_TimeoutOverridesGlobalTimeout() + public async Task HandleAsync_WithTimeoutRequest_ExplicitTimeoutOverridesGlobalTimeout() { // Per-request timeout (50ms) should take precedence over global (5s), // so the request should time out. @@ -182,7 +199,7 @@ await interceptor // This test simply verifies the interceptor completes without resource-leak exceptions. } - private sealed record TestTimeoutCommand(TimeSpan Timeout) : ICommand, ITimeoutRequest + private sealed record TestTimeoutCommand(TimeSpan? Timeout) : ICommand, ITimeoutRequest { public string? CorrelationId { get; set; } }