diff --git a/TUnit.Core/Attributes/TestMetadata/RetryAttribute.cs b/TUnit.Core/Attributes/TestMetadata/RetryAttribute.cs index 921e421550..e02f3f90d2 100644 --- a/TUnit.Core/Attributes/TestMetadata/RetryAttribute.cs +++ b/TUnit.Core/Attributes/TestMetadata/RetryAttribute.cs @@ -13,6 +13,11 @@ namespace TUnit.Core; /// in the class will be retried on failure. When applied at the assembly level, it affects all tests in the assembly. /// /// Method-level attributes take precedence over class-level attributes, which take precedence over assembly-level attributes. +/// +/// Optional retry policy properties: +/// - : Initial delay in milliseconds between retries (default 0 = no delay). +/// - : Multiplier for exponential backoff (default 2.0). +/// - : Only retry when the exception matches one of the specified types. /// /// /// @@ -23,6 +28,22 @@ namespace TUnit.Core; /// // This test will be retried up to 3 times if it fails /// } /// +/// // Retry with exponential backoff: waits 500ms, 1000ms, 2000ms between retries +/// [Test] +/// [Retry(3, BackoffMs = 500, BackoffMultiplier = 2.0)] +/// public void TestWithBackoff() +/// { +/// // This test will be retried with increasing delays +/// } +/// +/// // Retry only on specific exception types +/// [Test] +/// [Retry(3, RetryOnExceptionTypes = new[] { typeof(HttpRequestException), typeof(TimeoutException) })] +/// public void TestWithExceptionFilter() +/// { +/// // This test will only be retried if the exception is HttpRequestException or TimeoutException +/// } +/// /// // Example of a custom retry attribute with conditional logic /// public class RetryOnNetworkErrorAttribute : RetryAttribute /// { @@ -53,6 +74,38 @@ public class RetryAttribute : TUnitAttribute, ITestDiscoveryEventReceiver, IScop /// public int Times { get; } + /// + /// Gets or sets the initial delay in milliseconds before the first retry. + /// Subsequent retries will be delayed by BackoffMs * BackoffMultiplier^(attempt-1). + /// + /// + /// Default is 0 (no delay). When set to a positive value, exponential backoff is enabled. + /// For example, with BackoffMs = 100 and BackoffMultiplier = 2.0: + /// - 1st retry: 100ms delay + /// - 2nd retry: 200ms delay + /// - 3rd retry: 400ms delay + /// + public int BackoffMs { get; set; } + + /// + /// Gets or sets the multiplier applied to for each subsequent retry attempt. + /// + /// + /// Default is 2.0 (doubling the delay each time). Only used when is greater than 0. + /// Set to 1.0 for a constant delay between retries. + /// + public double BackoffMultiplier { get; set; } = 2.0; + + /// + /// Gets or sets the exception types that should trigger a retry. + /// When set, only exceptions that are assignable to one of these types will cause a retry. + /// + /// + /// Default is null (retry on any exception). The check uses + /// so derived exception types are also matched. + /// + public Type[]? RetryOnExceptionTypes { get; set; } + /// /// Initializes a new instance of the class with the specified number of retry attempts. /// @@ -82,11 +135,24 @@ public RetryAttribute(int times) /// Can be overridden in derived classes to implement conditional retry logic /// based on the specific exception type or other criteria. /// - /// The default implementation always returns true, meaning the test will always be retried - /// up to the maximum number of attempts regardless of the exception type. + /// The default implementation checks if set. + /// If no exception type filter is configured, it returns true for any exception. /// public virtual Task ShouldRetry(TestContext context, Exception exception, int currentRetryCount) { + if (RetryOnExceptionTypes is { Length: > 0 }) + { + foreach (var type in RetryOnExceptionTypes) + { + if (type.IsInstanceOfType(exception)) + { + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + return Task.FromResult(true); } @@ -95,6 +161,7 @@ public virtual Task ShouldRetry(TestContext context, Exception exception, public ValueTask OnTestDiscovered(DiscoveredTestContext context) { context.SetRetryLimit(Times, ShouldRetry); + context.SetRetryBackoff(BackoffMs, BackoffMultiplier); return default(ValueTask); } diff --git a/TUnit.Core/Contexts/DiscoveredTestContext.cs b/TUnit.Core/Contexts/DiscoveredTestContext.cs index 7e929e2c77..eeb5a6d802 100644 --- a/TUnit.Core/Contexts/DiscoveredTestContext.cs +++ b/TUnit.Core/Contexts/DiscoveredTestContext.cs @@ -60,6 +60,17 @@ public void SetRetryLimit(int retryCount, Func + /// Sets the backoff configuration for retry attempts. + /// + /// Initial delay in milliseconds before the first retry. 0 means no delay. + /// Multiplier for exponential backoff (e.g. 2.0 doubles the delay each retry). + public void SetRetryBackoff(int backoffMs, double backoffMultiplier) + { + TestContext.Metadata.TestDetails.RetryBackoffMs = backoffMs; + TestContext.Metadata.TestDetails.RetryBackoffMultiplier = backoffMultiplier; + } + /// /// Adds a parallel constraint to the test context. /// Multiple constraints can be combined (e.g., ParallelGroup + NotInParallel). diff --git a/TUnit.Core/Interfaces/ITestConfiguration.cs b/TUnit.Core/Interfaces/ITestConfiguration.cs index 78c5d85780..2446931a6c 100644 --- a/TUnit.Core/Interfaces/ITestConfiguration.cs +++ b/TUnit.Core/Interfaces/ITestConfiguration.cs @@ -15,4 +15,14 @@ public interface ITestConfiguration /// Gets the maximum number of retry attempts for this test. /// int RetryLimit { get; } + + /// + /// Gets the initial delay in milliseconds before the first retry. + /// + int RetryBackoffMs { get; } + + /// + /// Gets the multiplier for exponential backoff between retries. + /// + double RetryBackoffMultiplier { get; } } diff --git a/TUnit.Core/TestDetails.Configuration.cs b/TUnit.Core/TestDetails.Configuration.cs index 2281bd8102..c47aac8e4b 100644 --- a/TUnit.Core/TestDetails.Configuration.cs +++ b/TUnit.Core/TestDetails.Configuration.cs @@ -10,4 +10,6 @@ public partial class TestDetails // Explicit interface implementation for ITestConfiguration TimeSpan? ITestConfiguration.Timeout => Timeout; int ITestConfiguration.RetryLimit => RetryLimit; + int ITestConfiguration.RetryBackoffMs => RetryBackoffMs; + double ITestConfiguration.RetryBackoffMultiplier => RetryBackoffMultiplier; } diff --git a/TUnit.Core/TestDetails.cs b/TUnit.Core/TestDetails.cs index e8869869e3..a6ff6c2a7c 100644 --- a/TUnit.Core/TestDetails.cs +++ b/TUnit.Core/TestDetails.cs @@ -33,6 +33,18 @@ public partial class TestDetails : ITestIdentity, ITestClass, ITestMethod, ITest public TimeSpan? Timeout { get; set; } public int RetryLimit { get; set; } + /// + /// Gets or sets the initial delay in milliseconds before the first retry. + /// Used with for exponential backoff. + /// + public int RetryBackoffMs { get; set; } + + /// + /// Gets or sets the multiplier for exponential backoff between retries. + /// Default is 2.0. + /// + public double RetryBackoffMultiplier { get; set; } = 2.0; + public required MethodMetadata MethodMetadata { get; set; } public string TestFilePath { get; set; } = ""; public int TestLineNumber { get; set; } diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index fb571167f5..e19d4588d7 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -39,6 +39,9 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func ac } #endif + // Apply backoff delay before retrying + await ApplyBackoffDelay(testContext, attempt).ConfigureAwait(false); + // Clear the previous result before retrying testContext.Execution.Result = null; testContext.TestStart = null; @@ -67,4 +70,22 @@ private static async Task ShouldRetry(TestContext testContext, Exception e return await testContext.RetryFunc(testContext, ex, attempt + 1).ConfigureAwait(false); } + + private static async Task ApplyBackoffDelay(TestContext testContext, int attempt) + { + var backoffMs = testContext.Metadata.TestDetails.RetryBackoffMs; + + if (backoffMs <= 0) + { + return; + } + + var multiplier = testContext.Metadata.TestDetails.RetryBackoffMultiplier; + var delayMs = (int)(backoffMs * Math.Pow(multiplier, attempt)); + + if (delayMs > 0) + { + await Task.Delay(delayMs, testContext.CancellationToken).ConfigureAwait(false); + } + } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 74ecb07bd5..ac9c6c3b39 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -583,6 +583,7 @@ namespace public void SetDisplayName(string displayName) { } public void SetDisplayNameFormatter( formatterType) { } public void SetPriority(. priority) { } + public void SetRetryBackoff(int backoffMs, double backoffMultiplier) { } public void SetRetryLimit(int retryLimit) { } public void SetRetryLimit(int retryCount, <.TestContext, , int, .> shouldRetry) { } } @@ -1179,7 +1180,10 @@ namespace public class RetryAttribute : .TUnitAttribute, .IScopedAttribute, ., . { public RetryAttribute(int times) { } + public int BackoffMs { get; set; } + public double BackoffMultiplier { get; set; } public int Order { get; } + public []? RetryOnExceptionTypes { get; set; } public ScopeType { get; } public int Times { get; } public . OnTestDiscovered(.DiscoveredTestContext context) { } @@ -1454,6 +1458,8 @@ namespace public [] MethodGenericArguments { get; set; } public required .MethodMetadata MethodMetadata { get; set; } public required string MethodName { get; init; } + public int RetryBackoffMs { get; set; } + public double RetryBackoffMultiplier { get; set; } public int RetryLimit { get; set; } public required ReturnType { get; set; } public required object?[] TestClassArguments { get; set; } @@ -2408,6 +2414,8 @@ namespace .Interfaces } public interface ITestConfiguration { + int RetryBackoffMs { get; } + double RetryBackoffMultiplier { get; } int RetryLimit { get; } ? Timeout { get; } }