PowerBIClient and socket exhaustion in ASP.NET Core app

There are many sample solutions on the Internet that show how to use PowerBIClient in ASP.NET Core apps. The typical approach is to create a new instance of it and then use it to invoke web requests. Eventually, it is disposed. Is it a good approach?

PowerBIClient and socket exhaustion in ASP.NET Core app

PowerBIClient is a part of Microsoft.PowerBI.Api package. Developers use it to integrate their apps with PowerBI. There are many sample solutions on the Internet that show how to use it in ASP.NET Core apps. The typical approach is to create a new instance of PowerBIClient and then use it to invoke web requests. Eventually, it is disposed of. Unfortunately, under the hood, it manually creates HttpClient and therefore may lead to socket exhaustion. In this post, we will discuss two alternative options of using PowerBIClient in the ASP.NET Core web app.

This article does not describe socket exhaustion itself. I assume that you are familiar with HttpClient’s problems in .NET.

What is the problem and what are possible solutions?

The current version of Microsoft.PowerBI.Api (4.2.0) does not allow injecting your own HttpClient into PowerBIClient. There is no constructor that allows doing this. However, there are some overloads that take HttpMessageHandler and DelegatingHandler instances. Furthermore, if you look at PowerBIClient source code you will find out that it derives from ServiceClient<> class. Consequently, wherever PowerBIClient instance is disposed of, its internal HttpClient gets disposed of too together with all HTTP handlers that are referenced by ServiceClient base class.

What can we do? From my research there are two options:

  • Register PowerBIClient as a singleton.
  • Override internal HttpClient with instance created by IHttpClientFactory.

It used to be quite common to register HttpClient as a singleton. The downside of this approach was that HttpClient did not respond to DNS changes. Since ASP.NET Core 2.1 the suggested solution has been to use IHttpClientFactory which manages an instance of HttpMessageHandler (and therefore connections lifetime).Together with .NET Core 2.1 new SocketsHttpHandler was created. It is the main HttpMessageHandler of every HttpClient instance. Internally it manages connections and reacts to DNS changes by replacing them with new ones.

Both solutions mentioned above are applicable to PowerBIClient and will be presented in this article.

PowerBI API token provider

First of all, both option 1 and option 2 needs to have an API token. I'm going to briefly describe how it works in my application. You can skip this paragraph if you already have it working.

There is a class that implements ITokenProvider interface (PowerBIClient will use it). It acquires tokens from IConfidentialClientApplication (from Microsoft.Identity package) which has client id and client secret of service principal used to authenticate to PowerBI API.

public interface IPowerBIApiTokenProvider : ITokenProvider { }
public class PowerBIApiTokenProvider : IPowerBIApiTokenProvider{    private readonly IConfidentialClientApplication _confidentialClientApplication;    private readonly IList<string> _powerBiScopes;
    public PowerBIApiTokenProvider(        IList<string> powerBiScopes,        IConfidentialClientApplication confidentialClientApplication)    {        _powerBiScopes = powerBiScopes;        _confidentialClientApplication = confidentialClientApplication;    }
    public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync        CancellationToken cancellationToken)    {        var authenticationResult =            await _confidentialClientApplication                .AcquireTokenForClient(_powerBiScopes)                .ExecuteAsync(cancellationToken);
        return new("Bearer", authenticationResult.AccessToken);    }}
C#

It is registered as a singleton because IConfidentialClientApplication manages a token cache. I hardcoded (and mocked) service principal data. In real life, I would use IOptions interface to set appropriate values.

services.AddSingleton<IPowerBIApiTokenProvider, PowerBIApiTokenProvider>(provider =>{    var azureTenantSpecificUrl =        "https://login.microsoftonline.com/myTenant/";
    var clientApp = ConfidentialClientApplicationBuilder        .Create("service principal client id")        .WithClientSecret("service principal client secret")        .WithAuthority(azureTenantSpecificUrl)        .Build();
    return new(new List<string>()    {        "https://analysis.windows.net/powerbi/api/.default"    }, clientApp);});
C#

Option 1 – Registering PowerBIClient as a singleton

Below you can see how to register PowerBIClient as a singleton in the default ASP.NET Core dependency container. In order to log requests with ILogger we need to create LoggingHttpMessageHandler and LoggingScopeHttpMessageHandler to append them to HTTP handlers pipeline. After that, you can inject IPowerBIClient anywhere in your code.

services.AddSingleton<IPowerBIClient, PowerBIClient>(provider =>{    var logger = provider.GetRequiredService<ILogger<PowerBIClient>>();    var tokenProvider = provider.GetRequiredService<IPowerBIApiTokenProvider>();    var loggingHandler = new LoggingHttpMessageHandler(logger);    var loggingScopeHandler = new LoggingScopeHttpMessageHandler(logger);
    var httpHandler = new HttpClientHandler();
    var client = new PowerBIClient(        new TokenCredentials(tokenProvider),        httpHandler, loggingHandler, loggingScopeHandler);
    return client;});
C#

Under the hood, HttpClientHandler creates an instance of SocketsHttpHandler. Unfortunately, HttpClientHandler stores it in a private field, and as a result, it cannot be accessed. There are some properties in SocketsHttpHandler that may be interesting for us.

One of them is PooledConnectionLifetime which defines the maximum lifetime of connection in a pool. By default, it does not have any limit.

