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

Create custom policy for Polly in C#

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 that 2^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.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply