.NET 8 Networking Improvements

Máňa

Natalia Kondratyeva

It has become a tradition to publish a blog post about new interesting changes in networking space with new .NET release. This year, we’d like to introduce changes in HTTP space, newly added metrics, new HttpClientFactory APIs and more.

HTTP

Metrics

.NET 8 adds built-in HTTP Metrics to both ASP.NET Core and HttpClient using the System.Diagnostics.Metrics API that was introduced in .NET 6. Both the Metrics APIs and the semantics of the new built-in metrics were designed in close cooperation with OpenTelemetry, making sure the new metrics are consistent with the standard and work well with popular tools like Prometheus and Grafana.

The System.Diagnostics.Metrics API introduces many new features which were missing from the EventCounters. These features are utilized extensively by the new built-in metrics, resulting in a wider functionality achieved by a simpler and more elegant set of instruments. To list a couple of examples:

  • Histograms enable us to report the durations, eg. the request duration (http.client.request.duration) or the connection duration (http.client.connection.duration). These are new metrics without EventCounter counterparts.
  • Multi-dimensionality allows us to attach tags (a.k.a. attributes or labels) to measurements, meaning that we can report information like server.address (identifies the URI origin), or error.type (describes the error reason if a request fails) together with the measurements. Multi-dimensionality also enables simplification: to report the number of open HTTP connections SocketsHttpHandler uses 3 EventCounters: http11-connections-current-total, http20-connections-current-total and http30-connections-current-total, while the Metrics equivalent of these counters is a single instrument, http.client.open_connections where the HTTP version is reported using the network.protocol.version tag.
  • To help use cases where the built in tags aren’t sufficient to categorize outgoing HTTP requests, the http.client.request.duration metric supports the injection of user-defined tags. This is called enrichment.
  • IMeterFactory integration enables the isolation of the Meter instances used to emit the HTTP metrics, making it easier to write tests that run validation against the built-in measurements, and enabling the parallel execution of such tests.
  • While this is not specific to the built-in networking metrics, it’s worth mentioning that the collection APIs in System.Diagnostics.Metrics are also more advanced: they are strongly typed and more performant and allow multiple simultaneous listeners and listener access to unaggregated measurements.

These advantages together result in better, richer metrics which can be collected more efficiently by 3rd party tools like Prometheus. Thanks to the flexibility of PromQL (Prometheus Query Language), which allows creating sophisticated queries against the multi-dimensional metrics collected from the .NET networking stack, users can now get insights about the status and health of HttpClient and SocketsHttpHandler instances on a level that was not previously possible.

On the downside, we should mention that only the System.Net.Http and the System.Net.NameResolution components are instrumented using System.Diagnostics.Metrics in .NET 8, meaning that you still need to use EventCounters to extract counters from the lower levels of the stack such as System.Net.Sockets. While all built-in EventCounters which existed in previous versions are still supported, the .NET team doesn’t expect to make substantial new investments into EventCounters, and new built-in instrumentation will be added using System.Diagnostics.Metrics in future versions.

For more information about using built-in HTTP metrics, please read our tutorial on Networking metrics in .NET. It includes examples about collection and reporting using Prometheus and Grafana, and also demonstrates how to enrich and test built-in HTTP metrics. For a comprehensive list of built-in instruments see the docs for System.Net metrics. In case you are more interested about the server-side, please read the docs on ASP.NET Core metrics.

Extended Telemetry

Apart from the new metrics, the existing EventSource based telemetry events introduced in .NET 5 were augmented with more information about HTTP connections (dotnet/runtime#88853):

- ConnectionEstablished(byte versionMajor, byte versionMinor)
+ ConnectionEstablished(byte versionMajor, byte versionMinor, long connectionId, string scheme, string host, int port, string? remoteAddress)

- ConnectionClosed(byte versionMajor, byte versionMinor)
+ ConnectionClosed(byte versionMajor, byte versionMinor, long connectionId)

- RequestHeadersStart()
+ RequestHeadersStart(long connectionId)

Now, when a new connection is established, the event logs its connectionId together with its scheme, port, and peer IP address. This enables correlating requests and responses with connections via the RequestHeadersStart event – which occurs when a request is associated to a pooled connection and starts being processed – which also logs the associated connectionId. This is especially valuable in diagnostic scenarios where users want to see the IP addresses of the servers their HTTP requests are served by, which was the main motivation behind the addition (dotnet/runtime#63159).

The events can be consumed in many ways, see Networking telemetry in .NET – Events. But for the sake of in-process enhanced logging, a custom EventListener can be used to correlate the request/response pair with the connection data:

using IPLoggingListener ipLoggingListener = new();
using HttpClient client = new();

// Send requests in parallel.
await Parallel.ForAsync(0, 1000, async (i, ct) =>
{
    // Initialize the async local so that it can be populated by "RequestHeadersStart" event handler.
    RequestInfo info = RequestInfo.Current;
    using var response = await client.GetAsync("https://testserver");
    Console.WriteLine($"Response {response.StatusCode} handled by connection {info.ConnectionId}. Remote IP: {info.RemoteAddress}");

    // Process response...
});

internal sealed class RequestInfo
{
    private static readonly AsyncLocal<RequestInfo> _asyncLocal = new();
    public static RequestInfo Current => _asyncLocal.Value ??= new();

    public string? RemoteAddress;
    public long ConnectionId;
}

internal sealed class IPLoggingListener : EventListener
{
    private static readonly ConcurrentDictionary<long, string> s_connection2Endpoint = new ConcurrentDictionary<long, string>();

    // EventId corresponds to [Event(eventId)] attribute argument and the payload indices correspond to the event method argument order.

    // See: https://github.com/dotnet/runtime/blob/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8/src/libraries/System.Net.Http/src/System/Net/Http/HttpTelemetry.cs#L100-L101
    private const int ConnectionEstablished_EventId = 4;
    private const int ConnectionEstablished_ConnectionIdIndex = 2;
    private const int ConnectionEstablished_RemoteAddressIndex = 6;

    // See: https://github.com/dotnet/runtime/blob/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8/src/libraries/System.Net.Http/src/System/Net/Http/HttpTelemetry.cs#L106-L107
    private const int ConnectionClosed_EventId = 5;
    private const int ConnectionClosed_ConnectionIdIndex = 2;

    // See: https://github.com/dotnet/runtime/blob/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8/src/libraries/System.Net.Http/src/System/Net/Http/HttpTelemetry.cs#L118-L119
    private const int RequestHeadersStart_EventId = 7;
    private const int RequestHeadersStart_ConnectionIdIndex = 0;

    protected override void OnEventSourceCreated(EventSource eventSource)
    {
        if (eventSource.Name == "System.Net.Http")
        {
            EnableEvents(eventSource, EventLevel.LogAlways);
        }
    }

    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        ReadOnlyCollection<object?>? payload = eventData.Payload;
        if (payload == null) return;

        switch (eventData.EventId)
        {
            case ConnectionEstablished_EventId:
                // Remember the connection data.
                long connectionId = (long)payload[ConnectionEstablished_ConnectionIdIndex]!;
                string? remoteAddress = (string?)payload[ConnectionEstablished_RemoteAddressIndex];
                if (remoteAddress != null)
                {
                    Console.WriteLine($"Connection {connectionId} established to {remoteAddress}");
                    s_connection2Endpoint.TryAdd(connectionId, remoteAddress);
                }
                break;
            case ConnectionClosed_EventId:
                connectionId = (long)payload[ConnectionClosed_ConnectionIdIndex]!;
                s_connection2Endpoint.TryRemove(connectionId, out _);
                break;
            case RequestHeadersStart_EventId:
                // Populate the async local RequestInfo with data from "ConnectionEstablished" event.
                connectionId = (long)payload[RequestHeadersStart_ConnectionIdIndex]!;
                if (s_connection2Endpoint.TryGetValue(connectionId, out remoteAddress))
                {
                    RequestInfo.Current.RemoteAddress = remoteAddress;
                    RequestInfo.Current.ConnectionId = connectionId;
                }
                break;
        }
    }
}

Additionally, the Redirect event has been extended to include the redirect URI:

-void Redirect();
+void Redirect(string redirectUri);

HTTP Error Codes

One of the diagnostics problems of HttpClient was that in case of an exception it was not easy to distinguish programmatically the exact root cause for the error. The only way to differentiate many of these was to parse the exception message from HttpRequestException. Moreover, other HTTP implementations, like WinHTTP with ERROR_WINHTTP_* error codes, offer such functionality in the form of either numerical codes or enumerations. So .NET 8 introduces a similar enumeration and provides it in exceptions thrown from HTTP processing, which are:

The design of HttpRequestError enum and how it’s plugged into HTTP exceptions is described in dotnet/runtime#76644 API proposal.

Now, a consumer of an HttpClient methods can handle specific internal errors much more easily and reliably:

using HttpClient httpClient = new();

// Handling problems with the server:
try
{
    using HttpResponseMessage response = await httpClient.GetAsync("https://testserver", HttpCompletionOption.ResponseHeadersRead);
    using Stream responseStream = await response.Content.ReadAsStreamAsync();
    // Process responseStream ...
}
catch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.NameResolutionError)
{
    Console.WriteLine($"Unknown host: {e}");
    // --> Try different hostname.
}
catch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.ConnectionError)
{
    Console.WriteLine($"Server unreachable: {e}");
    // --> Try different server.
}
catch (HttpIOException e) when (e.HttpRequestError == HttpRequestError.InvalidResponse)
{
    Console.WriteLine($"Mangled responses: {e}");
    // --> Block list server.
}

// Handling problems with HTTP version selection:
try
{
    using HttpResponseMessage response = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://testserver")
    {
        Version = HttpVersion.Version20,
        VersionPolicy = HttpVersionPolicy.RequestVersionExact
    }, HttpCompletionOption.ResponseHeadersRead);
    using Stream responseStream = await response.Content.ReadAsStreamAsync();
    // Process responseStream ...
}
catch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.VersionNegotiationError)
{
    Console.WriteLine($"HTTP version is not supported: {e}");
    // Try with different HTTP version.
}

HTTPS Proxy Support

