Files
dotnet__skills/eng/skill-validator/tests/RetryHelperTests.cs
Jan Krivanek e392d3cfde Fix flaky test (#258)
* Fix flaky test

* Update eng/skill-validator/tests/RetryHelperTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-06 12:32:53 +01:00

192 lines
6.6 KiB
C#

using SkillValidator.Utilities;
namespace SkillValidator.Tests;
public class RetryHelperTests
{
[Fact]
public async Task SucceedsOnFirstAttempt_NoRetries()
{
var callCount = 0;
var result = await RetryHelper.ExecuteWithRetry(
(_) => { callCount++; return Task.FromResult(42); },
"test",
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(42, result);
Assert.Equal(1, callCount);
}
[Fact]
public async Task RetriesOnTransientFailure_ThenSucceeds()
{
var callCount = 0;
var result = await RetryHelper.ExecuteWithRetry(
(_) =>
{
callCount++;
if (callCount < 2)
throw new InvalidOperationException("transient");
return Task.FromResult("ok");
},
"test",
maxRetries: 2,
baseDelayMs: 1,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("ok", result);
Assert.Equal(2, callCount);
}
[Fact]
public async Task ThrowsAfterAllRetriesExhausted()
{
var callCount = 0;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetry<int>(
(_) =>
{
callCount++;
throw new InvalidOperationException("always fails");
},
"test",
maxRetries: 2,
baseDelayMs: 1,
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal(3, callCount); // 1 initial + 2 retries
Assert.Contains("all attempts failed", ex.Message);
Assert.IsType<InvalidOperationException>(ex.InnerException);
}
[Fact]
public async Task RespectsTotal_TimeoutBudget()
{
var callCount = 0;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetry<int>(
async (_) =>
{
callCount++;
// Simulate a slow operation that eats the budget
await Task.Delay(50);
throw new InvalidOperationException("timeout");
},
"test",
maxRetries: 10, // many retries but budget is tiny
baseDelayMs: 1,
totalTimeoutMs: 100,
cancellationToken: TestContext.Current.CancellationToken));
// Should have stopped before exhausting all 10 retries
Assert.True(callCount < 10, $"Expected fewer than 10 attempts but got {callCount}");
}
[Fact]
public async Task CancellationToken_StopsRetryLoop()
{
using var cts = new CancellationTokenSource();
var callCount = 0;
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
RetryHelper.ExecuteWithRetry<int>(
(_) =>
{
callCount++;
if (callCount == 1)
cts.Cancel(); // cancel after first failure
throw new InvalidOperationException("fail");
},
"test",
maxRetries: 5,
baseDelayMs: 1,
cancellationToken: cts.Token));
Assert.Equal(1, callCount);
}
[Fact]
public async Task ExponentialBackoff_DelaysIncrease()
{
var timestamps = new List<long>();
var callCount = 0;
await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetry<int>(
(_) =>
{
timestamps.Add(Environment.TickCount64);
callCount++;
throw new InvalidOperationException("fail");
},
"test",
maxRetries: 2,
baseDelayMs: 200,
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal(3, callCount);
// Second gap should be roughly double the first (with some tolerance).
// Using 200ms base so that OS scheduling jitter doesn't dominate.
if (timestamps.Count == 3)
{
var gap1 = timestamps[1] - timestamps[0];
var gap2 = timestamps[2] - timestamps[1];
// gap2 should be larger than gap1 (exponential)
Assert.True(gap2 > gap1, $"Expected exponential increase: gap1={gap1}ms, gap2={gap2}ms");
}
}
[Fact]
public async Task DelayClampedToRemainingBudget()
{
// With a very large base delay but small total budget,
// the delay should be clamped to the remaining budget.
// Uses injected clock/delay to avoid real-time sensitivity (see dotnet/skills#168).
var callCount = 0;
var fakeTimeMs = 0L;
var recordedDelays = new List<int>();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetryCore<int>(
(_) =>
{
callCount++;
fakeTimeMs += 10; // simulate 10ms per attempt
throw new InvalidOperationException("fail");
},
"test",
maxRetries: 1,
baseDelayMs: 60_000, // 60s base delay - would be huge without clamping
totalTimeoutMs: 2000,
cancellationToken: TestContext.Current.CancellationToken,
clock: () => fakeTimeMs,
delayFunc: (ms, _) => { recordedDelays.Add(ms); return Task.CompletedTask; }));
Assert.Equal(2, callCount);
// The retry delay should be clamped to remaining budget, not the raw 60s.
Assert.Single(recordedDelays);
Assert.True(recordedDelays[0] <= 2000,
$"Delay should be clamped to remaining budget, got {recordedDelays[0]}ms");
Assert.True(recordedDelays[0] >= 0,
$"Delay should be non-negative, got {recordedDelays[0]}ms");
}
[Fact]
public async Task BudgetToken_FlowsToAction()
{
// Verify that the CancellationToken passed to the action is functional.
CancellationToken capturedToken = default;
await RetryHelper.ExecuteWithRetry(
(ct) =>
{
capturedToken = ct;
return Task.FromResult(true);
},
"test",
cancellationToken: TestContext.Current.CancellationToken);
// The token should be a real linked token, not CancellationToken.None.
Assert.True(capturedToken.CanBeCanceled);
}
}