Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions TUnit.Core/Attributes/TestMetadata/RetryAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
/// - <see cref="BackoffMs"/>: Initial delay in milliseconds between retries (default 0 = no delay).
/// - <see cref="BackoffMultiplier"/>: Multiplier for exponential backoff (default 2.0).
/// - <see cref="RetryOnExceptionTypes"/>: Only retry when the exception matches one of the specified types.
/// </remarks>
/// <example>
/// <code>
Expand All @@ -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
/// {
Expand Down Expand Up @@ -53,6 +74,38 @@ public class RetryAttribute : TUnitAttribute, ITestDiscoveryEventReceiver, IScop
/// </remarks>
public int Times { get; }

/// <summary>
/// Gets or sets the initial delay in milliseconds before the first retry.
/// Subsequent retries will be delayed by <c>BackoffMs * BackoffMultiplier^(attempt-1)</c>.
/// </summary>
/// <remarks>
/// Default is 0 (no delay). When set to a positive value, exponential backoff is enabled.
/// For example, with <c>BackoffMs = 100</c> and <c>BackoffMultiplier = 2.0</c>:
/// - 1st retry: 100ms delay
/// - 2nd retry: 200ms delay
/// - 3rd retry: 400ms delay
/// </remarks>
public int BackoffMs { get; set; }

/// <summary>
/// Gets or sets the multiplier applied to <see cref="BackoffMs"/> for each subsequent retry attempt.
/// </summary>
/// <remarks>
/// Default is 2.0 (doubling the delay each time). Only used when <see cref="BackoffMs"/> is greater than 0.
/// Set to 1.0 for a constant delay between retries.
/// </remarks>
public double BackoffMultiplier { get; set; } = 2.0;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Default is null (retry on any exception). The check uses <see cref="Type.IsInstanceOfType"/>
/// so derived exception types are also matched.
/// </remarks>
public Type[]? RetryOnExceptionTypes { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="RetryAttribute"/> class with the specified number of retry attempts.
/// </summary>
Expand Down Expand Up @@ -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 <see cref="RetryOnExceptionTypes"/> if set.
/// If no exception type filter is configured, it returns true for any exception.
/// </remarks>
public virtual Task<bool> 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);
}

Expand All @@ -95,6 +161,7 @@ public virtual Task<bool> ShouldRetry(TestContext context, Exception exception,
public ValueTask OnTestDiscovered(DiscoveredTestContext context)
{
context.SetRetryLimit(Times, ShouldRetry);
context.SetRetryBackoff(BackoffMs, BackoffMultiplier);
return default(ValueTask);
}

Expand Down
11 changes: 11 additions & 0 deletions TUnit.Core/Contexts/DiscoveredTestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ public void SetRetryLimit(int retryCount, Func<TestContext, Exception, int, Task
TestContext.Metadata.TestDetails.RetryLimit = retryCount;
}

/// <summary>
/// Sets the backoff configuration for retry attempts.
/// </summary>
/// <param name="backoffMs">Initial delay in milliseconds before the first retry. 0 means no delay.</param>
/// <param name="backoffMultiplier">Multiplier for exponential backoff (e.g. 2.0 doubles the delay each retry).</param>
public void SetRetryBackoff(int backoffMs, double backoffMultiplier)
{
TestContext.Metadata.TestDetails.RetryBackoffMs = backoffMs;
TestContext.Metadata.TestDetails.RetryBackoffMultiplier = backoffMultiplier;
}

/// <summary>
/// Adds a parallel constraint to the test context.
/// Multiple constraints can be combined (e.g., ParallelGroup + NotInParallel).
Expand Down
10 changes: 10 additions & 0 deletions TUnit.Core/Interfaces/ITestConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,14 @@ public interface ITestConfiguration
/// Gets the maximum number of retry attempts for this test.
/// </summary>
int RetryLimit { get; }

/// <summary>
/// Gets the initial delay in milliseconds before the first retry.
/// </summary>
int RetryBackoffMs { get; }

/// <summary>
/// Gets the multiplier for exponential backoff between retries.
/// </summary>
double RetryBackoffMultiplier { get; }
}
2 changes: 2 additions & 0 deletions TUnit.Core/TestDetails.Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
12 changes: 12 additions & 0 deletions TUnit.Core/TestDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ public partial class TestDetails : ITestIdentity, ITestClass, ITestMethod, ITest
public TimeSpan? Timeout { get; set; }
public int RetryLimit { get; set; }

/// <summary>
/// Gets or sets the initial delay in milliseconds before the first retry.
/// Used with <see cref="RetryBackoffMultiplier"/> for exponential backoff.
/// </summary>
public int RetryBackoffMs { get; set; }

/// <summary>
/// Gets or sets the multiplier for exponential backoff between retries.
/// Default is 2.0.
/// </summary>
public double RetryBackoffMultiplier { get; set; } = 2.0;

public required MethodMetadata MethodMetadata { get; set; }
public string TestFilePath { get; set; } = "";
public int TestLineNumber { get; set; }
Expand Down
21 changes: 21 additions & 0 deletions TUnit.Engine/Services/TestExecution/RetryHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func<Task> 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;
Expand Down Expand Up @@ -67,4 +70,22 @@ private static async Task<bool> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, .<bool>> shouldRetry) { }
}
Expand Down Expand Up @@ -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) { }
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -2408,6 +2414,8 @@ namespace .Interfaces
}
public interface ITestConfiguration
{
int RetryBackoffMs { get; }
double RetryBackoffMultiplier { get; }
int RetryLimit { get; }
? Timeout { get; }
}
Expand Down
Loading