One of the highly requested features that got implemented with this release is support for HTTPS proxies (dotnet/runtime#31113). It’s possible now to use proxies serving requests over HTTPS, meaning the connection to the proxy is secure. That doesn’t say anything about the request itself from the proxy, which can still be both HTTP or HTTPS. In case of a plain text HTTP request, the connection to an HTTPS proxy is secure (over HTTPS), followed by plain text request from proxy to the destination. And in case of an HTTPS request (proxy tunnel), the initial CONNECT request to open the tunnel will be sent over secured channel (HTTPS) to the proxy, followed by the HTTPS request from the proxy to the destination through the tunnel.

To take advantage of the feature, all that’s needed is to use an HTTPS scheme when setting up the proxy:

using HttpClient client = new HttpClient(new SocketsHttpHandler()
{
    Proxy = new WebProxy("https://proxy.address:12345")
});

using HttpResponseMessage response = await client.GetAsync("https://httpbin.org/");

HttpClientFactory

.NET 8 extends the ways in which you can configure HttpClientFactory, including client defaults, custom logging, and simplified SocketsHttpHandler configuration. The APIs are implemented in the Microsoft.Extensions.Http package which is available on NuGet and includes support for .NET Standard 2.0. Therefore, this functionality is usable for customers not just on .NET 8, but on all versions of .NET, including .NET Framework (the only exception are the SocketsHttpHandler related APIs that are only available for .NET 5+).

Set Up Defaults For All Clients

.NET 8 adds the ability to set default configuration that would be used for all HttpClients created by HttpClientFactory (dotnet/runtime#87914). This is useful when all or most of the registered clients contain the same subset of the configuration.

Consider an example where two named clients are defined, and they both need MyAuthHandler in their message handlers chain.

services.AddHttpClient("consoto", c => c.BaseAddress = new Uri("https://consoto.com/"))
    .AddHttpMessageHandler<MyAuthHandler>();

services.AddHttpClient("github", c => c.BaseAddress = new Uri("https://github.com/"))
    .AddHttpMessageHandler<MyAuthHandler>();

To extract the common part, you can now use the ConfigureHttpClientDefaults method:

services.ConfigureHttpClientDefaults(b => b.AddHttpMessageHandler<MyAuthHandler>());

// both clients will have MyAuthHandler added by default
services.AddHttpClient("consoto", c => c.BaseAddress = new Uri("https://consoto.com/"));
services.AddHttpClient("github", c => c.BaseAddress = new Uri("https://github.com/"));

All IHttpClientBuilder extension methods that are used with AddHttpClient, can be used within ConfigureHttpClientDefaults as well.

The default configuration (ConfigureHttpClientDefaults) is applied to all clients before the client-specific (AddHttpClient) configurations; their relative position in the registration doesn’t matter. ConfigureHttpClientDefaults can be registered multiple times, in this case the configurations will be applied one-by-one in the order of registration. Any part of the configuration can be overridden or modified in the client-specific configurations, for example, you can set additional settings to HttpClient object or to a primary handler, remove previously added additional handler, etc.

Note that since 8.0, ConfigureHttpMessageHandlerBuilder method is deprecated. You should use ConfigurePrimaryHttpMessageHandler(Action<HttpMessageHandler,IServiceProvider>))) or ConfigureAdditionalHttpMessageHandlers methods instead, to modify a previously configured primary handler or a list of additional handlers respectively.

// by default, adds User-Agent header, uses HttpClientHandler with UseCookies=false
// as a primary handler, and adds MyAuthHandler to all clients
services.ConfigureHttpClientDefaults(b =>
    b.ConfigureHttpClient(c => c.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClient/8.0"))
     .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { UseCookies = false })
     .AddHttpMessageHandler<MyAuthHandler>());

// HttpClient will have both User-Agent (from defaults) and BaseAddress set
// + client will have UseCookies=false and MyAuthHandler from defaults
services.AddHttpClient("modify-http-client", c => c.BaseAddress = new Uri("https://httpbin.org/"))

// primary handler will have both UseCookies=false (from defaults) and MaxConnectionsPerServer set
// + client will have User-Agent and MyAuthHandler from defaults
services.AddHttpClient("modify-primary-handler")
    .ConfigurePrimaryHandler((h, _) => ((HttpClientHandler)h).MaxConnectionsPerServer = 1);

// MyWrappingHandler will be inserted at the top of the handlers chain
// + client will have User-Agent, UseCookies=false and MyAuthHandler from defaults
services.AddHttpClient("insert-handler-into-chain"))
    .ConfigureAdditionalHttpMessageHandlers((handlers, _) =>
        handlers.Insert(0, new MyWrappingHandler());

// MyAuthHandler (initially from defaults) will be removed from the handler chain
// + client will still have User-Agent and UseCookies=false from defaults
services.AddHttpClient("remove-handler-from-chain"))
    .ConfigureAdditionalHttpMessageHandlers((handlers, _) =>
        handlers.Remove(handlers.Single(h => h is MyAuthHandler)));

Modify HttpClient Logging