Another property is PooledConnectionIdleTimeout. It describes how long it takes to remove the connection from the pool if it is unused. The default value of this is 2 minutes. Consequently, with default settings, if the given connection is used often (more than once per 2 minutes) it will never be recreated. In some scenarios, this may lead to problems with DNS.

In order to fix that we need to use reflection. First of all, let’s create an extension method on HttpClientHandler class that will return a current reference to SocketsHttpHandler.

public static class HttpClientHandlerExtensions{    public static SocketsHttpHandler GetUnderlayingSocketsHttpHandler(        this HttpClientHandler handler)    {        var field = typeof(HttpClientHandler)            .GetFields(BindingFlags.NonPublic | BindingFlags.Instance)            .SingleOrDefault(x => x.FieldType == typeof(SocketsHttpHandler));
        return field?.GetValue(handler) as SocketsHttpHandler;    }}
C#

Then we can modify PowerBIClient factory method. We additionally log an error if for some reason we are not able to get SocketsHttpHandler. It may happen if the internal implementation of HttpClientHandler will change. We set PooledConnectionLifetime to 60 minutes so after this timespan connection will be replaced unless there is a request in progress.

services.AddSingleton<IPowerBIClient, PowerBIClient>(provider =>{    var logger = provider.GetRequiredService<ILogger<PowerBIClient>>();    var tokenProvider = provider.GetRequiredService<IPowerBIApiTokenProvider>();    var loggingHandler = new LoggingHttpMessageHandler(logger);    var loggingScopeHandler = new LoggingScopeHttpMessageHandler(logger);
    var httpHandler = new HttpClientHandler();
    var socketHandler = httpHandler.GetUnderlayingSocketsHttpHandler();
    if (socketHandler == null)    {        logger.LogError(            "SocketsHandler for PowerBIClient is null and has not been configured.");    }    else    {        socketHandler.PooledConnectionLifetime = TimeSpan.FromMinutes(60);    }
    var client = new PowerBIClient(        new TokenCredentials(tokenProvider),        httpHandler, loggingHandler, loggingScopeHandler);
    return client;});
C#

Option 2 – Overriding internal HttpClient

This solution is quite tricky. We need to create a class that derives from PowerBIClient. Thanks to that, we have access to HttpClient which has a protected setter. Then in the constructor, we are able to set our own HttpClient. In our case, this is created by IHttpClientFactory. Unfortunately, we invoke the base constructor first – this is how C# works. It creates HttpClient instance and HttpClientHandler (plus few other delegating handlers). Thus, we need to dispose of them and delete every reference to them. Thanks to that, the garbage collector will get rid of it. Because PowerBIClientWrapper will not have a chance to use these handlers, the connections will not be created in the pool.

public class PowerBIClientWrapper : PowerBIClient{    public PowerBIClientWrapper(        ServiceClientCredentials credentials,        HttpClient httpClient) : base(credentials)    {        HttpClient?.Dispose();        FirstMessageHandler?.Dispose();        HttpClientHandler?.Dispose();
        FirstMessageHandler = null;        HttpClientHandler = null;
        HttpClient = httpClient;    }}
C#

After that, we need to register HttpClient that will be used by PowerBIClient internally. By default, PowerBIClient uses RetryAfterDelegatingHandler and RetryDelegatingHandler as delegating handlers. The request may fail if there are too many concurrent requests to PowerBI API at a given moment. In this solution, you can define your own retry policy. For simplicity let’s try to replicate default behavior.

services.AddHttpClient(nameof(PowerBIClientWrapper))    .ConfigurePrimaryHttpMessageHandler(() =>    {        var delegatingHandler = new RetryAfterDelegatingHandler        {            InnerHandler = new HttpClientHandler()        };
        return new RetryDelegatingHandler(delegatingHandler);    });
C#

Then we can register PowerBIClientWrapper as scoped so a new instance will be created for every request. It’s ok for us since now we create HttpClient with IHttpClientFactory. Because PowerBIClientWrapper implements the IPowerBIClient interface, it can be registered under it. Logging will work out of the box.

services.AddScoped<IPowerBIClient>(provider =>{    var tokenProvider = provider.GetRequiredService<IPowerBIApiTokenProvider>();    var httpClientFactory = provider.GetRequiredService<IHttpClientFactory>();    var httpClient = httpClientFactory.CreateClient(nameof(PowerBIClientWrapper));    var client = new PowerBIClientWrapper(new TokenCredentials(tokenProvider), httpClient);    return client;});
C#

Although IPowerBIClient and created HttpClient are disposed of after each request the underlaying HTTP handlers are not. That’s because handlers used by HttpClient are not referenced by PowerBIClientWrapper. Furthermore, during HttpClient disposal, these handlers are not touched.

Conclusion

Both options have their drawbacks. Registering PowerBIClient as a singleton requires reflection to configure SocketsHttpHandler. However, it’s a slightly better solution than disposing of already existing HttpClient to replace it with the new one.

Both option one and two resolves the socket exhaustion problem. It’s up to you which one you choose. That would be great if there were a constructor that takes HttpClient in PowerBIClient. I hope that Microsoft will add that eventually.

Comments

Add comment
The comments system is based on GitHub Issues API. Once you add your comment to the linked issue on my GitHub repository, it will show up below.
© brokul.dev 2024