Note: see the link below for the English version of this article.

https://duongnt.com/polly-retry-request

Dùng Polly để thử gửi lại request trong C#

Đôi khi, các request HTTP của ta gặp phải lỗi nhất thời. Thông thường, nếu ta gửi lại request vào một thời điểm khác thì những lỗi này sẽ tự hết. Mặc dù có thể tự viết code để gửi lại request, tôi ưu tiên sử dụng thư viện chuyên dụng như Polly cho việc này.

Các bạn có thể tải code ví dụ trong bài từ đường link dưới đây.

https://github.com/duongntbk/PollyDemo

Tự mình gửi lại request

Giả sử thỉnh thoảng ta gặp lỗi 503-Service Unavailable khi gọi API. Ta muốn gửi lại request trong trường hợp đó, nhưng ta cần giới hạn số lần gửi một request là không quá 5 lần. Code thực hiện việc này có vẻ không quá phức tạp.

private readonly HttpClient _client = new HttpClient(); 

public async Task<HttpResponseMessage> SendRequestWithRetry()
{
    HttpResponseMessage response;
    var sendCount = 0;
    do
    {
        var request = new HttpRequestMessage(HttpMethod.Get, new Uri("https://www.example.com"));
        response = await _client.SendAsync(request);
        sendCount++;
    }
    while (sendCount < 5 && response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable);

    return response;
}

Nhưng ta khó tái sử dụng code trên cho những endpoint khác. Có lẽ ta cần tách phần logic xử lý request HTTP sang một class chuyên dụng và viết logic gửi lại request trong hàm SendRequest của class mới này.

Hơn nữa, ta không nên gửi lại request ngay sau khi có lỗi. Thay vào đó, ta nên đợi một lúc để API có thời gian phục hồi. Có lẽ các bạn đã nhận ra vấn đề. Việc đáp ứng tất cả các yêu cầu trên là tương đối tốn thời gian và dễ gây nhầm lẫn. Vì thế, ta nên sử dụng những thư viện chuyên dụng như Polly.

Gửi lại request bằng thư viện Polly

Bước chuẩn bị

Các bạn có thể tải Polly bằng lệnh dưới.

dotnet add package Polly

Ta cũng cần một API để test. Tôi đã viết một API đơn giản để làm ví dụ cho bài hôm nay, các bạn hãy tham khảo đường link này. Để chạy API, các bạn cần vào folder DummyApi và chạy lệnh dưới đây.

dotnet run

Đường dẫn tới API là https://localhost:5001.

Polly hoạt động như thế nào

Nói một cách tổng quát, ta sẽ gửi request bằng Polly thông qua 2 interface: ISyncPolicy dành cho lệnh đồng bộ và IAsyncPolicy dành cho lệnh không đồng bộ. Trong bài hôm nay, ta chỉ quan tâm tới interface IAsyncPolicy. Cụ thể hơn, ta sẽ dùng hàm dưới đây.

Task<TResult> ExecuteAsync<TResult>(Func<Context, Task<TResult>> action, Context context);

Khi dùng hàm này, ta có thể đơn giản hóa logic để gửi lại request như sau.