Customizing (or even simply turning off) HttpClientFactory logging was one of the long-requested features (dotnet/runtime#77312).

Old Logging Overview

Default (“old”) logging added by HttpClientFactory is quite verbose and emits 8 log messages per request:

  1. Start notification with request URI — before propagating through the delegating handler pipeline;
  2. Request headers — before handler pipeline;
  3. Start notification with request URI — after handler pipeline;
  4. Request headers — after handler pipeline;
  5. Stop notification with elapsed time — before propagating the response back through the delegating handler pipeline;
  6. Response headers — before propagating the response back;
  7. Stop notification with elapsed time — after propagating the response back;
  8. Response headers — after propagating the response back.

This can be illustrated with the diagram below. In this and the following diagrams, * and [...] denote a logging event (in the default implementation, a log message being written into ILogger), and --> symbolizes data flow through the Application and Transport layers.

  Request -->
*   [Start notification]    // "Start processing HTTP request ..." (1)
*   [Request headers]       // "Request Headers: ..." (2)
      --> Additional Handler #1 -->
        --> .... -->
          --> Additional Handler #N -->
*           [Start notification]    // "Sending HTTP request ..." (3)
*           [Request headers]       // "Request Headers: ..." (4)
                --> Primary Handler -->
                      --------Transport--layer------->
                                          // Server sends response
                      <-------Transport--layer--------
                <-- Primary Handler <--
*           [Stop notification]    // "Received HTTP response ..." (5)
*           [Response headers]     // "Response Headers: ..." (6)
          <-- Additional Handler #N <--
        <-- .... <--
      <-- Additional Handler #1 <--
*   [Stop notification]    // "End processing HTTP request ..." (7)
*   [Response headers]     // "Response Headers: ..." (8)
  Response <--

Console output of the default HttpClientFactory logging looks like this:

var client = _httpClientFactory.CreateClient();
await client.GetAsync("https://httpbin.org/get");
info: System.Net.Http.HttpClient.test.LogicalHandler[100]
      Start processing HTTP request GET https://httpbin.org/get
trce: System.Net.Http.HttpClient.test.LogicalHandler[102]
      Request Headers:
      ....
info: System.Net.Http.HttpClient.test.ClientHandler[100]
      Sending HTTP request GET https://httpbin.org/get
trce: System.Net.Http.HttpClient.test.ClientHandler[102]
      Request Headers:
      ....
info: System.Net.Http.HttpClient.test.ClientHandler[101]
      Received HTTP response headers after 581.2898ms - 200
trce: System.Net.Http.HttpClient.test.ClientHandler[103]
      Response Headers:
      ....
info: System.Net.Http.HttpClient.test.LogicalHandler[101]
      End processing HTTP request after 618.9736ms - 200
trce: System.Net.Http.HttpClient.test.LogicalHandler[103]
      Response Headers:
      ....

Note that in order to see Trace level messages, you need to opt-in to that in the global logging configuration file or via SetMinimumLevel(LogLevel.Trace). But even considering only Informational messages, “old” logging still has 4 messages per request.

To remove the default (or previously added) logging, you can use the new RemoveAllLoggers() extension method. It is especially powerful combined with the ConfigureHttpClientDefaults API described in the Set Up Defaults For All Clients section above. This way, you can remove the “old” logging for all of the clients in one line:

services.ConfigureHttpClientDefaults(b => b.RemoveAllLoggers()); // remove HttpClientFactory default logging for all clients

If you ever need to bring back the “old” logging, e.g. for a specific client, you can do so using AddDefaultLogger().

Add Custom Logging

In addition to the ability to remove the “old” logging, new HttpClientFactory APIs also allow you to fully customize the logging. You can specify what and how will be logged when HttpClient starts a request, receives a response or throws an exception.

You can add several custom loggers alongside, if you choose to do so — for example, both Console and ETW loggers, or both “wrapping” and “not wrapping” loggers. Because of its additive nature, you might need to explicitly remove the default “old” logging beforehand.

To add custom logging, you need to implement the IHttpClientLogger interface and then add the custom logger to your client with AddLogger. Note that logging implementation should not throw any Exceptions, otherwise it can disrupt the request execution.

Registration:

services.AddSingleton<SimpleConsoleLogger>(); // register the logger in DI

services.AddHttpClient("foo") // add a client
    .RemoveAllLoggers() // remove previous logging
    .AddLogger<SimpleConsoleLogger>(); // add the custom logger

Sample logger implementation:

// outputs one line per request to console
public class SimpleConsoleLogger : IHttpClientLogger
{
    public object? LogRequestStart(HttpRequestMessage request) => null;

    public void LogRequestStop(object? ctx, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed)
        => Console.WriteLine($"{request.Method} {request.RequestUri?.AbsoluteUri} - {(int)response.StatusCode} {response.StatusCode} in {elapsed.TotalMilliseconds}ms");

    public void LogRequestFailed(object? ctx, HttpRequestMessage request, HttpResponseMessage? response, Exception e, TimeSpan elapsed)
        => Console.WriteLine($"{request.Method} {request.RequestUri?.AbsoluteUri} - Exception {e.GetType().FullName}: {e.Message}");
}

Sample output:

var client = _httpClientFactory.CreateClient("foo");
await client.GetAsync("https://httpbin.org/get");
await client.PostAsync("https://httpbin.org/post", new ByteArrayContent(new byte[] { 42 }));
await client.GetAsync("http://httpbin.org/status/500");
await client.GetAsync("http://localhost:1234");
GET https://httpbin.org/get - 200 OK in 393.2039ms
POST https://httpbin.org/post - 200 OK in 95.524ms
GET https://httpbin.org/status/500 - 500 InternalServerError in 99.5025ms
GET http://localhost:1234/ - Exception System.Net.Http.HttpRequestException: No connection could be made because the target machine actively refused it. (localhost:1234)

Request Context Object

A context object can be used to match the LogRequestStart call with the corresponding LogRequestStop call to pass data from one to the other. Context object is produced by LogRequestStart and then passed back to LogRequestStop. This can be a property bag or any other object that holds the necessary data.

If a context object is not needed, implementation can return null from LogRequestStart.

The following example shows how context object can be used to pass a custom request identifier.

public class RequestIdLogger : IHttpClientLogger
{
    private readonly ILogger _log;

    public RequestIdLogger(ILogger<RequestIdLogger> log)
    {
        _log = log;
    }

    private static readonly Action<ILogger, Guid, string?, Exception?> _requestStart =
        LoggerMessage.Define<Guid, string?>(
            LogLevel.Information,
            EventIds.RequestStart,
            "Request Id={RequestId} ({Host}) started");

    private static readonly Action<ILogger, Guid, double, Exception?> _requestStop =
        LoggerMessage.Define<Guid, double>(
            LogLevel.Information,
            EventIds.RequestStop,
            "Request Id={RequestId} succeeded in {elapsed}ms");

    private static readonly Action<ILogger, Guid, Exception?> _requestFailed =
        LoggerMessage.Define<Guid>(
            LogLevel.Error,
            EventIds.RequestFailed,
            "Request Id={RequestId} FAILED");

    public object? LogRequestStart(HttpRequestMessage request)
    {
        var ctx = new Context(Guid.NewGuid());
        _requestStart(_log, ctx.RequestId, request.RequestUri?.Host, null);
        return ctx;
    }

    public void LogRequestStop(object? ctx, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed)
        => _requestStop(_log, ((Context)ctx!).RequestId, elapsed.TotalMilliseconds, null);

    public void LogRequestFailed(object? ctx, HttpRequestMessage request, HttpResponseMessage? response, Exception e, TimeSpan elapsed)
        => _requestFailed(_log, ((Context)ctx!).RequestId, null);

    public static class EventIds
    {
        public static readonly EventId RequestStart = new(1, "RequestStart");
        public static readonly EventId RequestStop = new(2, "RequestStop");
        public static readonly EventId RequestFailed = new(3, "RequestFailed");
    }

    record Context(Guid RequestId);
}
info: RequestIdLogger[1]
      Request Id=d0d63b84-cd67-4d21-ae9a-b63d26dfde50 (httpbin.org) started
info: RequestIdLogger[2]
      Request Id=d0d63b84-cd67-4d21-ae9a-b63d26dfde50 succeeded in 530.1664ms
info: RequestIdLogger[1]
      Request Id=09403213-dd3a-4101-88e8-db8ab19e1eeb (httpbin.org) started
info: RequestIdLogger[2]
      Request Id=09403213-dd3a-4101-88e8-db8ab19e1eeb succeeded in 83.2484ms
info: RequestIdLogger[1]
      Request Id=254e49bd-f640-4c56-b62f-5de678eca129 (httpbin.org) started
info: RequestIdLogger[2]
      Request Id=254e49bd-f640-4c56-b62f-5de678eca129 succeeded in 162.7776ms
info: RequestIdLogger[1]
      Request Id=e25ccb08-b97e-400d-b42b-b09d6c42adec (localhost) started
fail: RequestIdLogger[3]
      Request Id=e25ccb08-b97e-400d-b42b-b09d6c42adec FAILED

Avoid Reading From Content Streams

If you intend to read and log, for example, request and response Content, please be aware that it can potentially have an adverse side effect on the end user experience and cause bugs. For example, request content might become consumed before it was sent, or response content of a huge size might end up being buffered in memory. In addition, before .NET 7, accessing headers was not thread-safe and might lead to errors and unexpected behavior.

Use Async Logging With Caution

We expect that the synchronous IHttpClientLogger interface would be suitable for the vast majority of the custom logging use cases. It is advised to refrain from using async in logging for performance reasons. However, in case async access within the logging is strictly required, you can implement the asynchronous version IHttpClientAsyncLogger. It derives from IHttpClientLogger, so it can use the same AddLogger API for registration.

Note that in that case sync counterparts of the logging methods should be implemented as well, especially if the implementation is a part of a library targeting .NET Standard or .NET 5+. Sync counterparts are called from sync HttpClient.Send methods; even if .NET Standard surface doesn’t include them, .NET Standard library can be used in a .NET 5+ application, so the end users would have the access to sync HttpClient.Send methods.

Wrapping And Not Wrapping Loggers

When you add the logger, you may explicitly set wrapHandlersPipeline parameter to specify whether the logger would be

  • wrapping the handlers pipeline (added to the top of the pipeline, corresponding to the messages no. 1, 2, 7 and 8 in the Old Logging Overview section above)
  Request -->
*   [LogRequestStart()]                // wrapHandlersPipeline=TRUE
      --> Additional Handlers #1..N -->    // handlers pipeline
          --> Primary Handler -->
                --------Transport--layer--------
          <-- Primary Handler <--
      <-- Additional Handlers #N..1 <--    // handlers pipeline
*   [LogRequestStop()]                 // wrapHandlersPipeline=TRUE
  Response <--
  • or, not wrapping the handlers pipeline (added to the bottom, corresponding to the messages no. 3, 4, 5 and 6 in the Old Logging Overview section above).
  Request -->
    --> Additional Handlers #1..N --> // handlers pipeline
*     [LogRequestStart()]             // wrapHandlersPipeline=FALSE
          --> Primary Handler -->
                --------Transport--layer--------
          <-- Primary Handler <--
*     [LogRequestStop()]              // wrapHandlersPipeline=FALSE
    <-- Additional Handlers #N..1 <-- // handlers pipeline
  Response <--

By default, loggers are added as not wrapping.

The difference between wrapping and not wrapping the pipeline is most prominent in case of a retrying handler being added to the pipeline (e.g. Polly or some custom implementation of retries). In that case, a wrapping logger (on the top) would log a message about a single successful request, and the elapsed time logged would be the total time from the user initiating the request to them receiving a response. A non-wrapping logger (on the bottom) would log each of the retry iterations, with first ones potentially logging an exception or an unsuccessful status code, and the last one logging the success. The elapsed time in each case would be time spent purely within the primary handler (the one actually sending the request on the wire, e.g. HttpClientHandler).

This can be illustrated with the following diagrams:

  • Wrapping case (wrapHandlersPipeline=TRUE)
  Request -->
*   [LogRequestStart()]
        --> Additional Handlers #1..(N-1) -->
            --> Retry Handler -->
              --> //1
                  --> Primary Handler -->
                  <-- "503 Service Unavailable" <--
              --> //2
                  --> Primary Handler ->
                  <-- "503 Service Unavailable" <--
              --> //3
                  --> Primary Handler -->
                  <-- "200 OK" <--
            <-- Retry Handler <--
        <-- Additional Handlers #(N-1)..1 <--
*   [LogRequestStop()]
  Response <--
info: Example.CustomLogger.Wrapping[1]
      GET https://consoto.com/
info: Example.CustomLogger.Wrapping[2]
      200 OK - 809.2135ms
  • Not wrapping case (wrapHandlersPipeline=FALSE)
  Request -->
    --> Additional Handlers #1..(N-1) -->
        --> Retry Handler -->
          --> //1
*           [LogRequestStart()]
                --> Primary Handler -->
                <-- "503 Service Unavailable" <--
*           [LogRequestStop()]
          --> //2
*           [LogRequestStart()]
                --> Primary Handler -->
                <-- "503 Service Unavailable" <--
*           [LogRequestStop()]
          --> //3
*           [LogRequestStart()]
                --> Primary Handler -->
                <-- "200 OK" <--
*           [LogRequestStop()]
        <-- Retry Handler <--
    <-- Additional Handlers #(N-1)..1 <--
  Response <--
info: Example.CustomLogger.NotWrapping[1]
      GET https://consoto.com/
info: Example.CustomLogger.NotWrapping[2]
      503 Service Unavailable - 98.613ms
info: Example.CustomLogger.NotWrapping[1]
      GET https://consoto.com/
info: Example.CustomLogger.NotWrapping[2]
      503 Service Unavailable - 96.1932ms
info: Example.CustomLogger.NotWrapping[1]
      GET https://consoto.com/
info: Example.CustomLogger.NotWrapping[2]
      200 OK - 579.2133ms

Simplified SocketsHttpHandler Configuration

.NET 8 adds more convenient and fluent way to use SocketsHttpHandler as a primary handler in HttpClientFactory (dotnet/runtime#84075).

You can set and configure SocketsHttpHandler with the UseSocketsHttpHandler method. You can use IConfiguration to set SocketsHttpHandler properties from a config file, or you can configure it from the code, or you can combine both of the approaches.

Note that, when applying IConfiguration to the SocketsHttpHandler, only properties of SocketsHttpHandler of type bool, int, Enum, or TimeSpan are parsed. All unmatched properties in IConfiguration are ignored. Configuration is parsed only once upon registration and is not reloaded, so the handler will not reflect any config file changes until the application is restarted.

// sets up properties on the handler directly
services.AddHttpClient("foo")
    .UseSocketsHttpHandler((h, _) => h.UseCookies = false);

// uses a builder to combine approaches
services.AddHttpClient("bar")
    .UseSocketsHttpHandler(b =>
        b.Configure(config.GetSection($"HttpClient:bar")) // loads simple properties from config
         .Configure((h, _) => // sets up SslOptions in code
         {
            h.SslOptions.RemoteCertificateValidationCallback = delegate { return true; };
         });
    );
{
  "HttpClient": {
    "bar": {
      "AllowAutoRedirect": true,
      "UseCookies": false,
      "ConnectTimeout": "00:00:05"
    }
  }
}

QUIC

OpenSSL 3 Support

Most of the current Linux distributions adopted OpenSSL 3 in their recent releases:

.NET 8’s QUIC support is ready for that (dotnet/runtime#81801).

The first step to achieve that was to make sure that MsQuic, the QUIC implementation used underneath System.Net.Quic, can work with OpenSSL 3+. This work happened in MsQuic repository microsoft/msquic#2039. The next step was to make sure that libmsquic package is built and published with a corresponding dependency on the default OpenSSL version for the particular distribution and version. For example Debian distribution:

The last step was to make sure that the right versions of MsQuic and OpenSSL are being tested and that the tests have coverage across the breadth of .NET supported distributions.

Exceptions

After publishing QUIC APIs in .NET 7 (as preview feature), we received several issues about exceptions:

In .NET 8, System.Net.Quic exceptions behavior was completely revised in dotnet/runtime#82262 and the above mentioned issues were addressed.

One of the main goals of the revision was to make sure that the exceptions behavior in System.Net.Quic is as consistent as possible across the whole namespace. In general, the current behavior can be summarized as follows:

  • QuicException: all errors specific to the QUIC protocol or related to its processing.
    • Connection closed either locally or by the peer.
    • Connection idled out from inactivity.
    • Stream aborted either locally or by the peer.
    • Other errors described in QuicError
  • SocketException: for network problem such as network conditions, name resolution or user errors.
    • Address is already in use.
    • Target host cannot be reached.
    • Specified address is invalid.
    • Host name cannot be resolved.
  • AuthenticationException: for all TLS related issues. The goal is to have similar behavior as SslStream.
    • Certificate related errors.
    • ALPN negotiation errors.
    • User cancellation during handshake.
  • ArgumentException: when supplied QuicConnectionOptions or QuicListenerOptions are invalid.
  • OperationCanceledException: whenever CancellationToken gets fired cancellation.
  • ObjectDisposedException: whenever calling a method on an already disposed object.

Note that the examples mentioned above are not exhaustive.

Apart from changing the behavior, QuicException was changed as well. One of those changes was adjusting QuicError enum values. Items that are now covered by SocketException were removed and a new value for user callback errors was added (dotnet/runtime#87259). The newly added CallbackError is used to distinguish exceptions thrown by QuicListenerOptions.ConnectionOptionsCallback from System.Net.Quic originating ones (dotnet/runtime#88614). So, if the user code throws for example ArgumentException, QuicListener.AcceptConnectionAsync will wrap it in QuicException with QuicError set to CallbackError and the inner exception will contain the original user-thrown one. It can be used like this:

await using var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
    // ...
    ConnectionOptionsCallback = (con, hello, token) =>
    {
        if (blockedServers.Contains(hello.ServerName))
        {
            throw new ArgumentException($"Connection attempt from forbidden server: '{hello.ServerName}'.", nameof(hello));
        }

        return ValueTask.FromResult(new QuicServerConnectionOptions
        {
            // ...
        });
    },
});
// ...
try
{
    await listener.AcceptConnectionAsync();
}
catch (QuicException ex) when (ex.QuicError == QuicError.CallbackError && ex.InnerException is ArgumentException)
{
    Console.WriteLine($"Blocked connection attempt from forbidden server: {ex.InnerException.Message}");
}

The last change in exception space was adding transport error code into QuicException (dotnet/runtime#88550). Transport error codes are defined by RFC 9000 Transport Error Codes and they were already available to System.Net.Quic from MsQuic, they just weren’t exposed publicly. So a new nullable property was added to QuicException: TransportErrorCode. We’d like to thank a community contributor AlexRadch who implemented this change in dotnet/runtime#88614.

Sockets

The most impactful change done in socket space was significantly lowering allocations for connection-less (UDP) sockets (dotnet/runtime#30797). One of the biggest contributors to allocations when working with UDP sockets was allocating a new EndPoint object (and supporting allocations like IPAddress) on each call of Socket.ReceiveFrom. To mitigate this, a set of new APIs working with SocketAddress instead (dotnet/runtime#87397) was introduced. SocketAddress internally holds the IP address as an array of bytes in the platform dependent form so that it can be passed to the operating system calls directly. Therefore, no copies of the IP address data need to be done before calling native socket functions.

Moreover, the newly added ReceiveFrom-system-net-sockets-socketflags-system-net-socketaddress)) and ReceiveFromAsync-system-net-sockets-socketflags-system-net-socketaddress-system-threading-cancellationtoken)) overloads do not instantiate a new IPEndPoint on each call but rather mutate the provided receivedAddress parameter in place. All this together can be used to make UDP socket code more efficient:

