Skip to content

Commit b748dfc

Browse files
committed
Add: IRetryConfig + DefaultRetryPolicy
1 parent a8563bb commit b748dfc

File tree

4 files changed

+268
-0
lines changed

4 files changed

+268
-0
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.Data;
2+
using System.Data.Common;
3+
4+
namespace Ydb.Sdk.Ado.Retry;
5+
6+
public sealed class DefaultRetryPolicy : IRetryPolicy
7+
{
8+
private readonly RetryConfig _cfg;
9+
10+
public DefaultRetryPolicy(RetryConfig? config = null) => _cfg = config ?? new RetryConfig();
11+
12+
public int MaxAttempts => _cfg.MaxAttempts;
13+
14+
public bool IsStreaming(DbCommand command, CommandBehavior behavior) => _cfg.IsStreaming(command, behavior);
15+
16+
public bool CanRetry(Exception ex, bool isIdempotent)
17+
{
18+
if (TryUnwrapYdbException(ex, out var ydb))
19+
{
20+
return isIdempotent ? ydb.IsTransientWhenIdempotent : ydb.IsTransient;
21+
}
22+
23+
if (ex is TimeoutException) return true;
24+
if (ex is OperationCanceledException oce && !oce.CancellationToken.IsCancellationRequested) return true;
25+
26+
return false;
27+
}
28+
29+
public TimeSpan? GetDelay(Exception ex, int attempt)
30+
{
31+
if (attempt <= 0) attempt = 1;
32+
33+
if (TryUnwrapYdbException(ex, out var ydb))
34+
{
35+
if (_cfg.PerStatusDelay.TryGetValue(ydb.Code, out var calc))
36+
return Cap(calc(attempt));
37+
}
38+
39+
return Cap(_cfg.DefaultDelay(ex, attempt));
40+
}
41+
42+
private static bool TryUnwrapYdbException(Exception ex, out YdbException ydb)
43+
{
44+
for (var e = ex; e is not null; e = e.InnerException!)
45+
{
46+
if (e is YdbException yy)
47+
{
48+
ydb = yy; return true;
49+
}
50+
}
51+
ydb = null!;
52+
return false;
53+
}
54+
55+
private TimeSpan? Cap(TimeSpan? delay)
56+
{
57+
if (delay is null) return null;
58+
if (delay.Value <= TimeSpan.Zero) return TimeSpan.Zero;
59+
return delay.Value <= _cfg.MaxDelay ? delay : _cfg.MaxDelay;
60+
}
61+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System.Data.Common;
2+
3+
namespace Ydb.Sdk.Ado.Retry;
4+
5+
public interface IRetryPolicy
6+
{
7+
int MaxAttempts { get; }
8+
9+
bool CanRetry(Exception ex, bool isIdempotent);
10+
11+
TimeSpan? GetDelay(Exception ex, int attempt);
12+
13+
bool IsStreaming(DbCommand command, System.Data.CommandBehavior behavior);
14+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace Ydb.Sdk.Ado.Retry;
2+
3+
public sealed class RetryConfig
4+
{
5+
public int MaxAttempts { get; set; } = 10;
6+
7+
public TimeSpan BaseDelay { get; set; } = TimeSpan.FromMilliseconds(100);
8+
9+
public double Exponent { get; set; } = 2.0;
10+
11+
public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(10);
12+
13+
public double JitterFraction { get; set; } = 0.5;
14+
15+
public Dictionary<StatusCode, Func<int, TimeSpan?>> PerStatusDelay { get; } = new();
16+
17+
public Func<System.Data.Common.DbCommand, System.Data.CommandBehavior, bool> IsStreaming { get; set; }
18+
= static (_, behavior) => (behavior & System.Data.CommandBehavior.SequentialAccess) != 0;
19+
20+
public Func<Exception, int, TimeSpan?> DefaultDelay { get; set; }
21+
22+
= static (ex, attempt) =>
23+
{
24+
var baseMs = 100.0 * Math.Pow(2.0, Math.Max(0, attempt - 1));
25+
var jitter = 1.0 + (Random.Shared.NextDouble() * 0.5);
26+
var ms = Math.Min(baseMs * jitter, 10_000.0);
27+
return TimeSpan.FromMilliseconds(ms);
28+
};
29+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
using System.Data;
2+
using Xunit;
3+
using Ydb.Sdk.Ado.Retry;
4+
5+
namespace Ydb.Sdk.Ado.Tests;
6+
7+
public class DefaultRetryPolicyTests : TestBase
8+
{
9+
[Fact]
10+
public void GetDelay_WhenAttemptOne_UsesDefaultDelayDelegate()
11+
{
12+
var config = new RetryConfig
13+
{
14+
DefaultDelay = (_, __) => TimeSpan.FromMilliseconds(100),
15+
MaxDelay = TimeSpan.FromMilliseconds(500)
16+
};
17+
18+
var policy = new DefaultRetryPolicy(config);
19+
20+
var delay = policy.GetDelay(new Exception("test"), 1);
21+
Assert.Equal(TimeSpan.FromMilliseconds(100), delay);
22+
}
23+
24+
[Fact]
25+
public void GetDelay_WhenAttemptOne_ReturnsBaseDelay_NoJitter()
26+
{
27+
var config = new RetryConfig
28+
{
29+
BaseDelay = TimeSpan.FromMilliseconds(100),
30+
Exponent = 2.0,
31+
JitterFraction = 0.0,
32+
MaxDelay = TimeSpan.FromMilliseconds(500),
33+
DefaultDelay = (ex, attempt) =>
34+
{
35+
attempt = Math.Max(1, attempt);
36+
var baseMs = 100.0 * Math.Pow(2.0, attempt - 1);
37+
var ms = Math.Min(baseMs, 500.0);
38+
return TimeSpan.FromMilliseconds(ms);
39+
}
40+
};
41+
42+
var policy = new DefaultRetryPolicy(config);
43+
44+
var delay = policy.GetDelay(new Exception("test"), 1);
45+
Assert.Equal(TimeSpan.FromMilliseconds(100), delay);
46+
}
47+
48+
[Fact]
49+
public void GetDelay_WhenStatusHasOverride_ReturnsPerStatusDelay()
50+
{
51+
var config = new RetryConfig();
52+
config.PerStatusDelay[StatusCode.Unavailable] = attempt => TimeSpan.FromMilliseconds(123);
53+
var policy = new DefaultRetryPolicy(config);
54+
55+
var ex = new YdbException(StatusCode.Unavailable, "unavailable");
56+
var delay = policy.GetDelay(ex, 2);
57+
58+
Assert.Equal(TimeSpan.FromMilliseconds(123), delay);
59+
}
60+
61+
[Fact]
62+
public void CanRetry_WhenTransientYdbException_ReturnsTrue()
63+
{
64+
var config = new RetryConfig();
65+
var policy = new DefaultRetryPolicy(config);
66+
var ex = new YdbException(StatusCode.Aborted, "transient");
67+
68+
Assert.True(policy.CanRetry(ex, isIdempotent: false));
69+
}
70+
71+
[Fact]
72+
public void CanRetry_WhenOverloaded_RetriesRegardlessOfIdempotency()
73+
{
74+
var policy = new DefaultRetryPolicy(new RetryConfig());
75+
var ex = new YdbException(StatusCode.Overloaded, "overloaded");
76+
77+
Assert.True(policy.CanRetry(ex, isIdempotent: false));
78+
Assert.True(policy.CanRetry(ex, isIdempotent: true));
79+
}
80+
81+
[Fact]
82+
public void CanRetry_WhenTimeoutException_ReturnsTrue()
83+
{
84+
var config = new RetryConfig();
85+
var policy = new DefaultRetryPolicy(config);
86+
87+
Assert.True(policy.CanRetry(new TimeoutException(), isIdempotent: true));
88+
}
89+
90+
[Fact]
91+
public void CanRetry_WhenUserCancelled_ReturnsFalse()
92+
{
93+
using var cts = new CancellationTokenSource();
94+
cts.Cancel();
95+
var config = new RetryConfig();
96+
var policy = new DefaultRetryPolicy(config);
97+
98+
var ex = new OperationCanceledException(cts.Token);
99+
Assert.False(policy.CanRetry(ex, isIdempotent: true));
100+
}
101+
102+
[Fact]
103+
public void IsStreaming_WhenSequentialAccess_ReturnsTrue()
104+
{
105+
var config = new RetryConfig();
106+
var policy = new DefaultRetryPolicy(config);
107+
var cmd = new DummyCommand();
108+
109+
Assert.True(policy.IsStreaming(cmd, CommandBehavior.SequentialAccess));
110+
}
111+
112+
[Fact]
113+
public void IsStreaming_WhenCustomConfigDelegateUsed_ReturnsTrue()
114+
{
115+
var config = new RetryConfig { IsStreaming = (c, b) => true };
116+
var policy = new DefaultRetryPolicy(config);
117+
var cmd = new DummyCommand();
118+
119+
Assert.True(policy.IsStreaming(cmd, CommandBehavior.Default));
120+
}
121+
122+
[Fact]
123+
public void GetDelay_WhenDelayExceedsMaxDelay_IsCappedToMaxDelay()
124+
{
125+
var config = new RetryConfig
126+
{
127+
MaxDelay = TimeSpan.FromMilliseconds(500),
128+
DefaultDelay = (_, __) => TimeSpan.FromMilliseconds(1000)
129+
};
130+
var policy = new DefaultRetryPolicy(config);
131+
132+
var delay = policy.GetDelay(new Exception("test"), 1);
133+
134+
Assert.Equal(TimeSpan.FromMilliseconds(500), delay);
135+
}
136+
137+
[Fact]
138+
public void CanRetry_WhenOperationCanceledWithoutToken_ReturnsTrue()
139+
{
140+
var config = new RetryConfig();
141+
var policy = new DefaultRetryPolicy(config);
142+
143+
var ex = new OperationCanceledException(); // токен не передаём
144+
Assert.True(policy.CanRetry(ex, isIdempotent: true));
145+
}
146+
147+
private class DummyCommand : System.Data.Common.DbCommand
148+
{
149+
public override string CommandText { get; set; } = string.Empty;
150+
public override int CommandTimeout { get; set; }
151+
public override CommandType CommandType { get; set; } = CommandType.Text;
152+
protected override System.Data.Common.DbConnection DbConnection { get; set; }
153+
protected override System.Data.Common.DbParameterCollection DbParameterCollection { get; } = null!;
154+
protected override System.Data.Common.DbTransaction DbTransaction { get; set; }
155+
public override bool DesignTimeVisible { get; set; }
156+
public override UpdateRowSource UpdatedRowSource { get; set; }
157+
public override void Cancel() { }
158+
public override int ExecuteNonQuery() => 0;
159+
public override object ExecuteScalar() => null!;
160+
public override void Prepare() { }
161+
protected override System.Data.Common.DbParameter CreateDbParameter() => throw new NotImplementedException();
162+
protected override System.Data.Common.DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotImplementedException();
163+
}
164+
}

0 commit comments

Comments
 (0)