2019年10月18日 星期五

[ASP.NET Core] HttpClient and IHttpClientFactory


 ASP.NET Core   HttpClient   Dependency Injection  


Introduction


Creating a new instance of HttpClient every time is thought as an bad idea, cus it will create a new TCP/IP connection and wait for a period time even if you dispose HttpClient instance. (Reference: YOU'RE USING HTTPCLIENT WRONG AND IT IS DESTABILIZING YOUR SOFTWARE)

After ASP.NET Core 2.1,  we can use IHttpClientFactory to manage(register/create) the HttpClient instance(s) and manage the pooling and lifetime of underlying HttpMessageHandler instances to avoid common DNS problems (Such as DNS changes).


This article shows the 3 ways to use HttpClient with IHttpClientFactory.
·         Basic Usage
·         Named Clients
·         Typed Clients


The following picture from Microsoft Document shows how Typed Client works, but the concept of the above ways are the same.





Environment


ASP.NET Core 3.0.100



Implement



Basic usage

Register the IHttpClientFactory to service container in Startup: ConfigureServices,

Startup.cs

 public void ConfigureServices(IServiceCollection services)
 {
    // …skip
    services.AddHttpClient();
 }


Then we can use it on our other API Controller or injected service, for example,

API Controller

[Route("api/[controller]")]
[ApiController]
public class MyController : ControllerBase
{
        
        private readonly IHttpClientFactory httpClientFactory = null;

        public MyController(IHttpClientFactory httpClientFactory)
        {
            this.httpClientFactory = httpClientFactory;
        }

        [HttpGet("Test")]
        public async Task<IActionResultTest()
        {
            var httpClient = this.httpClientFactory.CreateClient();
            var result = await httpClient.GetAsync("https://karatejb.blogspot.com/");
            return this.Ok();
        }
}



Named Clients

For different configuration of HttpClient, we can use Named Clients to distinguish them.
For example,

Startup.cs : ConfigureServices

 services.AddHttpClient("AuthHttpClient"
         config => 
                {
                    config.Timeout = TimeSpan.FromMinutes(5);
                    config.BaseAddress = new Uri("https://MyAuthServer/");
                    config.DefaultRequestHeaders.Add("Accept""application/json");
                })
                .SetHandlerLifetime(TimeSpan.FromMinutes(5)); 

 services.AddHttpClient("NormalHttpClient"
         config => 
                {
                    config.Timeout = TimeSpan.FromMinutes(1);
                    config.BaseAddress = new Uri("https://karatejb.blogspot.com");
                    config.DefaultRequestHeaders.Add("Accept""text/xml");
                    config.DefaultRequestHeaders.TryAddWithoutValidation("Authorization""Bearer XXXXXX");
                })
                .SetHandlerLifetime(TimeSpan.FromMinutes(2)); 


Notice the default HttpMessageHandler‘s lifetime is 2 minutes and can be reused from pool, and after 2 mins the created HttpClientHandler (which inherits HttpMessageHandler) instance will no longer be used by new HttpClient instance and will be waiting for GC. We can update the lifetime by SetHandlerLifetime method as the above sample codes.

After injecting Named Clients, we can create the new specified HttpClient by passing name to IHttpClientFactory.CreateClient,

[Route("api/[controller]")]
[ApiController]
public class MyController : ControllerBase
{
        private readonly IHttpClientFactory httpClientFactory = null;

        public MyController(IHttpClientFactory httpClientFactory)
        {
            this.httpClientFactory = httpClientFactory;
        }

        [HttpGet("Test")]
        public async Task<IActionResultTest()
        {
            var httpClient = this.httpClientFactory.CreateClient("NormalHttpClient");
            var result = await httpClient.GetAsync("2019/07/aspnet-core-identity-server-4-ldap.html");
            return this.Ok();
        }
}

Which will results sending a request as below,




Typed Clients

The Typed-Client way provides injecting the HttpClient as Transient-lifetime into services.

Startup.cs : ConfigureServices

For example, we can inject the Typed Client to a custom service: IdentityClient,

services.AddHttpClient<IIdentityClientIdentityClient>( config => config.BaseAddressnew Uri("https://localhost:6001/")).SetHandlerLifetime(TimeSpan.FromMinutes(2)) // HttpMessageHandler default lifetime = 2 min


PS. However the IdentityClient instance will be Transient as well!


Then get the HttpClient instance like this,

IdentityClient.cs

public class IdentityClient : IIdentityClient
{
        private readonly HttpClient httpClient = null;

        public IdentityClient(HttpClient httpClient)
        {
            this.httpClient = httpClient;
        }

        private async Task<HttpResponseMessage> SendMyRequest()
        {
            var response = await this.httpClient.GetAsync("https://localhost/xxxxx");
            return response;
        }
}



Outgoing request middleware

We can use and chain delegating handlers to perform some works BEFORE and AFTER the outgoing request.

Take sending a refreshing-token request for example, we can check
1.  If the target host is allowed BEFORE sending request
2.  If the request url is secure with Https BEFORE sending request

And if we want to allow one-time refresh token (that means after first-time refreshing token, we will not send a new Refresh Token to do the second-time refreshing token). We can withdraw the new refresh token AFTER receiving the response.

Create the following delegating handlers to meet the above requirements by inheriting System.Net.Http.DelegatingHandler.

AuthHostCheckRequestHandler: Check request’s target host

 public class AuthHostCheckRequestHandler : DelegatingHandler
 {
        private readonly string[] ALLOWED_DOMAINS = new string[] { "localhost""my_auth_server" };
        protected override async Task<HttpResponseMessageSendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken)
        {
            if (!ALLOWED_DOMAINS.Contains(request.RequestUri.Host))
            {
                var response = new HttpResponseMessage(HttpStatusCode.BadRequest);
                var jObject = JObject.Parse("{\"error\":\"The target domain " + request.RequestUri.Host + " is not allowed!\"}");
                response.Content = new StringContent(jObject.ToString(), Encoding.UTF8"application/json");
                return response;
            };

            return await base.SendAsync(requestcancellationToken);
        }
 }