// Same initialization code as before, no change here.
Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
byte[] message = Encoding.UTF8.GetBytes("Hello world!");
byte[] buffer = new byte[1024];
IPEndPoint endpoint = new IPEndPoint(IPAddress.Loopback, 12345);
server.Bind(endpoint);

// --------
// Original code that would allocate IPEndPoint for each ReceiveFromAsync:
Task<SocketReceiveFromResult> receiveTaskOrig = server.ReceiveFromAsync(buffer, SocketFlags.None, endpoint);
await client.SendToAsync(message, SocketFlags.None, endpoint);
SocketReceiveFromResult resultOrig = await receiveTaskOrig;

Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, result.ReceivedBytes) + " from " + result.RemoteEndPoint);
// Prints:
// Hello world! from 127.0.0.1:59769

// --------
// New variables that can be re-used for subsequent calls:
SocketAddress receivedAddress = endpoint.Serialize();
SocketAddress targetAddress = endpoint.Serialize();

// New code that will mutate provided SocketAddress for each ReceiveFromAsync:
ValueTask<int> receiveTaskNew = server.ReceiveFromAsync(buffer, SocketFlags.None, receivedAddress);
await client.SendToAsync(message, SocketFlags.None, targetAddress);
var length = await receiveTaskNew;

Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, length) + " from " + receivedAddress);
// Prints:
// Hello world! from InterNetwork:16:{233,121,127,0,0,1,0,0,0,0,0,0,0,0}