var policy = <code to create an object that implements IAsyncPolicy>;
var pollyContext = new Context('<tên cho context>');
var response = await policy.ExecuteAsync(async ctx =>
{
    var request = new HttpRequestMessage(HttpMethod.Get, new Uri("https://www.example.com"));
    return await _client.SendAsync(request);
}

Đó là tất cả những điều ta cần làm. Bằng cách thiết lập object policy, ta có thể làm nhiều điều, ví dụ như.

  • Xử lý một kiểu exception nhất định, hoặc xử lý exception có một property nhất định.
  • Thử gửi lại request tối đa một số lần nhất định, giữa 2 lần gửi request có thời gian chờ.
  • Ngừng đợi request nếu thời gian chạy là quá lâu.
  • Lưu response vào cache.
  • Giới hạn tần suất gửi request.
  • Kết hợp của 2 hay nhiều việc trên.

Thử gửi lại request nếu gặp một lỗi nhất định

Code ta sử dụng

Trong ví dụ đầu tiên, ta sẽ dùng Polly để gửi lại request nếu gặp lỗi 503. Số lần thử lại tối đa là 5 lần. Đồng thời, ta đợi 500 ms giữa 2 lần gửi.

var pollyContext = new Context("Retry 503");
var policy = Policy
    .Handle<HttpRequestException>(ex => ex.Message.Contains("503"))
    .WaitAndRetryAsync(
        5,
        _ => TimeSpan.FromMilliseconds(500),
        (result, timespan, retryNo, context) =>
        {
            Console.WriteLine($"{context.OperationKey}: Retry number {retryNo} within " +
                $"{timespan.TotalMilliseconds}ms. Original status code: 503");
        }
    );

var response = await policy.ExecuteAsync(async ctx =>
{
    var request = new HttpRequestMessage(HttpMethod.Get, new Uri("https://www.example.com"));
    var response = await _client.SendAsync(request);
    response.EnsureSuccessStatusCode();
    return response;
}, pollyContext);

Đoạn code trên làm những gì?

Ta sẽ xét từng dòng trong đoạn code trên để xem chúng làm gì.

Hàm Handle<T> quyết định policy của ta có thể xử lý những sự kiện gì. Trong trường hợp này, ta dùng HttpRequestException làm kiểu generic, đồng thời ta truyền vào hàm một delegate để lọc ra những HttpRequestException có status code là 503-Service Unavailable.

.Handle<HttpRequestException>(ex => ex.Message.Contains("503"))

Hàm WaitAndRetryAsync định nghĩa hành động ta muốn thực hiện. Ở đây, ta sẽ thử gửi lại request sau một khoảng chờ. Hàm này có rất nhiều overload, nhưng ta sẽ sử dụng phiên bản đơn giản với 3 tham số.

  • retryCount: số lần tối đa ta gửi lại request. Ta đặt giá trị này là 5.
  • Func<int, TimeSpan> sleepDurationProvider: delegate để trả về thời gian chờ giữa 2 lần gửi request. Giá trị này được tính dựa vào số lần request đã được gửi trước đó. Ở đây, ta cố định thời gian chờ là 500 ms. Nhưng nếu cần ta có thể tăng dần thời gian chờ sau mỗi lần request gặp lỗi.
    _ => TimeSpan.FromMilliseconds(500)
    
  • Action<Exception, TimeSpan, int, Context> onRetry: những lệnh ta thực hiện trước khi gửi lại request. Ở đây ta chỉ đơn giản là ghi lại log.
    (ex, timespan, retryNo, context) =>
    {
        Console.WriteLine($"{context.OperationKey}: Retry number {retryNo} within " +
            $"{timespan.TotalMilliseconds}ms. Original status code: 503");
    }
    

Có lẽ các bạn cũng đã nhận ra là ta gọi hàm EnsureSuccessStatusCode bên trong delegate của ExecuteAsync. Nguyên nhân là do HttpClient không sinh exception khi server trả về 503-Service Unavailable. Trong khi đó, kiểu T của hàm Handle<T> lại phải là exception. Vì thế, ta phải tự mình sinh HttpRequestException.

response.EnsureSuccessStatusCode();

Chạy thử đoạn code trên

Đường dẫn /demo/error/{requestId}/{errorCode} của API test luôn trả về errorCode trong lần đầu tiên ta gọi nó với từng requestId. Những lần gọi sau với cùng requestId sẽ trả về 200-OK.

var requestId = Guid.NewGuid();
var errorCode = 503;
var response = await policy.ExecuteAsync(async ctx =>
{
    var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://localhost:5001/demo/error/{requestId}/{errorCode}"));
    var response = await _client.SendAsync(request);
    response.EnsureSuccessStatusCode();
    return response;
}, pollyContext);

Console.WriteLine(await response.Content.ReadAsStringAsync());

Đoạn code trên sẽ in ra thông tin dưới đây.

Retry503: Retry number 1 within 503ms. Original status code: 503 // request đầu tiên trả về 503 và được gửi lại một lần nữa
be21236d-6eb7-4a58-8208-56ac1dee4207 did not throw an error. // lần gửi thứ 2 thì request đã thành công

Thử lại tất cả các lỗi nhất thời

Đoạn code ở phần trên cho phép ta tự do chọn xử lý những status code nào. Nhưng nếu ta muốn xử lý tất cả những lỗi nhất thời thì ta sẽ phải viết một delegate phức tạp cho hàm Handle<T>. Thay vào đó, ta có thể dùng hàm HandleTransientHttpError trong thư viện Polly.Extensions.Http. Hàm này cho phép ta xử lý tất cả các lỗi 5xx và cả lỗi 408-Request Timeout.

var policy = HttpPolicyExtensions
    .HandleTransientHttpError() // <- hàm extension từ Polly.Extensions.Http
    .WaitAndRetryAsync(
        // lược bỏ một phần code
    );

Code sử dụng policy trên cũng đơn giản hơn. Chú ý là ta không cần gọi EnsureSuccessStatusCode nữa.

var response = await policy.ExecuteAsync(async ctx =>
{
    var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://localhost:5001/demo/error/{requestId}/{errorCode}"));
    return await _client.SendAsync(request);
}, pollyContext);

Console.WriteLine(await response.Content.ReadAsStringAsync());

Đoạn code trên sẽ in ra thông tin dưới đây.

RetryError: Retry number 1 after 500ms. Original status code: ServiceUnavailable // request đầu tiên trả về 503 và được gửi lại một lần nữa
be21236d-6eb7-4a58-8208-56ac1dee4207 did not throw an error. // lần gửi thứ 2 thì request đã thành công

Ngừng đợi một request chạy lâu

Ta không nhất thiết phải dùng hàm WaitAndRetryAsync khi tạo policy. Giả sử rằng request của ta đôi khi chạy rất mất thời gian và ta muốn ngừng đợi nó sau một khoảng chờ nhất định. Hàm TimeoutAsync có thể giúp ta thực hiện việc này.

var timeoutInMilliSecs = 200; // Ngừng đợi request sau 200 ms
var policy = Policy.TimeoutAsync(TimeSpan.FromMilliseconds(timeoutInMilliSecs), TimeoutStrategy.Pessimistic);

Nếu request thực hiện bằng policy này chạy lâu hơn 200 ms, Polly sẽ tự động sinh ra TimeoutRejectedException.

Ta sẽ thử policy này bằng cách gọi /demo/error/{requestId}/{delayInMilliSecs}. Đường dẫn này sẽ đợi ít nhất delayInMilliSecs trước khi trả về kết quả trong lần gọi đầu tiên với từng requestId. Những lần gọi sau với cùng requestId sẽ trả lại kết quả ngay lập tức.

// Đoạn code dưới sẽ gặp TimeoutRejectedException
var requestId = Guid.NewGuid();
var delay = 1000; // API sẽ đợi ít nhất 1000 ms trước khi trả lại kết quả
var response = await policy.ExecuteAsync(async ctx =>
{
    var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://localhost:5001/demo/delay/{requestId}/{delay}"));
    return await _client.SendAsync(request);
}, pollyContext);

Kết họp nhiều policy của Polly cùng lúc

Trong phần này, chúng ta sẽ ngừng đợi các request chạy lâu rồi thử gửi lại nó. Ta đã biết cách riêng lẻ làm từng yêu cầu trên, nhưng làm thế nào để thực hiện cả hai cùng lúc? Câu trả lời là ta cần dùng hàm Policy.WrapAsync. Hàm này cho phép ta tạo một object AsyncPolicyWrap để chứa tất cả các IAsyncPolicy ta truyền vào. Và vì bản thân nó cũng implement IAsyncPolicy, ta không phải thay đổi code nhiều.

var timeoutPolicy = Policy.TimeoutAsync(TimeSpan.FromMilliseconds(timeoutInMilliSecs), TimeoutStrategy.Pessimistic);
var retryPolicy = Policy
    .Handle<TimeoutRejectedException>() // chú ý là ở đây ta cần xử lý TimeoutRejectedException
    .WaitAndRetryAsync(
        // lược bỏ một phần code
    );
var wrapper = Policy.WrapAsync(retryPolicy, timeoutPolicy);

var response = await wrapper.ExecuteAsync(async ctx =>
{
    var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://localhost:5001/demo/delay/{requestId}/{delay}"));
    return await _client.SendAsync(request);
}, pollyContext);

Console.WriteLine(await response.Content.ReadAsStringAsync());

Ở đây có một điểm đáng lưu ý. Đó là khi gọi WrapAsync, ta cần truyền retryPolicy trước khi truyền timeoutPolicy. Nguyên nhân là vì AsyncPolicyWrap sẽ bọc policy bên trái ra ngoài policy bên phải. Nếu ta viết là (retryPolicy, timeoutPolicy) thì đầu tiên timeoutPolicy sẽ ngừng đợi request chạy lâu bằng cách sinh TimeoutRejectedException. Sau đó, retryPolicy sẽ thử gửi lại những request nào có TimeoutRejectedException. Nếu ta đảo ngược thứ tự thì retryPolicy sẽ được thực hiện trước, và nó sẽ không tìm thấy TimeoutRejectedException nào cả.

Đoạn code trên sẽ in ra thông tin dưới đây.

RetryDelay: Retry number 1 after 500ms because The delegate executed asynchronously through TimeoutPolicy did not complete within the timeout. // Request này chạy lâu hơn 200 ms, bị timeout, rồi được thử lại
be21236d-6eb7-4a58-8208-56ac1dee4207 was not delayed. // lần gửi request thứ 2 sẽ thành công

Kết thúc

Thư viện Polly giúp việc thử gửi lại request trở nên dễ dàng hơn. Nhưng thư viện này còn làm được nhiều hơn thế. Từ các tham số của ExecuteAsync, có lẽ các bạn cũng đoán được là nó không chỉ biết cách xử lý các request HTTP. Trong bài tới, chúng ta sẽ tìm hiểu cách viết custom policy bằng Policy.

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

One Thought on “Dùng Polly để thử gửi lại request trong C#”

Leave a Reply