SecureRequestHandler: Https url required

 public class SecureRequestHandler : DelegatingHandler
 {
        protected override async Task<HttpResponseMessageSendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken)
        {
            
            if (!request.RequestUri.ToString().StartsWith("https"))
            {
                var response = new HttpResponseMessage(HttpStatusCode.BadRequest);
                var jObject = JObject.Parse("{\"error\":\"You cannot send request to insecure endpoints!\"}");
                response.Content = new StringContent(jObject.ToString(), Encoding.UTF8"application/json");
                return response;
            };

            return await base.SendAsync(requestcancellationToken);
        }
 }



OneTimeRefreshTokenResponseHandler: Withdraw new refresh token

 public class OneTimeRefreshTokenResponseHandler : DelegatingHandler
 {
        protected override async Task<HttpResponseMessageSendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken)
        {
            var response = await base.SendAsync(requestcancellationToken);
            if (response.IsSuccessStatusCode)
            {
                var jsonStr = await response.Content.ReadAsStringAsync();
                JObject jObject = JObject.Parse(jsonStr);
                jObject.Remove("refresh_token");
                response.Content = new StringContent(jObject.ToString(), Encoding.UTF8"application/json");
            }

            return response;
        }
 }


Startup: ConfigureServices

We have to register the delegating handlers and add them to HttpClient by HttpClientBuilderExtensions: AddHttpMessageHandler method as following,

 public void ConfigureServices(IServiceCollection services)
 {
            services.AddTransient<AuthHostCheckRequestHandler>();
            services.AddTransient<SecureRequestHandler>();
            services.AddTransient<OneTimeRefreshTokenResponseHandler>();

            services.AddHttpClient("RefreshTokenHttpClient",
                config =>
                {
                    config.Timeout = TimeSpan.FromMinutes(5);
                    config.BaseAddress = new Uri("https://my_auth_server/");
                    config.DefaultRequestHeaders.Add("Accept""application/json");
                })
                .AddHttpMessageHandler<AuthHostCheckRequestHandler>()
                .AddHttpMessageHandler<SecureRequestHandler>()
                .AddHttpMessageHandler<OneTimeRefreshTokenResponseHandler>();
 }


Notice that the delegating handlers will be triggered by the order of AddHttpMessageHandler.
The order will be AuthHostCheckRequestHandler -> SecureRequestHandler -> OneTimeRefreshTokenResponseHandler.




Lets take a look at the final response for every scenario.


1.  The host of base address is not allowed (intercepted by AuthHostCheckRequestHandler)




2.  The request is not Https (intercepted by SecureRequestHandler)





3.  Successfully refreshing token and the new refresh token is withdrawed (intercepted by OneTimeRefreshTokenResponseHandler)




BTW, if not using this delegating handler, the original response will be