On top of that, work with SocketAddress was improved in dotnet/runtime#86872. SocketAddress now has several additional members that make it more useful on its own:

  • getter Buffer: to access the whole underlying address buffer.
  • setter Size: to be able to adjust the above mentioned buffer size (only to a smaller size).
  • static GetMaximumAddressSize: to get the necessary buffer size based on the address type.
  • interface IEquatable<SocketAddress>: SocketAddress can be used to differentiate peers with whom the socket communicates, for example as a key in a dictionary (this is not a new functionality, it just makes it callable via the interface).

And lastly, some of the internally made copies of the IP address data were removed to make it more performant.

Networking Primitives

MIME Types

Adding missing MIME types was one of the most upvoted issues in the networking space (dotnet/runtime#1489). This was a mostly community-driven change that lead to the dotnet/runtime#85807 API proposal. As this addition needed to go through the API review process, it was necessary to make sure that the added types are relevant and follow the specification (IANA Media Types). For this preparatory work, we’d like to thank community contributors Bilal-io and mmarinchenko.

IPNetwork

Another new API addition in .NET 8 is the new type IPNetwork (dotnet/runtime#79946). The struct allows specifying classless IP sub-networks as defined in RFC 4632. For example:

  • 127.0.0.0/8 for a class-less definition corresponding to a class A subnet.
  • 42.42.128.0/17 for a class-less subnet of 215 addresses.
  • 2a01:110:8012::/100 for IPv6 subnet of 228 addresses.

The new API offers construction either from an IPAddress and prefix length with the constructor or by parsing from string via TryParse or Parse. On top of that, it allows checking if an IPAddress belongs to the subnet with Contains method. Sample usage can look like this:

// IPv4 with manual construction.
IPNetwork ipNet = new IPNetwork(new IPAddress(new byte[] { 127, 0, 0, 0 }), 8);
IPAddress ip1 = new IPAddress(new byte[] { 255, 0, 0, 1 });
IPAddress ip2 = new IPAddress(new byte[] { 127, 0, 0, 10 });
Console.WriteLine($"{ip1} {(ipNet.Contains(ip1) ? "belongs" : "doesn't belong")} to {ipNet}");
Console.WriteLine($"{ip2} {(ipNet.Contains(ip2) ? "belongs" : "doesn't belong")} to {ipNet}");
// Prints:
// 255.0.0.1 doesn't belong to 127.0.0.0/8
// 127.0.0.10 belongs to 127.0.0.0/8

// IPv6 with parsing.
IPNetwork ipNet = IPNetwork.Parse("2a01:110:8012::/96");
IPAddress ip1 = IPAddress.Parse("2a01:110:8012::1742:4244");
IPAddress ip2 = IPAddress.Parse("2a01:110:8012:1010:914e:2451:16ff:ffff");
Console.WriteLine($"{ip1} {(ipNet.Contains(ip1) ? "belongs" : "doesn't belong")} to {ipNet}");
Console.WriteLine($"{ip2} {(ipNet.Contains(ip2) ? "belongs" : "doesn't belong")} to {ipNet}");
// Prints:
// 2a01:110:8012::1742:4244 belongs to 2a01:110:8012::/96
// 2a01:110:8012:1010:914e:2451:16ff:ffff doesn't belong to 2a01:110:8012::/96

Note that this type should not be confused with the Microsoft.AspNetCore.HttpOverrides.IPNetwork class that existed in ASP.NET Core since 1.0. We expect that ASP.NET APIs will eventually migrate to the new System.Net.IPNetwork type (dotnet/aspnetcore#46157).

Final Notes

The topics chosen for this blog post are not an exhaustive list of all changes done in .NET 8, just the ones we think might be the most interesting to read about. If you’re more interested in performance improvements, you should check out networking section in Stephen’s huge performance blog post. And if you have any questions or find any bugs, you can reach out to us in dotnet/runtime repository.

Lastly, I’d like to thank my co-authors:

13 comments

Discussion is closed. Login to edit/delete existing comments.

  • silkfire 0

    Could you add a comment informing readers that the new IPNetwork struct should not be confused with the IPNetwork that’s existed in ASP.NET Core since 1.0?

    • Anton FirszovMicrosoft employee 1

      Good point, will do!

    • Natalia KondratyevaMicrosoft employee 1

      We’ve updated the post. Thanks!

  • John King 0

    what about the Full-Managed-Quic implement ? MsQuic seems be pain dependency on android

    • Natalia KondratyevaMicrosoft employee 0

      It’s not planned for the upcoming release, but that’s definitely a direction where we’d like to eventually go. That’s quite an extensive investment, so the work might span several releases. It would be great to see if there’s enough ask for it — would you mind creating a feature request in our github repo, so that if anyone is interested, they could upvote?

  • Mark Adamson 0

    The logging change feels a bit odd as it feels like it is stepping away from the standard mechanisms of ILogger for managing the verbosity of logs and introducing another system. What’s the thinking behind the different direction?

    • Natalia KondratyevaMicrosoft employee 0

      IHttpClientLogger and ILogger are two different things, they are on different levels. IHttpClientLogger is designed to simplify customizing HttpClient logging in general, not for managing the verbosity, and definitely not to substitute ILogger. IHttpClientLogger is technically a set of callbacks for the specific points of HTTP request processing. You can choose to implement it using ILogger if you wish so — check out the example in the Request Context Object section.

  • Nattapong Nunpan 0

    In .NET 8 there is more way to configure SocketsHttpHandler that’s primary handler, so I have a question.

    What’s happened when we configures both primary handler HttpClientHandler and SocketsHttpHandler something like:

    services.AddHttpClient("primary-handler")) 
        .ConfigurePrimaryHandler(()=> new HttpClientHandler
        {
            UseCookies= false
        })
        .UseSocketsHttpHandler((handler, _) => 
        {
            handler.UseCookies = true;
        };
    • Natalia KondratyevaMicrosoft employee 2

      Thank you for the question! I contemplated adding this to the post, but decided against it, as HttpClientFactory part was big enough already; maybe I was wrong in this assumption 🙂

      Configuration methods follow general DI pattern, where the later methods build on top or overwrite the previous ones. In the case you’ve provided, UseSocketsHttpHandler would overwrite previously added HttpClientHandler with a new SocketsHttpHandler instance, because it needs it to then execute the configuration action (setting UseCookies to true). However, if primary handler was already a SocketsHttpHandler, the configuration method would just execute the configuration action on the existing instance. In this particular case the result would be the same, but if some other property was set in SocketsHttpHandler, then it will be retained (given that you didn’t overwrite the primary handler again later):

      
      // will have both AllowAutoRedirect and UseCookies set to false
      services.AddHttpClient("foo")) 
          .ConfigurePrimaryHandler(() => new SocketsHttpHandler
          {
              AllowAutoRedirect = false // default value: true
          })
          .UseSocketsHttpHandler((handler, _) => 
          {
              handler.UseCookies = false; // default value: true
          });
      
      // or, with the same result:
      
      services.AddHttpClient("bar")) 
          .UseSocketsHttpHandler((handler, _) => 
          {
              handler.AllowAutoRedirect = false; // default value: true
          })
          .UseSocketsHttpHandler((handler, _) => 
          {
              handler.UseCookies = false; // default value: true
          });

      Also note that if you use ConfigurePrimaryHandler overload that returns a new handler instance (e.g. ConfigurePrimaryHttpMessageHandler(IHttpClientBuilder, Func<HttpMessageHandler>))), then this instance would overwrite the previous one. But if you use a new ConfigurePrimaryHandler overload that applies an action to an instance (ConfigurePrimaryHttpMessageHandler(IHttpClientBuilder, Action<HttpMessageHandler,IServiceProvider>)), it will retain the previously configured handler instance and execute the action on top of it.

  • Chris DaMour 0

    why was only the 1 metric instrument enabled for enrichment? is there a decision tree that leads to the determination if enrichment for an instrument is appropriate or not?

    • Anton FirszovMicrosoft employee 1

      Out of all the built-in HTTP instruments http.client.request.duration is the only one where all the potential sources for enrichment info – the request, response and error info – are available when the measurements are recorded. Note that the duration recordings can be used to derive other metrics, eg. number of total requests or number of failed requests.

      For contrast, there are no such sources of information for connection instruments given that connections and their originating requests are decoupled since .NET 5.0.

      If you see use-cases where enrichment would be valuable and feasible for the other instruments, feel free to open an issue on github, we will investigate if we can implement it in future versions.

      • Chris DaMour 0

        makes a lot of sense thx for explaining the logic, i was asking more as a library developer

Feedback usabilla icon