Skip to content

Commit 9e39482

Browse files
committed
Add retry pattern with delay calculation support
1 parent 5610f95 commit 9e39482

File tree

2 files changed

+342
-0
lines changed

2 files changed

+342
-0
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//-----------------------------------------------------------------------
2+
// <copyright file="RetrySpec.cs" company="Akka.NET Project">
3+
// Copyright (C) 2009-2020 Lightbend Inc. <http://www.lightbend.com>
4+
// Copyright (C) 2013-2020 .NET Foundation <https://github.com/akkadotnet/akka.net>
5+
// </copyright>
6+
//-----------------------------------------------------------------------
7+
8+
using System;
9+
using System.Threading.Tasks;
10+
using Akka.TestKit;
11+
using Xunit;
12+
using static Akka.Pattern.RetrySupport;
13+
14+
namespace Akka.Tests.Pattern
15+
{
16+
public class RetrySpec : AkkaSpec
17+
{
18+
[Fact]
19+
public Task Pattern_Retry_must_run_a_successful_task_immediately()
20+
{
21+
var retried = Retry(() => Task.FromResult(5), 5, TimeSpan.FromSeconds(1), Sys.Scheduler);
22+
23+
return WithinAsync(TimeSpan.FromSeconds(3), async () =>
24+
{
25+
var remaining = await retried;
26+
Assert.Equal(5, remaining);
27+
});
28+
}
29+
30+
[Fact]
31+
public Task Pattern_Retry_must_run_a_successful_task_only_once()
32+
{
33+
var counter = 0;
34+
var retried = Retry(() =>
35+
{
36+
counter++;
37+
return Task.FromResult(counter);
38+
}, 5, TimeSpan.FromSeconds(1), Sys.Scheduler);
39+
40+
return WithinAsync(TimeSpan.FromSeconds(3), async () =>
41+
{
42+
var remaining = await retried;
43+
Assert.Equal(1, remaining);
44+
});
45+
}
46+
47+
[Fact]
48+
public Task Pattern_Retry_must_eventually_return_a_failure_for_a_task_that_will_never_succeed()
49+
{
50+
var retried = Retry(() => Task.FromException<int>(new InvalidOperationException("Mexico")), 5, TimeSpan.FromMilliseconds(100), Sys.Scheduler);
51+
52+
return WithinAsync(TimeSpan.FromSeconds(3), async () =>
53+
{
54+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => retried);
55+
Assert.Equal("Mexico", exception.Message);
56+
});
57+
}
58+
59+
[Fact]
60+
public Task Pattern_Retry_must_return_a_success_for_a_task_that_succeeds_eventually()
61+
{
62+
var failCount = 0;
63+
64+
Task<int> Attempt()
65+
{
66+
if (failCount < 5)
67+
{
68+
failCount += 1;
69+
return Task.FromException<int>(new InvalidOperationException(failCount.ToString()));
70+
}
71+
else
72+
{
73+
return Task.FromResult(5);
74+
}
75+
}
76+
77+
var retried = Retry(() => Attempt(), 10, TimeSpan.FromMilliseconds(100), Sys.Scheduler);
78+
79+
return WithinAsync(TimeSpan.FromSeconds(3), async () =>
80+
{
81+
var remaining = await retried;
82+
Assert.Equal(5, remaining);
83+
});
84+
}
85+
86+
[Fact]
87+
public Task Pattern_Retry_must_return_a_failure_for_a_task_that_would_have_succeeded_but_retries_were_exhausted()
88+
{
89+
var failCount = 0;
90+
91+
Task<int> Attempt()
92+
{
93+
if (failCount < 10)
94+
{
95+
failCount += 1;
96+
return Task.FromException<int>(new InvalidOperationException(failCount.ToString()));
97+
}
98+
else
99+
{
100+
return Task.FromResult(5);
101+
}
102+
}
103+
104+
var retried = Retry(() => Attempt(), 5, TimeSpan.FromMilliseconds(100), Sys.Scheduler);
105+
106+
return WithinAsync(TimeSpan.FromSeconds(3), async () =>
107+
{
108+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => retried);
109+
Assert.Equal("6", exception.Message);
110+
});
111+
}
112+
113+
[Fact]
114+
public Task Pattern_Retry_must_return_a_failure_for_a_task_that_would_have_succeeded_but_retries_were_exhausted_with_delay_function()
115+
{
116+
var failCount = 0;
117+
var attemptedCount = 0;
118+
119+
Task<int> Attempt()
120+
{
121+
if (failCount < 10)
122+
{
123+
failCount += 1;
124+
return Task.FromException<int>(new InvalidOperationException(failCount.ToString()));
125+
}
126+
else
127+
{
128+
return Task.FromResult(5);
129+
}
130+
}
131+
132+
var retried = Retry(() => Attempt(), 5, attempted =>
133+
{
134+
attemptedCount = attempted;
135+
return TimeSpan.FromMilliseconds(100 + attempted);
136+
}, Sys.Scheduler);
137+
138+
return WithinAsync(TimeSpan.FromSeconds(3), async () =>
139+
{
140+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => retried);
141+
Assert.Equal("6", exception.Message);
142+
Assert.Equal(5, attemptedCount);
143+
});
144+
}
145+
146+
[Fact]
147+
public Task Pattern_Retry_can_be_attempted_without_any_delay()
148+
{
149+
var failCount = 0;
150+
151+
Task<int> Attempt()
152+
{
153+
if (failCount < 1000)
154+
{
155+
failCount += 1;
156+
return Task.FromException<int>(new InvalidOperationException(failCount.ToString()));
157+
}
158+
else
159+
{
160+
return Task.FromResult(1);
161+
}
162+
}
163+
164+
var start = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
165+
var retried = Retry(() => Attempt(), 999);
166+
167+
return WithinAsync(TimeSpan.FromSeconds(1), async () =>
168+
{
169+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => retried);
170+
Assert.Equal("1000", exception.Message);
171+
172+
var elapse = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - start;
173+
Assert.True(elapse <= 100);
174+
});
175+
}
176+
177+
[Fact]
178+
public Task Pattern_Retry_must_handle_thrown_exceptions_in_same_way_as_failed_task()
179+
{
180+
var failCount = 0;
181+
182+
Task<int> Attempt()
183+
{
184+
if (failCount < 5)
185+
{
186+
failCount += 1;
187+
return Task.FromException<int>(new InvalidOperationException(failCount.ToString()));
188+
}
189+
else
190+
{
191+
return Task.FromResult(5);
192+
}
193+
}
194+
195+
var retried = Retry(() => Attempt(), 10, TimeSpan.FromMilliseconds(100), Sys.Scheduler);
196+
197+
return WithinAsync(TimeSpan.FromSeconds(3), async () =>
198+
{
199+
var remaining = await retried;
200+
Assert.Equal(5, remaining);
201+
});
202+
}
203+
}
204+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//-----------------------------------------------------------------------
2+
// <copyright file="RetrySupport.cs" company="Akka.NET Project">
3+
// Copyright (C) 2009-2020 Lightbend Inc. <http://www.lightbend.com>
4+
// Copyright (C) 2013-2020 .NET Foundation <https://github.com/akkadotnet/akka.net>
5+
// </copyright>
6+
//-----------------------------------------------------------------------
7+
8+
using System;
9+
using System.Threading.Tasks;
10+
using Akka.Actor;
11+
using Akka.Util;
12+
using static Akka.Pattern.FutureTimeoutSupport;
13+
14+
namespace Akka.Pattern
15+
{
16+
/// <summary>
17+
/// This class provides the retry utility functions.
18+
/// </summary>
19+
public static class RetrySupport
20+
{
21+
/// <summary>
22+
/// <para>
23+
/// Given a function, returns an internally retrying Task.
24+
/// The first attempt will be made immediately, each subsequent attempt will be made immediately
25+
/// if the previous attempt failed.
26+
/// </para>
27+
/// If attempts are exhausted the returned Task is simply the result of invoking attempt.
28+
/// </summary>
29+
/// <param name="attempt">TBD</param>
30+
/// <param name="attempts">TBD</param>
31+
public static Task<T> Retry<T>(Func<Task<T>> attempt, int attempts) =>
32+
Retry(attempt, attempts, attempted: 0);
33+
34+
/// <summary>
35+
/// <para>
36+
/// Given a function, returns an internally retrying Task.
37+
/// The first attempt will be made immediately, each subsequent attempt will be made with a backoff time,
38+
/// if the previous attempt failed.
39+
/// </para>
40+
/// If attempts are exhausted the returned Task is simply the result of invoking attempt.
41+
/// </summary>
42+
/// <param name="attempt">TBD</param>
43+
/// <param name="attempts">TBD</param>
44+
/// <param name="minBackoff">minimum (initial) duration until the child actor will started again, if it is terminated.</param>
45+
/// <param name="maxBackoff">the exponential back-off is capped to this duration.</param>
46+
/// <param name="randomFactor">after calculation of the exponential back-off an additional random delay based on this factor is added, e.g. `0.2` adds up to `20%` delay. In order to skip this additional delay pass in `0`.</param>
47+
/// <param name="scheduler">The scheduler instance to use.</param>
48+
public static Task<T> Retry<T>(Func<Task<T>> attempt, int attempts, TimeSpan minBackoff, TimeSpan maxBackoff, int randomFactor, IScheduler scheduler)
49+
{
50+
if (attempt == null) throw new ArgumentNullException("Parameter attempt should not be null.");
51+
if (minBackoff <= TimeSpan.Zero) throw new ArgumentException("Parameter minBackoff must be > 0");
52+
if (maxBackoff < minBackoff) throw new ArgumentException("Parameter maxBackoff must be >= minBackoff");
53+
if (randomFactor < 0.0 || randomFactor > 1.0) throw new ArgumentException("RandomFactor must be between 0.0 and 1.0");
54+
55+
return Retry(attempt, attempts, attempted => BackoffSupervisor.CalculateDelay(attempted, minBackoff, maxBackoff, randomFactor), scheduler);
56+
}
57+
58+
/// <summary>
59+
/// <para>
60+
/// Given a function, returns an internally retrying Task.
61+
/// The first attempt will be made immediately, each subsequent attempt will be made after 'delay'.
62+
/// A scheduler (eg Context.System.Scheduler) must be provided to delay each retry.
63+
/// </para>
64+
/// If attempts are exhausted the returned future is simply the result of invoking attempt.
65+
/// </summary>
66+
/// <param name="attempt">TBD</param>
67+
/// <param name="attempts">TBD</param>
68+
/// <param name="delay">TBD</param>
69+
/// <param name="scheduler">The scheduler instance to use.</param>
70+
public static Task<T> Retry<T>(Func<Task<T>> attempt, int attempts, TimeSpan delay, IScheduler scheduler) =>
71+
Retry(attempt, attempts, _ => delay, scheduler);
72+
73+
/// <summary>
74+
/// <para>
75+
/// Given a function, returns an internally retrying Task.
76+
/// The first attempt will be made immediately, each subsequent attempt will be made after
77+
/// the 'delay' return by `delayFunction`(the input next attempt count start from 1).
78+
/// Returns <see cref="Option{TimeSpan}.None"/> for no delay.
79+
/// A scheduler (eg Context.System.Scheduler) must be provided to delay each retry.
80+
/// You could provide a function to generate the next delay duration after first attempt,
81+
/// this function should never return `null`, otherwise an <see cref="InvalidOperationException"/> will be through.
82+
/// </para>
83+
/// If attempts are exhausted the returned Task is simply the result of invoking attempt.
84+
/// </summary>
85+
/// <param name="attempt">TBD</param>
86+
/// <param name="attempts">TBD</param>
87+
/// <param name="delayFunction">TBD</param>
88+
/// <param name="scheduler">The scheduler instance to use.</param>
89+
public static Task<T> Retry<T>(Func<Task<T>> attempt, int attempts, Func<int, Option<TimeSpan>> delayFunction, IScheduler scheduler) =>
90+
Retry(attempt, attempts, delayFunction, attempted: 0, scheduler);
91+
92+
private static Task<T> Retry<T>(Func<Task<T>> attempt, int maxAttempts, int attempted) =>
93+
Retry(attempt, maxAttempts, _ => Option<TimeSpan>.None, attempted);
94+
95+
private static Task<T> Retry<T>(Func<Task<T>> attempt, int maxAttempts, Func<int, Option<TimeSpan>> delayFunction, int attempted, IScheduler scheduler = null)
96+
{
97+
Task<T> tryAttempt()
98+
{
99+
try
100+
{
101+
return attempt();
102+
}
103+
catch (Exception ex)
104+
{
105+
return Task.FromException<T>(ex); // in case the `attempt` function throws
106+
}
107+
}
108+
109+
if (maxAttempts < 0) throw new ArgumentException("Parameter maxAttempts must >= 0.");
110+
if (attempt == null) throw new ArgumentNullException(nameof(attempt), "Parameter attempt should not be null.");
111+
112+
if (maxAttempts - attempted > 0)
113+
{
114+
return tryAttempt().ContinueWith(t =>
115+
{
116+
if (t.IsFaulted)
117+
{
118+
var nextAttempt = attempted + 1;
119+
switch (delayFunction(nextAttempt))
120+
{
121+
case Option<TimeSpan> delay when delay.HasValue:
122+
return delay.Value.Ticks < 1
123+
? Retry(attempt, maxAttempts, delayFunction, nextAttempt, scheduler)
124+
: After(delay.Value, scheduler, () => Retry(attempt, maxAttempts, delayFunction, nextAttempt, scheduler));
125+
case Option<TimeSpan> _:
126+
return Retry(attempt, maxAttempts, delayFunction, nextAttempt, scheduler);
127+
default:
128+
throw new InvalidOperationException("The delayFunction of Retry should not return null.");
129+
}
130+
}
131+
return t;
132+
}).Unwrap();
133+
}
134+
135+
return tryAttempt();
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)