Polly: dynamic policies

Polly is a .NET resilience and transient-fault-handling library that allows developers to express policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, and Fallback in a fluent and thread-safe manner. (From Github)



You have to install Microsoft.Extensions.Http.Polly to use the extension methods later in the sample codes.




Polly Timeout Policy sample

Here is an example for setting a timeout policy to HttpClient (in Startup: ConfigureServices),

var timeout = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(10));
services.AddHttpClient("MyHttpClient").AddPolicyHandler(timeout);

Or set conditional policies for different Http methods,

var timeout = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(10));
var longTimeout = Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(60));

services.AddHttpClient("MyHttpClient")
        .AddPolicyHandler(request =>
                    request.Method == HttpMethod.Get ? timeout : longTimeout);



Polly Retry Policy sample

A retry policy for failing request (try up to 3 times).

services.AddHttpClient("MyHttpClient")
        .AddTransientHttpErrorPolicy(p => p.RetryAsync(3));

A retry policy for certain Response’s status code (e.q. 500 or 400) and do something before re-sending the request:

var retryPolicy = Policy.HandleResult<HttpResponseMessage>(
         r => r.StatusCode.Equals(HttpStatusCode.BadRequest) || r.StatusCode.Equals(HttpStatusCode.InternalServerError)).RetryAsync(3, (exceptionretryCount) =>
            {
                // Logic to be executed before sending retry request
                Debug.WriteLine("Do something...");
            });

services.AddHttpClient("MyHttpClient")
            ////.AddPolicyHandler(request => request.Method == HttpMethod.Post ? retryPolicy : null)
            .AddPolicyHandler(retryPolicy);

Other ways:

var retryPolicy = HttpPolicyExtensions.HandleTransientHttpError()
       .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.BadRequest)
       .WaitAndRetryAsync(3retryAttempt => TimeSpan.FromSeconds(Math.Pow(2retryAttempt)));

services.AddHttpClient("MyHttpClient").AddPolicyHandler(retryPolicy);


var retryPolicy = HttpPolicyExtensions.HandleTransientHttpError().OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.BadRequest)
     .RetryAsync(2, (exceptionretryCount) =>
     {
           // Logic to be executed before sending retry request
           Debug.WriteLine("Update data ...");
     });

services.AddHttpClient("MyHttpClient").AddPolicyHandler(retryPolicy);

The above ways are appending the retry policy on the HttpClientFactory level.
If you want HttpClient to use different retry policy on the fly, you can execute the retry policy when sending a request and even cancel a retry-request:

var httpClient = this.httpClientFactory.CreateClient("MyHttpClient");
CancellationTokenSource cts = new CancellationTokenSource();

var retryPolicy = Policy
            .HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.UnprocessableEntity)
            .RetryAsync(3async (exceptionretryCount) =>
            {
                if (someUnexpectedCondition)
                {
                    // Cancel the retry request
                    cts.Cancel();
                }
                else
                {
                    // Do something before sending the retry request
                }
            });

HttpResponseMessage response = null;
var result = await retryPolicy.ExecuteAsync(
                async (token) =>
                {
                   if (!token.IsCancellationRequested)
                   {
                       response = await httpClient.PostAsync(urlnew StringContent(myContentEncoding.UTF8type));
                   }
                   return response;
                }, cts.Token);

return result;





Polly Mixed Policy sample

A mixed retry and circuit-breaker policy sample from
Microsoft Doc.

 services.AddHttpClient ("MyHttpClient")
         .AddTransientHttpErrorPolicy (p => p.RetryAsync (3))
         .AddTransientHttpErrorPolicy (p => p.CircuitBreakerAsync (5TimeSpan.FromSeconds (30)));

The second policy is a circuit breaker policy. Further requests are blocked for 30 seconds if five failed attempts occur sequentially. Circuit breaker policies are stateful. All calls through this client share the same circuit state.


Some notes

Self-signed SSL cert error

We may encounter a scenario that HttpClient will not allow accessing a self-signed cert even if it is in our trusted store in Linux or macOS.

We can bypass it by the following setting, (ONLY use it on non-production environment!)

 services.AddHttpClient<IIdentityClientIdentityClient>()
            .ConfigurePrimaryHttpMessageHandler(h =>
            {
              var handler = new HttpClientHandler();
              if (this.env.IsDevelopment())
              {
                  //Allow untrusted Https connection
                  handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
              }
              return handler;
            });




Reference








沒有留言:

張貼留言