Note: phiên bản Tiếng Việt của bài này ở link dưới.
https://duongnt.com/polly-custom-policy-vie
In a previous article, we used Polly to retry HTTP requests. As promised in the Conclusion section, today we will check out a more advanced feature: custom policy.
Remember how we called the Policy.TimeoutAsync method to create an IAsyncPolicy
and used that policy to time out long-running requests. In this article, we will define our own policies.
You can download the sample code in this article from the link below.
https://github.com/duongntbk/PollyDemo
A problem that necessitates retries
As mentioned in the last article, HTTP requests is not the only thing Polly can retry. Today, we will solve a different problem that also requires retry.
Generate numbers within an arbitrary range with just a coin
We will start with a well-known problem. Given a random number generator that can only return 0
or 1
, each with 50% probability (essentially a coin flip), how can we randomly generate numbers in a given range so that each number appears with the same frequency?
The algorithm to solve this problem is below.
- Calculate smallest
n
so that2^n - 1 >= upper bound
. - Use the generator to generate
n
bits. - Convert the
n
bit binary number above to decimal. - If the result is within the range, we return that number.
- Otherwise, we discard that number and retry from step 1.
The initial code
The first version of our code is below.
var bitCount = (int)Math.Floor(Math.Log(max, 2)) + 1;
var rs = 0;
for (var bitIndex = 0; bitIndex < bitCount; bitIndex++)
{
var bit = _bitGetter.Get(); // _bitGetter is a generator that returns 0 or 1
rs += (int)Math.Pow(2, bitIndex) * bit;
}
return rs;
Clearly, it will generate all numbers between 0
and 2^n - 1
. Our goal today is to create a custom Polly policy to retry whenever rs
is outside the given range.
Create a custom policy with Polly
The "Hello, world!" of custom policy
Polly makes it very easy to create a custom policy. To handle synchronous delegates, we just need to inherit from the abstract class Policy
and override the Implementation
method. In the most simple case, a policy looks like this.
public class PassthroughPolicy : Policy
{
protected override TResult Implementation<TResult>(Func<Context, CancellationToken, TResult> action,
Context context, CancellationToken cancellationToken)
{
return action(context, cancellationToken);
}
}
Here, action
is whatever delegate we pass to the Execute
method of ISyncPolicy
. As its name suggests, the PassthroughPolicy
just invokes the delegate and returns the result without changing anything.
Verify that the generated number is within the given range
We save the lower bound and upper bound into two private fields when we initialize our policy. Notice that we set the constructor to private and define a static method to call that private constructor. This is simply Polly’s convention.
private readonly int _min, _max;
private NumberOutOfRangePolicy(int min, int max)
{
_min = min;
_max = max;
}
public static NumberOutOfRangePolicy Create(int min, int max) =>
new NumberOutOfRangePolicy(min, max);
Then we add the code below to verify that the number generated by action
is within that range.
var num = action(context, cancellationToken);
if (num as int? < _min || num as int? > _max)
{
throw new NumberOutOfRangeException();
}
return num;
Retry when the generated number is outside the given range
As we can see, our policy throws a NumberOutOfRangeException
when the generated number is outside the given range. Another policy will "handle" that exception and perform the retry. Because our action is synchronous, we use Policy.Wrap
and Execute
instead of Policy.WrapAsync
and ExecuteAsync
.
var numberOutOfRangePolicy = NumberOutOfRangePolicy.Create(min, max);
var retryPolicy = Policy
.Handle<NumberOutOfRangeException>()
.RetryForever(); // Will success eventually, because of probability
_policies = Policy.Wrap(retryPolicy, numberOutOfRangePolicy);
_policies.Execute(() => numberGenerator.Next(91, 100));
Of course, there is nothing preventing us from implementing that retry logic directly inside NumberOutOfRangePolicy
. In that case, we don’t need the retryPolicy
anymore.
TResult num;
do
{
num = action(context, cancellationToken);
}
while (num as int? <= _min && num as int? >= _max);
return num;
I don’t recommend doing this though. Why reinvent the wheel?
Test our generator
Below is the result of 1000 calls to numberGenerator.Next(91, 100)
, with and without Polly policies.
######################################
Generating numbers without policy...
######################################
0: 0.5%
1: 0.5%
2: 0.8%
3: 0.4%
// ...omitted
126: 0.8999999999999999%
127: 0.8%
######################################
Generating numbers with policy...
######################################
91: 10.7%
92: 8.9%
93: 10%
94: 9.1%
95: 9.8%
96: 10.2%
97: 9.6%
98: 10%
99: 11.600000000000001%
100: 10.100000000000001%
Without policy, all numbers between 0
and 127 (2^7 - 1)
appeared with approximately 1/128
probability. With policies, only numbers between 91
and 100
appeared, each with 1/10
probability.
What about asynchronous custom policy?
Define an asynchronous custom policy
To create an asynchronous custom policy, we need to inherit from the AsyncPolicy
abstract class. As an example, we will create a policy to time out long-running asynchronous processes. It will be similar to the Policy.TimeoutAsync
policy introduced in the previous article. You can find the complete code here. Below is the ImplementationAsync
method.
private readonly int _timeoutInMilliSecs;
protected override async Task<TResult> ImplementationAsync<TResult>(
Func<Context, CancellationToken, Task<TResult>> action, Context context,
CancellationToken cancellationToken, bool continueOnCapturedContext)
{
var delegateTask = action(context, cancellationToken);
var timeoutTask = Task.Delay(_timeoutInMilliSecs);
await Task.WhenAny(delegateTask, timeoutTask).ConfigureAwait(continueOnCapturedContext);
if (timeoutTask.IsCompleted)
{
throw new DuongTimeoutException(
$"{context.OperationKey}: Task did not complete within: {_timeoutInMilliSecs}ms");
}
return await delegateTask;
}
Obviously, this is a crude implementation and is not suitable for production. But it gets the job done as a demonstration. We use Task.Delay
to start a task that finishes after _timeoutInMilliSecs
. If that task completes before the task created by action
, we know that action
is taking more than _timeoutInMilliSecs
and we should throw an exception.
Test our timeout policy
Below is the code to use our policy to time out a task that runs longer than 500ms
.
var pollyContext = new Context("Timeout");
var policy = DuongTimeoutPolicy.Create(500);
try
{
await policy.ExecuteAsync(async ctx =>
{
await Task.Delay(1000);
}, pollyContext);
Console.WriteLine("Task ran to completion.");
}
catch (DuongTimeoutException ex)
{
Console.WriteLine(ex.Message);
}
It should print the following to the command line.
Timeout: Task did not complete within: 500ms
If we want to retry this task, we can add another policy to "handle" the DuongTimeoutException
.
Conclusion
The two use cases we explored today are still quite simple. If you can come up with something more creative then please let me know in the comment section.