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; }
}