I recently ran into an issue using HttpClient in ASP.NET Core and the built-in Dependency Injection system. This post aims to highlight what I found, what I believe the issue was, and how I was able to get my app working again.

Introduction

I recently shipped a new application to solve a very generic problem for my applications. The generic problem is a cross-cutting concern for a lot of the applications we manage on my team, but we’ve always solved the problem in isolation within the applications. It’s sort of like we had multiple teams that all had the same problem at the same time and came up with indepdendent solutions. I’m not clear on the history, but I see the artifact in our source control system.

This new application provides a very generic solution for around 90% of our use-cases. The functionality is exposed in two parts:

  • An ASP.NET Web API application that provides a more generic solution (for the 90% of use-cases that are easy) as a service
  • A managed client that we update with the application

The second part is sort of like an “SDK” in that we provide an API surface to call the Web API, which consuming applications obtain via a private NuGet feed hosted in Azure Artifacts. The client project that we ship targets the netstandard2.0 Target Framework Monioker (TFM), which makes it accessible to the majority of our related applications. We expose this primarily with an interface and default implementation:

using System.Net.Http;

public interface IClientService {
    
}

public class HttpClientService : IClientService {
    public HttpClientService(HttpClient httpClient) {
        this.HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    public HttpClient HttpClient = httpClient;
}

Pretty straight forward, right? Right. Users of the client project just need to figure out how to configure an HttpClient instance, and then provide it to our service class, which will:

  • Handle mapping of actions to routes
  • Translate a CLR-method to an HTTP request
  • Map the results

All of the mapping and translation is manually written code today, because we don’t have much need for anything more than that. We could have used Refit, and that would have worked well, but honestly, there’s just not that much code, and I didn’t feel it was worth taking a dependency. Perhaps in the future we will adopt it, but for where we’re at right now, we don’t mind living with a tiny bit of code for a small service.

I get the great pleasure of being the one to build all of this stuff, but part of that means I also have to dogfood most of it. I ran into the first issue when I tried to integrate with the first existing application in a real-world use-case.

The Problem

My application, which is currently running some very important workloads in our production system, targets the netcoreapp3.1 TFM. This has implications for the SDKs that get pulled in by the compiler, and the Intermediate Language (IL) that gets generated as a result. Once I added the NuGet package reference for this project, I put the following code in my projects’ startup code:

// GetServiceUri is a helper that obtains a System.Uri from
// IConfiguration, which is initialized by the ASP.NET Core Runtime.
var serviceUri = GetServiceUri("ClientServiceUri");

services.AddHttpClient<IClientService, HttpClientService>();
services.AddTransient<IClientService, HttpClientService>();

HttpClientService is a “typed” HttpClient, and calling AddHttpClient with the type arguments allows the runtime to perform any magic it wants about generation of HttpClient instances, along with any lifetime management it chooses to do. You can learn more about typed clients in the documentation, and more about some pitfalls of using HttpClient in You’re Using HttpClient Wrong and it is Destabilizing Your Software. This was all great, until I tried to run my application, and I received an exception with the message “Unable to resolve service for type ‘System.Net.Http.HttpClient’”. The project is using the built-in Dependency Injection that comes pre-packaged in ASP.NET Core projects.

I wrestled with this for quite a while, and tried:

  • Reading Stack Overflow answers for any issues that appeared to be the same or related. I ran across one answer for View Components
  • Changing my startup code to match certain usage patterns in the docs, including removing the interfaces altogether
  • Viewing the registration code and dependencies using ILSpy

What wound up working for me was using ILSpy. You can look at decompiled C# from IL, but that’s usually fraught with errors because decompiled C# is a projected version of the code based on the IL, which means it will perform certain namespace imports (usings) and what not automatically, and you won’t necessarily see anything different than what you see in your editor.

I fortunately had one other dependency that was using HttpClient, and that dependency is fairly stable… I’ve been shipping it for a number of releases, and it’s been reliably serving production traffic for about six months, which means that the configuration, registration, and execution of that code is all working. I therefore had a great baseline and reference implementation within my own project to compare to! I did just that.

My existing dependency follows essentially the same pattern as the new HttpClientService dependency that I was importing, but had one major difference: my existing dependency was in the same solution as the project I was having troubles in, and the project it was defined in also targeted the netcoreapp3.1 TFM. I spend the majority of my time in this project, and I was very keenly aware of the differences of TFMs between the various projects (it was at the forefront of my mind as I was working through the issue). After looking through the IL for my existing, working dependency, and my currently broken new dependency, I saw that their constructors were different when they referenced the HttpClient class.

Here’s a sample of some IL that is similar to the IL from the working code:

.class public auto ansi beforefieldinit MyService
	extends [System.Runtime]System.Object
{
	// Methods
	.method public hidebysig specialname rtspecialname 
		instance void .ctor (
			class [System.Net.Http]System.Net.Http.HttpClient httpClient
		) cil managed 
	{
		// Method begins at RVA 0x2ace
		// Code size 16 (0x10)
		.maxstack 8
		// {
		IL_000f: ret
	} // end of method MyService::.ctor
}

The interesting part of the code is in the httpClient reference. Note that it is qualified as [System.Net.Http]System.Net.Http.HttpClient. In the other service, the reference was typed as [netstandard]System.Net.Http.HttpClient. I suspect the problem is with the type of the HttpClient, specifically the module where it is defined ([netstandard] versus [System.Net.HttpClient]), and when the IServiceProvider implementation (which is built from an IServiceCollection) attempts to resolve an instance of the HttpClient class, the module where HttpClient is defined does not match between the NuGet package and the project being built.

Fortunately for us, the workaround is pretty simple. Just update the startup code to the following:

services.AddHttpClient(nameof(MyService));
services.AddTransient(provider =>
{
	var factory = provider.GetRequiredService<IHttpClientFactory>();
	var httpClient = factory.CreateClient(nameof(MyService));
	return new MyService(httpClient);
})

This will register a custom client, which can optionally be configured with things like redirect policies, authentication schemes, and other details that you’d like to abstract from the consuming code. All of the type info gets resolved at compile-time, and you shouldn’t experience any issues at runtime.

Closing Thoughts

The .NET ecosystem does a lot of work for us on things like multi-targeting, delivering a consistent experience between legacy .NET Framework applications, and targeting the new cross-platform frameworks, but we still have some work to do as developers, which isn’t always obvious as we make choices regarding which versions of the framework and runtime to choose. They’re often surprising, and can add many hours to our development cycles. We can also mitigate a certain amount of these issues through good design. I hope you don’t need the content from this post, but if you do, I hope my article helps you solve it.

Cheers!

- Brian