{"id":49504,"date":"2023-12-07T10:05:00","date_gmt":"2023-12-07T18:05:00","guid":{"rendered":"https:\/\/devblogs.microsoft.com\/dotnet\/?p=49504"},"modified":"2024-01-04T11:06:51","modified_gmt":"2024-01-04T19:06:51","slug":"dotnet-8-networking-improvements","status":"publish","type":"post","link":"https:\/\/devblogs.microsoft.com\/dotnet\/dotnet-8-networking-improvements\/","title":{"rendered":".NET 8 Networking Improvements"},"content":{"rendered":"<p>It has become a tradition to publish a blog post about new interesting changes in networking space with new <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/announcing-dotnet-8\/\">.NET release<\/a>. This year, we&#8217;d like to introduce changes in <a href=\"#http\">HTTP<\/a> space, newly added <a href=\"#metrics\">metrics<\/a>, new <a href=\"#httpclientfactory\"><code>HttpClientFactory<\/code><\/a> APIs and more.<\/p>\n<h2>HTTP<\/h2>\n<h3>Metrics<\/h3>\n<p>.NET 8 adds built-in HTTP Metrics to both ASP.NET Core and <code>HttpClient<\/code> using the <a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/diagnostics\/metrics\">System.Diagnostics.Metrics API<\/a> 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 <a href=\"https:\/\/prometheus.io\/\">Prometheus<\/a> and <a href=\"https:\/\/grafana.com\/\">Grafana<\/a>.<\/p>\n<p>The <code>System.Diagnostics.Metrics<\/code> 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:<\/p>\n<ul>\n<li><a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.diagnostics.metrics.meter.createhistogram?view=net-8.0\">Histograms<\/a> enable us to report the durations, eg. the request duration (<a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/diagnostics\/built-in-metrics-system-net#instrument-httpclientrequestduration\"><code>http.client.request.duration<\/code><\/a>) or the connection duration (<a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/diagnostics\/built-in-metrics-system-net#instrument-httpclientconnectionduration\"><code>http.client.connection.duration<\/code><\/a>). These are new metrics without EventCounter counterparts.<\/li>\n<li><a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/diagnostics\/metrics-instrumentation#multi-dimensional-metrics\">Multi-dimensionality<\/a> allows us to attach tags (a.k.a. attributes or labels) to measurements, meaning that we can report information like <code>server.address<\/code> (identifies the <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc9110.html#name-uri-origin\">URI origin<\/a>), or <code>error.type<\/code> (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 <code>SocketsHttpHandler<\/code> uses 3 EventCounters: <a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/diagnostics\/available-counters#systemnethttp-counters\"><code>http11-connections-current-total<\/code>, <code>http20-connections-current-total<\/code> and <code>http30-connections-current-total<\/code><\/a>, while the Metrics equivalent of these counters is a single instrument, <a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/diagnostics\/built-in-metrics-system-net#instrument-httpclientopen_connections\"><code>http.client.open_connections<\/code><\/a> where the HTTP version is reported using the <code>network.protocol.version<\/code> tag. <\/li>\n<li>To help use cases where the built in tags aren&#8217;t sufficient to categorize outgoing HTTP requests, the <code>http.client.request.duration<\/code> metric supports the injection of user-defined tags. This is called <a href=\"https:\/\/learn.microsoft.com\/dotnet\/fundamentals\/networking\/telemetry\/metrics#enrichment\">enrichment<\/a>.<\/li>\n<li><a href=\"https:\/\/learn.microsoft.com\/dotnet\/fundamentals\/networking\/telemetry\/metrics#imeterfactory-and-ihttpclientfactory-integration\"><code>IMeterFactory<\/code> integration<\/a> enables the isolation of the <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.diagnostics.metrics.meter\"><code>Meter<\/code><\/a> 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.<\/li>\n<li>While this is not specific to the built-in networking metrics, it&#8217;s worth mentioning that the collection APIs in <code>System.Diagnostics.Metrics<\/code> are also more advanced: they are strongly typed and more performant and allow multiple simultaneous listeners and listener access to unaggregated measurements.<\/li>\n<\/ul>\n<p>These advantages together result in better, richer metrics which can be collected more efficiently by 3rd party tools like Prometheus.\nThanks to the flexibility of <a href=\"https:\/\/prometheus.io\/docs\/prometheus\/latest\/querying\/basics\/\">PromQL (Prometheus Query Language)<\/a>, 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 <code>HttpClient<\/code> and <code>SocketsHttpHandler<\/code> instances on a level that was not previously possible.<\/p>\n<p>On the downside, we should mention that only the <code>System.Net.Http<\/code> and the <code>System.Net.NameResolution<\/code> components are instrumented using <code>System.Diagnostics.Metrics<\/code> in .NET 8, meaning that you still need to use EventCounters to extract counters from the lower levels of the stack such as <code>System.Net.Sockets<\/code>.\nWhile all built-in EventCounters which existed in previous versions are still supported, the .NET team doesn&#8217;t expect to make substantial new investments into EventCounters, and new built-in instrumentation will be added using <code>System.Diagnostics.Metrics<\/code> in future versions.<\/p>\n<p>For more information about using built-in HTTP metrics, please read our tutorial on <a href=\"https:\/\/learn.microsoft.com\/dotnet\/fundamentals\/networking\/telemetry\/metrics\">Networking metrics in .NET<\/a>. 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 <a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/diagnostics\/built-in-metrics-system-net\">System.Net metrics<\/a>. In case you are more interested about the server-side, please read the docs on <a href=\"https:\/\/learn.microsoft.com\/aspnet\/core\/log-mon\/metrics\/metrics\">ASP.NET Core metrics<\/a>.<\/p>\n<h3>Extended Telemetry<\/h3>\n<p>Apart from the new metrics, the existing <code>EventSource<\/code> based telemetry events introduced in <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/net-5-new-networking-improvements\/#telemetry\">.NET 5<\/a> were augmented with more information about HTTP connections (<a href=\"https:\/\/github.com\/dotnet\/runtime\/pull\/88853\">dotnet\/runtime#88853<\/a>):<\/p>\n<pre><code class=\"language-diff\">- ConnectionEstablished(byte versionMajor, byte versionMinor)\n+ ConnectionEstablished(byte versionMajor, byte versionMinor, long connectionId, string scheme, string host, int port, string? remoteAddress)\n\n- ConnectionClosed(byte versionMajor, byte versionMinor)\n+ ConnectionClosed(byte versionMajor, byte versionMinor, long connectionId)\n\n- RequestHeadersStart()\n+ RequestHeadersStart(long connectionId)<\/code><\/pre>\n<p>Now, when a new connection is established, the event logs its <code>connectionId<\/code> together with its scheme, port, and peer IP address. This enables correlating requests and responses with connections via the <code>RequestHeadersStart<\/code> event &#8211; which occurs when a request is associated to a pooled connection and starts being processed &#8211; which also logs the associated <code>connectionId<\/code>. 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 (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/63159\">dotnet\/runtime#63159<\/a>).<\/p>\n<p>The events can be consumed in many ways, see <a href=\"https:\/\/learn.microsoft.com\/dotnet\/fundamentals\/networking\/telemetry\/events\">Networking telemetry in .NET &#8211; Events<\/a>. But for the sake of in-process enhanced logging, a custom <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.diagnostics.tracing.eventlistener?view=net-8.0\"><code>EventListener<\/code><\/a> can be used to correlate the request\/response pair with the connection data:<\/p>\n<pre><code class=\"language-csharp\">using IPLoggingListener ipLoggingListener = new();\nusing HttpClient client = new();\n\n\/\/ Send requests in parallel.\nawait Parallel.ForAsync(0, 1000, async (i, ct) =&gt;\n{\n    \/\/ Initialize the async local so that it can be populated by \"RequestHeadersStart\" event handler.\n    RequestInfo info = RequestInfo.Current;\n    using var response = await client.GetAsync(\"https:\/\/testserver\");\n    Console.WriteLine($\"Response {response.StatusCode} handled by connection {info.ConnectionId}. Remote IP: {info.RemoteAddress}\");\n\n    \/\/ Process response...\n});\n\ninternal sealed class RequestInfo\n{\n    private static readonly AsyncLocal&lt;RequestInfo&gt; _asyncLocal = new();\n    public static RequestInfo Current =&gt; _asyncLocal.Value ??= new();\n\n    public string? RemoteAddress;\n    public long ConnectionId;\n}\n\ninternal sealed class IPLoggingListener : EventListener\n{\n    private static readonly ConcurrentDictionary&lt;long, string&gt; s_connection2Endpoint = new ConcurrentDictionary&lt;long, string&gt;();\n\n    \/\/ EventId corresponds to [Event(eventId)] attribute argument and the payload indices correspond to the event method argument order.\n\n    \/\/ See: https:\/\/github.com\/dotnet\/runtime\/blob\/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8\/src\/libraries\/System.Net.Http\/src\/System\/Net\/Http\/HttpTelemetry.cs#L100-L101\n    private const int ConnectionEstablished_EventId = 4;\n    private const int ConnectionEstablished_ConnectionIdIndex = 2;\n    private const int ConnectionEstablished_RemoteAddressIndex = 6;\n\n    \/\/ See: https:\/\/github.com\/dotnet\/runtime\/blob\/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8\/src\/libraries\/System.Net.Http\/src\/System\/Net\/Http\/HttpTelemetry.cs#L106-L107\n    private const int ConnectionClosed_EventId = 5;\n    private const int ConnectionClosed_ConnectionIdIndex = 2;\n\n    \/\/ See: https:\/\/github.com\/dotnet\/runtime\/blob\/a6e4834d53ac591a4b3d4a213a8928ad685f7ad8\/src\/libraries\/System.Net.Http\/src\/System\/Net\/Http\/HttpTelemetry.cs#L118-L119\n    private const int RequestHeadersStart_EventId = 7;\n    private const int RequestHeadersStart_ConnectionIdIndex = 0;\n\n    protected override void OnEventSourceCreated(EventSource eventSource)\n    {\n        if (eventSource.Name == \"System.Net.Http\")\n        {\n            EnableEvents(eventSource, EventLevel.LogAlways);\n        }\n    }\n\n    protected override void OnEventWritten(EventWrittenEventArgs eventData)\n    {\n        ReadOnlyCollection&lt;object?&gt;? payload = eventData.Payload;\n        if (payload == null) return;\n\n        switch (eventData.EventId)\n        {\n            case ConnectionEstablished_EventId:\n                \/\/ Remember the connection data.\n                long connectionId = (long)payload[ConnectionEstablished_ConnectionIdIndex]!;\n                string? remoteAddress = (string?)payload[ConnectionEstablished_RemoteAddressIndex];\n                if (remoteAddress != null)\n                {\n                    Console.WriteLine($\"Connection {connectionId} established to {remoteAddress}\");\n                    s_connection2Endpoint.TryAdd(connectionId, remoteAddress);\n                }\n                break;\n            case ConnectionClosed_EventId:\n                connectionId = (long)payload[ConnectionClosed_ConnectionIdIndex]!;\n                s_connection2Endpoint.TryRemove(connectionId, out _);\n                break;\n            case RequestHeadersStart_EventId:\n                \/\/ Populate the async local RequestInfo with data from \"ConnectionEstablished\" event.\n                connectionId = (long)payload[RequestHeadersStart_ConnectionIdIndex]!;\n                if (s_connection2Endpoint.TryGetValue(connectionId, out remoteAddress))\n                {\n                    RequestInfo.Current.RemoteAddress = remoteAddress;\n                    RequestInfo.Current.ConnectionId = connectionId;\n                }\n                break;\n        }\n    }\n}<\/code><\/pre>\n<p>Additionally, the <code>Redirect<\/code> event has been extended to include the redirect URI:<\/p>\n<pre><code class=\"language-diff\">-void Redirect();\n+void Redirect(string redirectUri);<\/code><\/pre>\n<h3>HTTP Error Codes<\/h3>\n<p>One of the diagnostics problems of <code>HttpClient<\/code> was that in case of an exception it was not easy to distinguish <em>programmatically<\/em> the exact root cause for the error. The only way to differentiate many of these was to parse the exception message from <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.http.httprequestexception?view=net-8.0\"><code>HttpRequestException<\/code><\/a>. Moreover, other HTTP implementations, like WinHTTP with <a href=\"https:\/\/learn.microsoft.com\/windows\/win32\/winhttp\/error-messages\"><code>ERROR_WINHTTP_*<\/code><\/a> 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:<\/p>\n<ul>\n<li><a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.http.httprequestexception?view=net-8.0\"><code>HttpRequestException<\/code><\/a> for request processing up until receiving response headers.<\/li>\n<li><a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.http.httpioexception?view=net-8.0\"><code>HttpIOException<\/code><\/a> for response content reading.<\/li>\n<\/ul>\n<p>The design of <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.http.httprequesterror?view=net-8.0\"><code>HttpRequestError<\/code><\/a> enum and how it&#8217;s plugged into HTTP exceptions is described in <a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/76644\">dotnet\/runtime#76644<\/a> API proposal.<\/p>\n<p>Now, a consumer of an <code>HttpClient<\/code> methods can handle specific internal errors much more easily and reliably:<\/p>\n<pre><code class=\"language-csharp\">using HttpClient httpClient = new();\n\n\/\/ Handling problems with the server:\ntry\n{\n    using HttpResponseMessage response = await httpClient.GetAsync(\"https:\/\/testserver\", HttpCompletionOption.ResponseHeadersRead);\n    using Stream responseStream = await response.Content.ReadAsStreamAsync();\n    \/\/ Process responseStream ...\n}\ncatch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.NameResolutionError)\n{\n    Console.WriteLine($\"Unknown host: {e}\");\n    \/\/ --&gt; Try different hostname.\n}\ncatch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.ConnectionError)\n{\n    Console.WriteLine($\"Server unreachable: {e}\");\n    \/\/ --&gt; Try different server.\n}\ncatch (HttpIOException e) when (e.HttpRequestError == HttpRequestError.InvalidResponse)\n{\n    Console.WriteLine($\"Mangled responses: {e}\");\n    \/\/ --&gt; Block list server.\n}\n\n\/\/ Handling problems with HTTP version selection:\ntry\n{\n    using HttpResponseMessage response = await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, \"https:\/\/testserver\")\n    {\n        Version = HttpVersion.Version20,\n        VersionPolicy = HttpVersionPolicy.RequestVersionExact\n    }, HttpCompletionOption.ResponseHeadersRead);\n    using Stream responseStream = await response.Content.ReadAsStreamAsync();\n    \/\/ Process responseStream ...\n}\ncatch (HttpRequestException e) when (e.HttpRequestError == HttpRequestError.VersionNegotiationError)\n{\n    Console.WriteLine($\"HTTP version is not supported: {e}\");\n    \/\/ Try with different HTTP version.\n}\n<\/code><\/pre>\n<h3>HTTPS Proxy Support<\/h3>\n<p>One of the highly requested features that got implemented with this release is support for HTTPS proxies (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/31113\">dotnet\/runtime#31113<\/a>). It&#8217;s possible now to use proxies serving requests over HTTPS, meaning the connection <em>to<\/em> the proxy is secure. That doesn&#8217;t say anything about the request itself <em>from<\/em> 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 <code>CONNECT<\/code> 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.<\/p>\n<p>To take advantage of the feature, all that&#8217;s needed is to use an HTTPS scheme when setting up the proxy:<\/p>\n<pre><code class=\"language-csharp\">using HttpClient client = new HttpClient(new SocketsHttpHandler()\n{\n    Proxy = new WebProxy(\"https:\/\/proxy.address:12345\")\n});\n\nusing HttpResponseMessage response = await client.GetAsync(\"https:\/\/httpbin.org\/\");<\/code><\/pre>\n<h2>HttpClientFactory<\/h2>\n<p>.NET 8 extends the ways in which you can configure <a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/extensions\/httpclient-factory\"><code>HttpClientFactory<\/code><\/a>, including client defaults, custom logging, and simplified <code>SocketsHttpHandler<\/code> configuration. The APIs are implemented in the <code>Microsoft.Extensions.Http<\/code> 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 <code>SocketsHttpHandler<\/code> related APIs that are only available for .NET 5+).<\/p>\n<h3>Set Up Defaults For All Clients<\/h3>\n<p>.NET 8 adds the ability to set default configuration that would be used for all <code>HttpClient<\/code>s created by <code>HttpClientFactory<\/code> (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/87914\">dotnet\/runtime#87914<\/a>). This is useful when all or most of the registered clients contain the same subset of the configuration.<\/p>\n<p>Consider an example where two named clients are defined, and they both need <code>MyAuthHandler<\/code> in their message handlers chain.<\/p>\n<pre><code class=\"language-csharp\">services.AddHttpClient(\"consoto\", c =&gt; c.BaseAddress = new Uri(\"https:\/\/consoto.com\/\"))\n    .AddHttpMessageHandler&lt;MyAuthHandler&gt;();\n\nservices.AddHttpClient(\"github\", c =&gt; c.BaseAddress = new Uri(\"https:\/\/github.com\/\"))\n    .AddHttpMessageHandler&lt;MyAuthHandler&gt;();<\/code><\/pre>\n<p>To extract the common part, you can now use the <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.httpclientfactoryservicecollectionextensions.configurehttpclientdefaults\"><code>ConfigureHttpClientDefaults<\/code><\/a> method:<\/p>\n<pre><code class=\"language-csharp\">services.ConfigureHttpClientDefaults(b =&gt; b.AddHttpMessageHandler&lt;MyAuthHandler&gt;());\n\n\/\/ both clients will have MyAuthHandler added by default\nservices.AddHttpClient(\"consoto\", c =&gt; c.BaseAddress = new Uri(\"https:\/\/consoto.com\/\"));\nservices.AddHttpClient(\"github\", c =&gt; c.BaseAddress = new Uri(\"https:\/\/github.com\/\"));<\/code><\/pre>\n<p>All <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.ihttpclientbuilder\"><code>IHttpClientBuilder<\/code><\/a> extension methods that are used with <code>AddHttpClient<\/code>, can be used within <code>ConfigureHttpClientDefaults<\/code> as well.<\/p>\n<p>The default configuration (<code>ConfigureHttpClientDefaults<\/code>) is applied to all clients <em>before<\/em> the client-specific (<code>AddHttpClient<\/code>) configurations; their relative position in the registration doesn&#8217;t matter. <code>ConfigureHttpClientDefaults<\/code> 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 <code>HttpClient<\/code> object or to a primary handler, remove previously added additional handler, etc.<\/p>\n<p>Note that since 8.0, <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.httpclientbuilderextensions.configurehttpmessagehandlerbuilder\"><code>ConfigureHttpMessageHandlerBuilder<\/code><\/a> method is <em>deprecated<\/em>. You should use <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.httpclientbuilderextensions.configureprimaryhttpmessagehandler?view=dotnet-plat-ext-8.0#microsoft-extensions-dependencyinjection-httpclientbuilderextensions-configureprimaryhttpmessagehandler(microsoft-extensions-dependencyinjection-ihttpclientbuilder-system-action((system-net-http-httpmessagehandler-system-iserviceprovider)\"><code>ConfigurePrimaryHttpMessageHandler(Action&lt;HttpMessageHandler,IServiceProvider&gt;)<\/code><\/a>)) or <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.httpclientbuilderextensions.configureadditionalhttpmessagehandlers\"><code>ConfigureAdditionalHttpMessageHandlers<\/code><\/a> methods instead, to modify a previously configured primary handler or a list of additional handlers respectively.<\/p>\n<pre><code class=\"language-csharp\">\/\/ by default, adds User-Agent header, uses HttpClientHandler with UseCookies=false\n\/\/ as a primary handler, and adds MyAuthHandler to all clients\nservices.ConfigureHttpClientDefaults(b =&gt;\n    b.ConfigureHttpClient(c =&gt; c.DefaultRequestHeaders.UserAgent.ParseAdd(\"HttpClient\/8.0\"))\n     .ConfigurePrimaryHttpMessageHandler(() =&gt; new HttpClientHandler() { UseCookies = false })\n     .AddHttpMessageHandler&lt;MyAuthHandler&gt;());\n\n\/\/ HttpClient will have both User-Agent (from defaults) and BaseAddress set\n\/\/ + client will have UseCookies=false and MyAuthHandler from defaults\nservices.AddHttpClient(\"modify-http-client\", c =&gt; c.BaseAddress = new Uri(\"https:\/\/httpbin.org\/\"))\n\n\/\/ primary handler will have both UseCookies=false (from defaults) and MaxConnectionsPerServer set\n\/\/ + client will have User-Agent and MyAuthHandler from defaults\nservices.AddHttpClient(\"modify-primary-handler\")\n    .ConfigurePrimaryHandler((h, _) =&gt; ((HttpClientHandler)h).MaxConnectionsPerServer = 1);\n\n\/\/ MyWrappingHandler will be inserted at the top of the handlers chain\n\/\/ + client will have User-Agent, UseCookies=false and MyAuthHandler from defaults\nservices.AddHttpClient(\"insert-handler-into-chain\"))\n    .ConfigureAdditionalHttpMessageHandlers((handlers, _) =&gt;\n        handlers.Insert(0, new MyWrappingHandler());\n\n\/\/ MyAuthHandler (initially from defaults) will be removed from the handler chain\n\/\/ + client will still have User-Agent and UseCookies=false from defaults\nservices.AddHttpClient(\"remove-handler-from-chain\"))\n    .ConfigureAdditionalHttpMessageHandlers((handlers, _) =&gt;\n        handlers.Remove(handlers.Single(h =&gt; h is MyAuthHandler)));<\/code><\/pre>\n<h3>Modify HttpClient Logging<\/h3>\n<p>Customizing (or even simply turning off) <code>HttpClientFactory<\/code> logging was one of the long-requested features (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/77312\">dotnet\/runtime#77312<\/a>).<\/p>\n<h4>Old Logging Overview<\/h4>\n<p>Default (&#8220;old&#8221;) logging added by <code>HttpClientFactory<\/code> is quite verbose and emits 8 log messages per request:<\/p>\n<ol>\n<li><em>Start notification<\/em> with request URI &#8212; before propagating through the delegating handler pipeline;<\/li>\n<li><em>Request headers<\/em> &#8212; before handler pipeline;<\/li>\n<li><em>Start notification<\/em> with request URI &#8212; after handler pipeline;<\/li>\n<li><em>Request headers<\/em> &#8212; after handler pipeline;<\/li>\n<li><em>Stop notification<\/em> with elapsed time &#8212; before propagating the response back through the delegating handler pipeline;<\/li>\n<li><em>Response headers<\/em> &#8212; before propagating the response back;<\/li>\n<li><em>Stop notification<\/em> with elapsed time &#8212; after propagating the response back;<\/li>\n<li><em>Response headers<\/em> &#8212; after propagating the response back.<\/li>\n<\/ol>\n<p>This can be illustrated with the diagram below. In this and the following diagrams, <code>*<\/code> and <code>[...]<\/code> denote a logging event (in the default implementation, a log message being written into <code>ILogger<\/code>), and <code>--&gt;<\/code> symbolizes data flow through the <em>Application<\/em> and <em>Transport<\/em> layers.<\/p>\n<pre><code class=\"language-fsharp\">  Request --&gt;\n*   [Start notification]    \/\/ \"Start processing HTTP request ...\" (1)\n*   [Request headers]       \/\/ \"Request Headers: ...\" (2)\n      --&gt; Additional Handler #1 --&gt;\n        --&gt; .... --&gt;\n          --&gt; Additional Handler #N --&gt;\n*           [Start notification]    \/\/ \"Sending HTTP request ...\" (3)\n*           [Request headers]       \/\/ \"Request Headers: ...\" (4)\n                --&gt; Primary Handler --&gt;\n                      --------Transport--layer-------&gt;\n                                          \/\/ Server sends response\n                      &lt;-------Transport--layer--------\n                &lt;-- Primary Handler &lt;--\n*           [Stop notification]    \/\/ \"Received HTTP response ...\" (5)\n*           [Response headers]     \/\/ \"Response Headers: ...\" (6)\n          &lt;-- Additional Handler #N &lt;--\n        &lt;-- .... &lt;--\n      &lt;-- Additional Handler #1 &lt;--\n*   [Stop notification]    \/\/ \"End processing HTTP request ...\" (7)\n*   [Response headers]     \/\/ \"Response Headers: ...\" (8)\n  Response &lt;--<\/code><\/pre>\n<p>Console output of the default <code>HttpClientFactory<\/code> logging looks like this:<\/p>\n<pre><code class=\"language-csharp\">var client = _httpClientFactory.CreateClient();\nawait client.GetAsync(\"https:\/\/httpbin.org\/get\");<\/code><\/pre>\n<pre><code class=\"language-Output\">info: System.Net.Http.HttpClient.test.LogicalHandler[100]\n      Start processing HTTP request GET https:\/\/httpbin.org\/get\ntrce: System.Net.Http.HttpClient.test.LogicalHandler[102]\n      Request Headers:\n      ....\ninfo: System.Net.Http.HttpClient.test.ClientHandler[100]\n      Sending HTTP request GET https:\/\/httpbin.org\/get\ntrce: System.Net.Http.HttpClient.test.ClientHandler[102]\n      Request Headers:\n      ....\ninfo: System.Net.Http.HttpClient.test.ClientHandler[101]\n      Received HTTP response headers after 581.2898ms - 200\ntrce: System.Net.Http.HttpClient.test.ClientHandler[103]\n      Response Headers:\n      ....\ninfo: System.Net.Http.HttpClient.test.LogicalHandler[101]\n      End processing HTTP request after 618.9736ms - 200\ntrce: System.Net.Http.HttpClient.test.LogicalHandler[103]\n      Response Headers:\n      ....<\/code><\/pre>\n<p>Note that in order to see <code>Trace<\/code> level messages, you need to opt-in to that in the global logging configuration file or via <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.logging.loggingbuilderextensions.setminimumlevel?view=dotnet-plat-ext-8.0\"><code>SetMinimumLevel(LogLevel.Trace)<\/code><\/a>. But even considering only <code>Informational<\/code> messages, &#8220;old&#8221; logging still has 4 messages per request.<\/p>\n<p>To remove the default (or previously added) logging, you can use the new <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.httpclientbuilderextensions.removeallloggers\"><code>RemoveAllLoggers()<\/code><\/a> extension method. It is especially powerful combined with the <code>ConfigureHttpClientDefaults<\/code> API described in the <a href=\"#set-up-defaults-for-all-clients\">Set Up Defaults For All Clients<\/a> section above. This way, you can remove the &#8220;old&#8221; logging for all of the clients in one line:<\/p>\n<pre><code class=\"language-csharp\">services.ConfigureHttpClientDefaults(b =&gt; b.RemoveAllLoggers()); \/\/ remove HttpClientFactory default logging for all clients<\/code><\/pre>\n<p>If you ever need to bring back the &#8220;old&#8221; logging, e.g. for a specific client, you can do so using <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.httpclientbuilderextensions.adddefaultlogger\"><code>AddDefaultLogger()<\/code><\/a>.<\/p>\n<h4>Add Custom Logging<\/h4>\n<p>In addition to the ability to remove the &#8220;old&#8221; logging, new <code>HttpClientFactory<\/code> APIs also allow you to fully customize the logging. You can specify what and how will be logged when <code>HttpClient<\/code> starts a request, receives a response or throws an exception.<\/p>\n<p>You can add several custom loggers alongside, if you choose to do so &#8212; for example, both Console and ETW loggers, or both <a href=\"#wrapping-and-not-wrapping-loggers\">&#8220;wrapping&#8221; and &#8220;not wrapping&#8221;<\/a> loggers. Because of its additive nature, you might need to explicitly remove the default &#8220;old&#8221; logging beforehand.<\/p>\n<p>To add custom logging, you need to implement the <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.http.logging.ihttpclientlogger\"><code>IHttpClientLogger<\/code><\/a> interface and then add the custom logger to your client with <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.httpclientbuilderextensions.addlogger\"><code>AddLogger<\/code><\/a>. Note that logging implementation <em>should not<\/em> throw any Exceptions, otherwise it can disrupt the request execution.<\/p>\n<p>Registration:<\/p>\n<pre><code class=\"language-csharp\">services.AddSingleton&lt;SimpleConsoleLogger&gt;(); \/\/ register the logger in DI\n\nservices.AddHttpClient(\"foo\") \/\/ add a client\n    .RemoveAllLoggers() \/\/ remove previous logging\n    .AddLogger&lt;SimpleConsoleLogger&gt;(); \/\/ add the custom logger<\/code><\/pre>\n<p>Sample logger implementation:<\/p>\n<pre><code class=\"language-csharp\">\/\/ outputs one line per request to console\npublic class SimpleConsoleLogger : IHttpClientLogger\n{\n    public object? LogRequestStart(HttpRequestMessage request) =&gt; null;\n\n    public void LogRequestStop(object? ctx, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed)\n        =&gt; Console.WriteLine($\"{request.Method} {request.RequestUri?.AbsoluteUri} - {(int)response.StatusCode} {response.StatusCode} in {elapsed.TotalMilliseconds}ms\");\n\n    public void LogRequestFailed(object? ctx, HttpRequestMessage request, HttpResponseMessage? response, Exception e, TimeSpan elapsed)\n        =&gt; Console.WriteLine($\"{request.Method} {request.RequestUri?.AbsoluteUri} - Exception {e.GetType().FullName}: {e.Message}\");\n}<\/code><\/pre>\n<p>Sample output:<\/p>\n<pre><code class=\"language-csharp\">var client = _httpClientFactory.CreateClient(\"foo\");\nawait client.GetAsync(\"https:\/\/httpbin.org\/get\");\nawait client.PostAsync(\"https:\/\/httpbin.org\/post\", new ByteArrayContent(new byte[] { 42 }));\nawait client.GetAsync(\"http:\/\/httpbin.org\/status\/500\");\nawait client.GetAsync(\"http:\/\/localhost:1234\");<\/code><\/pre>\n<pre><code class=\"language-Output\">GET https:\/\/httpbin.org\/get - 200 OK in 393.2039ms\nPOST https:\/\/httpbin.org\/post - 200 OK in 95.524ms\nGET https:\/\/httpbin.org\/status\/500 - 500 InternalServerError in 99.5025ms\nGET http:\/\/localhost:1234\/ - Exception System.Net.Http.HttpRequestException: No connection could be made because the target machine actively refused it. (localhost:1234)<\/code><\/pre>\n<h4>Request Context Object<\/h4>\n<p>A context object can be used to match the <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.http.logging.ihttpclientlogger.logrequeststart\"><code>LogRequestStart<\/code><\/a> call with the corresponding <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.http.logging.ihttpclientlogger.logrequeststop\"><code>LogRequestStop<\/code><\/a> call to pass data from one to the other. Context object is produced by <code>LogRequestStart<\/code> and then passed back to <code>LogRequestStop<\/code>. This can be a property bag or any other object that holds the necessary data.<\/p>\n<p>If a context object is not needed, implementation can return <code>null<\/code> from <code>LogRequestStart<\/code>.<\/p>\n<p>The following example shows how context object can be used to pass a custom request identifier.<\/p>\n<pre><code class=\"language-csharp\">public class RequestIdLogger : IHttpClientLogger\n{\n    private readonly ILogger _log;\n\n    public RequestIdLogger(ILogger&lt;RequestIdLogger&gt; log)\n    {\n        _log = log;\n    }\n\n    private static readonly Action&lt;ILogger, Guid, string?, Exception?&gt; _requestStart =\n        LoggerMessage.Define&lt;Guid, string?&gt;(\n            LogLevel.Information,\n            EventIds.RequestStart,\n            \"Request Id={RequestId} ({Host}) started\");\n\n    private static readonly Action&lt;ILogger, Guid, double, Exception?&gt; _requestStop =\n        LoggerMessage.Define&lt;Guid, double&gt;(\n            LogLevel.Information,\n            EventIds.RequestStop,\n            \"Request Id={RequestId} succeeded in {elapsed}ms\");\n\n    private static readonly Action&lt;ILogger, Guid, Exception?&gt; _requestFailed =\n        LoggerMessage.Define&lt;Guid&gt;(\n            LogLevel.Error,\n            EventIds.RequestFailed,\n            \"Request Id={RequestId} FAILED\");\n\n    public object? LogRequestStart(HttpRequestMessage request)\n    {\n        var ctx = new Context(Guid.NewGuid());\n        _requestStart(_log, ctx.RequestId, request.RequestUri?.Host, null);\n        return ctx;\n    }\n\n    public void LogRequestStop(object? ctx, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed)\n        =&gt; _requestStop(_log, ((Context)ctx!).RequestId, elapsed.TotalMilliseconds, null);\n\n    public void LogRequestFailed(object? ctx, HttpRequestMessage request, HttpResponseMessage? response, Exception e, TimeSpan elapsed)\n        =&gt; _requestFailed(_log, ((Context)ctx!).RequestId, null);\n\n    public static class EventIds\n    {\n        public static readonly EventId RequestStart = new(1, \"RequestStart\");\n        public static readonly EventId RequestStop = new(2, \"RequestStop\");\n        public static readonly EventId RequestFailed = new(3, \"RequestFailed\");\n    }\n\n    record Context(Guid RequestId);\n}<\/code><\/pre>\n<pre><code class=\"language-Output\">info: RequestIdLogger[1]\n      Request Id=d0d63b84-cd67-4d21-ae9a-b63d26dfde50 (httpbin.org) started\ninfo: RequestIdLogger[2]\n      Request Id=d0d63b84-cd67-4d21-ae9a-b63d26dfde50 succeeded in 530.1664ms\ninfo: RequestIdLogger[1]\n      Request Id=09403213-dd3a-4101-88e8-db8ab19e1eeb (httpbin.org) started\ninfo: RequestIdLogger[2]\n      Request Id=09403213-dd3a-4101-88e8-db8ab19e1eeb succeeded in 83.2484ms\ninfo: RequestIdLogger[1]\n      Request Id=254e49bd-f640-4c56-b62f-5de678eca129 (httpbin.org) started\ninfo: RequestIdLogger[2]\n      Request Id=254e49bd-f640-4c56-b62f-5de678eca129 succeeded in 162.7776ms\ninfo: RequestIdLogger[1]\n      Request Id=e25ccb08-b97e-400d-b42b-b09d6c42adec (localhost) started\nfail: RequestIdLogger[3]\n      Request Id=e25ccb08-b97e-400d-b42b-b09d6c42adec FAILED<\/code><\/pre>\n<h4>Avoid Reading From Content Streams<\/h4>\n<p>If you intend to read and log, for example, request and response Content, please be aware that it can potentially have an <em>adverse  side effect<\/em> 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.<\/p>\n<h4>Use Async Logging With Caution<\/h4>\n<p>We expect that the synchronous <code>IHttpClientLogger<\/code> 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 <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.http.logging.ihttpclientasynclogger\"><code>IHttpClientAsyncLogger<\/code><\/a>. It derives from <code>IHttpClientLogger<\/code>, so it can use the same <code>AddLogger<\/code> API for registration.<\/p>\n<p>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 <code>HttpClient.Send<\/code> methods; even if .NET Standard surface doesn\u2019t include them, .NET Standard library can be used in a .NET 5+ application, so the end users would have the access to sync <code>HttpClient.Send<\/code> methods.<\/p>\n<h4>Wrapping And Not Wrapping Loggers<\/h4>\n<p>When you add the logger, you may explicitly set <code>wrapHandlersPipeline<\/code> parameter to specify whether the logger would be<\/p>\n<ul>\n<li><em>wrapping<\/em> the handlers pipeline (added to the top of the pipeline, corresponding to the messages no. 1, 2, 7 and 8 in the <a href=\"#old-logging-overview\">Old Logging Overview<\/a> section above)<\/li>\n<\/ul>\n<pre><code class=\"language-fsharp\">  Request --&gt;\n*   [LogRequestStart()]                \/\/ wrapHandlersPipeline=TRUE\n      --&gt; Additional Handlers #1..N --&gt;    \/\/ handlers pipeline\n          --&gt; Primary Handler --&gt;\n                --------Transport--layer--------\n          &lt;-- Primary Handler &lt;--\n      &lt;-- Additional Handlers #N..1 &lt;--    \/\/ handlers pipeline\n*   [LogRequestStop()]                 \/\/ wrapHandlersPipeline=TRUE\n  Response &lt;--<\/code><\/pre>\n<ul>\n<li>or, <em>not wrapping<\/em> the handlers pipeline (added to the bottom, corresponding to the messages no. 3, 4, 5 and 6 in the <a href=\"#old-logging-overview\">Old Logging Overview<\/a> section above).<\/li>\n<\/ul>\n<pre><code class=\"language-fsharp\">  Request --&gt;\n    --&gt; Additional Handlers #1..N --&gt; \/\/ handlers pipeline\n*     [LogRequestStart()]             \/\/ wrapHandlersPipeline=FALSE\n          --&gt; Primary Handler --&gt;\n                --------Transport--layer--------\n          &lt;-- Primary Handler &lt;--\n*     [LogRequestStop()]              \/\/ wrapHandlersPipeline=FALSE\n    &lt;-- Additional Handlers #N..1 &lt;-- \/\/ handlers pipeline\n  Response &lt;--<\/code><\/pre>\n<p>By default, loggers are added as <em>not wrapping<\/em>.<\/p>\n<p>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. <code>HttpClientHandler<\/code>).<\/p>\n<p>This can be illustrated with the following diagrams:<\/p>\n<ul>\n<li><em>Wrapping<\/em> case (<code>wrapHandlersPipeline=TRUE<\/code>)<\/li>\n<\/ul>\n<pre><code class=\"language-fsharp\">  Request --&gt;\n*   [LogRequestStart()]\n        --&gt; Additional Handlers #1..(N-1) --&gt;\n            --&gt; Retry Handler --&gt;\n              --&gt; \/\/1\n                  --&gt; Primary Handler --&gt;\n                  &lt;-- \"503 Service Unavailable\" &lt;--\n              --&gt; \/\/2\n                  --&gt; Primary Handler -&gt;\n                  &lt;-- \"503 Service Unavailable\" &lt;--\n              --&gt; \/\/3\n                  --&gt; Primary Handler --&gt;\n                  &lt;-- \"200 OK\" &lt;--\n            &lt;-- Retry Handler &lt;--\n        &lt;-- Additional Handlers #(N-1)..1 &lt;--\n*   [LogRequestStop()]\n  Response &lt;--<\/code><\/pre>\n<pre><code class=\"language-txt\">info: Example.CustomLogger.Wrapping[1]\n      GET https:\/\/consoto.com\/\ninfo: Example.CustomLogger.Wrapping[2]\n      200 OK - 809.2135ms<\/code><\/pre>\n<ul>\n<li><em>Not wrapping<\/em> case (<code>wrapHandlersPipeline=FALSE<\/code>)<\/li>\n<\/ul>\n<pre><code class=\"language-fsharp\">  Request --&gt;\n    --&gt; Additional Handlers #1..(N-1) --&gt;\n        --&gt; Retry Handler --&gt;\n          --&gt; \/\/1\n*           [LogRequestStart()]\n                --&gt; Primary Handler --&gt;\n                &lt;-- \"503 Service Unavailable\" &lt;--\n*           [LogRequestStop()]\n          --&gt; \/\/2\n*           [LogRequestStart()]\n                --&gt; Primary Handler --&gt;\n                &lt;-- \"503 Service Unavailable\" &lt;--\n*           [LogRequestStop()]\n          --&gt; \/\/3\n*           [LogRequestStart()]\n                --&gt; Primary Handler --&gt;\n                &lt;-- \"200 OK\" &lt;--\n*           [LogRequestStop()]\n        &lt;-- Retry Handler &lt;--\n    &lt;-- Additional Handlers #(N-1)..1 &lt;--\n  Response &lt;--<\/code><\/pre>\n<pre><code class=\"language-txt\">info: Example.CustomLogger.NotWrapping[1]\n      GET https:\/\/consoto.com\/\ninfo: Example.CustomLogger.NotWrapping[2]\n      503 Service Unavailable - 98.613ms\ninfo: Example.CustomLogger.NotWrapping[1]\n      GET https:\/\/consoto.com\/\ninfo: Example.CustomLogger.NotWrapping[2]\n      503 Service Unavailable - 96.1932ms\ninfo: Example.CustomLogger.NotWrapping[1]\n      GET https:\/\/consoto.com\/\ninfo: Example.CustomLogger.NotWrapping[2]\n      200 OK - 579.2133ms<\/code><\/pre>\n<h3>Simplified SocketsHttpHandler Configuration<\/h3>\n<p>.NET 8 adds more convenient and fluent way to use <code>SocketsHttpHandler<\/code> as a primary handler in <code>HttpClientFactory<\/code> (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/84075\">dotnet\/runtime#84075<\/a>).<\/p>\n<p>You can set and configure <code>SocketsHttpHandler<\/code> with the <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.extensions.dependencyinjection.httpclientbuilderextensions.usesocketshttphandler\"><code>UseSocketsHttpHandler<\/code><\/a> method. You can use <code>IConfiguration<\/code> to set <code>SocketsHttpHandler<\/code> properties from a config file, or you can configure it from the code, or you can combine both of the approaches.<\/p>\n<p>Note that, when applying <code>IConfiguration<\/code> to the <code>SocketsHttpHandler<\/code>, only properties of SocketsHttpHandler of type <code>bool<\/code>, <code>int<\/code>, <code>Enum<\/code>, or <code>TimeSpan<\/code> are parsed. All unmatched properties in <code>IConfiguration<\/code> are <em>ignored<\/em>. Configuration is parsed only once upon registration and is <em>not<\/em> reloaded, so the handler will not reflect any config file changes until the application is restarted.<\/p>\n<pre><code class=\"language-csharp\">\/\/ sets up properties on the handler directly\nservices.AddHttpClient(\"foo\")\n    .UseSocketsHttpHandler((h, _) =&gt; h.UseCookies = false);\n\n\/\/ uses a builder to combine approaches\nservices.AddHttpClient(\"bar\")\n    .UseSocketsHttpHandler(b =&gt;\n        b.Configure(config.GetSection($\"HttpClient:bar\")) \/\/ loads simple properties from config\n         .Configure((h, _) =&gt; \/\/ sets up SslOptions in code\n         {\n            h.SslOptions.RemoteCertificateValidationCallback = delegate { return true; };\n         });\n    );<\/code><\/pre>\n<pre><code class=\"language-json\">{\n  \"HttpClient\": {\n    \"bar\": {\n      \"AllowAutoRedirect\": true,\n      \"UseCookies\": false,\n      \"ConnectTimeout\": \"00:00:05\"\n    }\n  }\n}<\/code><\/pre>\n<h2>QUIC<\/h2>\n<h3>OpenSSL 3 Support<\/h3>\n<p>Most of the current Linux distributions adopted OpenSSL 3 in their recent releases:<\/p>\n<ul>\n<li>Debian 12+: <a href=\"https:\/\/packages.debian.org\/bookworm\/openssl\">Bookworm OpenSSL<\/a><\/li>\n<li>Ubuntu 22+: <a href=\"https:\/\/packages.ubuntu.com\/jammy\/openssl\">Jammy OpenSSL<\/a><\/li>\n<li>Fedora 37+: <a href=\"https:\/\/packages.fedoraproject.org\/pkgs\/openssl\/openssl\/\">Fedora OpenSSL<\/a><\/li>\n<li>OpenSUSE: <a href=\"https:\/\/software.opensuse.org\/package\/openssl\">Tumbleweed OpenSSL<\/a><\/li>\n<li>AlmaLinux 9+: <a href=\"http:\/\/repo.almalinux.org\/almalinux\/9\/BaseOS\/x86_64\/os\/Packages\/\">AlmaLinux 9 package repository<\/a><\/li>\n<\/ul>\n<p>.NET 8&#8217;s QUIC support is ready for that (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/81801\">dotnet\/runtime#81801<\/a>).<\/p>\n<p>The first step to achieve that was to make sure that <a href=\"https:\/\/github.com\/microsoft\/msquic\">MsQuic<\/a>, the QUIC implementation used underneath <code>System.Net.Quic<\/code>, can work with OpenSSL 3+. This work happened in MsQuic repository <a href=\"https:\/\/github.com\/microsoft\/msquic\/issues\/2039\">microsoft\/msquic#2039<\/a>. The next step was to make sure that <code>libmsquic<\/code> package is built and published with a corresponding dependency on the default OpenSSL version for the particular distribution and version. For example Debian distribution:<\/p>\n<ul>\n<li>Debian 11 <a href=\"https:\/\/packages.microsoft.com\/debian\/11\/prod\/pool\/main\/libm\/libmsquic\/\"><code>libmsquic<\/code><\/a> depends on OpenSSL 1.1<\/li>\n<li>Debian 12 <a href=\"https:\/\/packages.microsoft.com\/debian\/12\/prod\/pool\/main\/libm\/libmsquic\/\"><code>libmsquic<\/code><\/a> depends on OpenSSL 3<\/li>\n<\/ul>\n<p>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.<\/p>\n<h3>Exceptions<\/h3>\n<p>After publishing QUIC APIs in .NET 7 (as <a href=\"https:\/\/github.com\/dotnet\/designs\/blob\/main\/accepted\/2021\/preview-features\/preview-features.md\">preview feature<\/a>), we received several issues about exceptions:<\/p>\n<ul>\n<li><a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/78751\">dotnet\/runtime#78751<\/a>: <code>QuicConnection.ConnectAsync<\/code> raises <code>SocketException<\/code> when host not found<\/li>\n<li><a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/78096\">dotnet\/runtime#78096<\/a>: QuicListener AcceptConnectionAsync and OperationCanceledException<\/li>\n<li><a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/75115\">dotnet\/runtime#75115<\/a>: QuicListener.AcceptConnectionAsync rethrowing exceptions<\/li>\n<\/ul>\n<p>In .NET 8, <code>System.Net.Quic<\/code> exceptions behavior was completely revised in <a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/82262\">dotnet\/runtime#82262<\/a> and the above mentioned issues were addressed.<\/p>\n<p>One of the main goals of the revision was to make sure that the exceptions behavior in <code>System.Net.Quic<\/code> is as consistent as possible across the whole namespace. In general, the current behavior can be summarized as follows:<\/p>\n<ul>\n<li><code>QuicException<\/code>: all errors specific to the QUIC protocol or related to its processing.\n<ul>\n<li>Connection closed either locally or by the peer.<\/li>\n<li>Connection idled out from inactivity.<\/li>\n<li>Stream aborted either locally or by the peer.<\/li>\n<li>Other errors described in <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicerror?view=net-8.0\"><code>QuicError<\/code><\/a><\/li>\n<\/ul>\n<\/li>\n<li><code>SocketException<\/code>: for network problem such as network conditions, name resolution or user errors.\n<ul>\n<li>Address is already in use.<\/li>\n<li>Target host cannot be reached.<\/li>\n<li>Specified address is invalid.<\/li>\n<li>Host name cannot be resolved.<\/li>\n<\/ul>\n<\/li>\n<li><code>AuthenticationException<\/code>: for all TLS related issues. The goal is to have similar behavior as <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.security.sslstream?view=net-8.0\"><code>SslStream<\/code><\/a>.\n<ul>\n<li>Certificate related errors.<\/li>\n<li>ALPN negotiation errors.<\/li>\n<li>User cancellation during handshake.<\/li>\n<\/ul>\n<\/li>\n<li><code>ArgumentException<\/code>: when supplied <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicconnectionoptions?view=net-8.0\"><code>QuicConnectionOptions<\/code><\/a> or <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quiclisteneroptions?view=net-8.0\"><code>QuicListenerOptions<\/code><\/a> are invalid.\n<ul>\n<li>Provided stream limits as not within the range of 0-65535.<\/li>\n<li>Omitting mandatory properties like: <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicconnectionoptions.defaultcloseerrorcode?view=net-8.0#system-net-quic-quicconnectionoptions-defaultcloseerrorcode\"><code>DefaultCloseErrorCode<\/code><\/a> or <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicconnectionoptions.defaultstreamerrorcode?view=net-8.0#system-net-quic-quicconnectionoptions-defaultstreamerrorcode\"><code>DefaultStreamErrorCode<\/code><\/a>.<\/li>\n<li>Not specifying <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicclientconnectionoptions.clientauthenticationoptions?view=net-8.0\"><code>ClientAuthenticationOptions<\/code><\/a> or <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicserverconnectionoptions.serverauthenticationoptions?view=net-8.0\"><code>ServerAuthenticationOptions<\/code><\/a>.<\/li>\n<\/ul>\n<\/li>\n<li><code>OperationCanceledException<\/code>: whenever <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.threading.cancellationtoken?view=net-8.0\"><code>CancellationToken<\/code><\/a> gets fired cancellation.<\/li>\n<li><code>ObjectDisposedException<\/code>: whenever calling a method on an already disposed object.<\/li>\n<\/ul>\n<p>Note that the examples mentioned above are not exhaustive.<\/p>\n<p>Apart from changing the behavior, <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicexception?view=net-8.0\"><code>QuicException<\/code><\/a> was changed as well. One of those changes was adjusting <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicerror?view=net-8.0\"><code>QuicError<\/code><\/a> enum values. Items that are now covered by <code>SocketException<\/code> were removed and a new value for user callback errors was added (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/87259\">dotnet\/runtime#87259<\/a>). The newly added <code>CallbackError<\/code> is used to distinguish exceptions thrown by <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quiclisteneroptions.connectionoptionscallback?view=net-8.0#system-net-quic-quiclisteneroptions-connectionoptionscallback\"><code>QuicListenerOptions.ConnectionOptionsCallback<\/code><\/a> from <code>System.Net.Quic<\/code> originating ones (<a href=\"https:\/\/github.com\/dotnet\/runtime\/pull\/88614\">dotnet\/runtime#88614<\/a>). So, if the user code throws for example <code>ArgumentException<\/code>, <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quiclistener.acceptconnectionasync?view=net-8.0\"><code>QuicListener.AcceptConnectionAsync<\/code><\/a> will wrap it in <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicexception?view=net-8.0\"><code>QuicException<\/code><\/a> with <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicexception.quicerror?view=net-8.0\"><code>QuicError<\/code><\/a> set to <code>CallbackError<\/code> and the inner exception will contain the original user-thrown one. It can be used like this:<\/p>\n<pre><code class=\"language-csharp\">await using var listener = await QuicListener.ListenAsync(new QuicListenerOptions\n{\n    \/\/ ...\n    ConnectionOptionsCallback = (con, hello, token) =&gt;\n    {\n        if (blockedServers.Contains(hello.ServerName))\n        {\n            throw new ArgumentException($\"Connection attempt from forbidden server: '{hello.ServerName}'.\", nameof(hello));\n        }\n\n        return ValueTask.FromResult(new QuicServerConnectionOptions\n        {\n            \/\/ ...\n        });\n    },\n});\n\/\/ ...\ntry\n{\n    await listener.AcceptConnectionAsync();\n}\ncatch (QuicException ex) when (ex.QuicError == QuicError.CallbackError &amp;&amp; ex.InnerException is ArgumentException)\n{\n    Console.WriteLine($\"Blocked connection attempt from forbidden server: {ex.InnerException.Message}\");\n}\n<\/code><\/pre>\n<p>The last change in exception space was adding transport error code into <code>QuicException<\/code> (<a href=\"https:\/\/github.com\/dotnet\/runtime\/pull\/88550\">dotnet\/runtime#88550<\/a>). Transport error codes are defined by <a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc9000.html#name-transport-error-codes\">RFC 9000 Transport Error Codes<\/a> and they were already available to <code>System.Net.Quic<\/code> from <a href=\"https:\/\/github.com\/microsoft\/msquic\">MsQuic<\/a>, they just weren&#8217;t exposed publicly. So a new nullable property was added to <code>QuicException<\/code>: <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.quic.quicexception.transporterrorcode?view=net-8.0\"><code>TransportErrorCode<\/code><\/a>. We&#8217;d like to thank a community contributor <a href=\"https:\/\/github.com\/AlexRadch\">AlexRadch<\/a> who implemented this change in <a href=\"https:\/\/github.com\/dotnet\/runtime\/pull\/88550\">dotnet\/runtime#88614<\/a>.<\/p>\n<h2>Sockets<\/h2>\n<p>The most impactful change done in socket space was significantly lowering allocations for connection-less (UDP) sockets (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/30797\">dotnet\/runtime#30797<\/a>). One of the biggest contributors to allocations when working with UDP sockets was allocating a new <code>EndPoint<\/code> object (and supporting allocations like <code>IPAddress<\/code>) on each call of <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.sockets.socket.receivefrom?view=net-8.0\"><code>Socket.ReceiveFrom<\/code><\/a>. To mitigate this, a set of new APIs working with <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.socketaddress?view=net-8.0\"><code>SocketAddress<\/code><\/a> instead (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/87397\">dotnet\/runtime#87397<\/a>) was introduced. <code>SocketAddress<\/code> 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.<\/p>\n<p>Moreover, the newly added <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.sockets.socket.receivefrom?view=net-8.0#system-net-sockets-socket-receivefrom(system-span((system-byte)\"><code>ReceiveFrom<\/code><\/a>-system-net-sockets-socketflags-system-net-socketaddress)) and <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.sockets.socket.receivefromasync?view=net-8.0#system-net-sockets-socket-receivefromasync(system-memory((system-byte)\"><code>ReceiveFromAsync<\/code><\/a>-system-net-sockets-socketflags-system-net-socketaddress-system-threading-cancellationtoken)) overloads do not instantiate a new <code>IPEndPoint<\/code> on each call but rather mutate the provided <code>receivedAddress<\/code> parameter in place. All this together can be used to make UDP socket code more efficient:<\/p>\n<pre><code class=\"language-csharp\">\/\/ Same initialization code as before, no change here.\nSocket server = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);\nSocket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);\nbyte[] message = Encoding.UTF8.GetBytes(\"Hello world!\");\nbyte[] buffer = new byte[1024];\nIPEndPoint endpoint = new IPEndPoint(IPAddress.Loopback, 12345);\nserver.Bind(endpoint);\n\n\/\/ --------\n\/\/ Original code that would allocate IPEndPoint for each ReceiveFromAsync:\nTask&lt;SocketReceiveFromResult&gt; receiveTaskOrig = server.ReceiveFromAsync(buffer, SocketFlags.None, endpoint);\nawait client.SendToAsync(message, SocketFlags.None, endpoint);\nSocketReceiveFromResult resultOrig = await receiveTaskOrig;\n\nConsole.WriteLine(Encoding.UTF8.GetString(buffer, 0, result.ReceivedBytes) + \" from \" + result.RemoteEndPoint);\n\/\/ Prints:\n\/\/ Hello world! from 127.0.0.1:59769\n\n\/\/ --------\n\/\/ New variables that can be re-used for subsequent calls:\nSocketAddress receivedAddress = endpoint.Serialize();\nSocketAddress targetAddress = endpoint.Serialize();\n\n\/\/ New code that will mutate provided SocketAddress for each ReceiveFromAsync:\nValueTask&lt;int&gt; receiveTaskNew = server.ReceiveFromAsync(buffer, SocketFlags.None, receivedAddress);\nawait client.SendToAsync(message, SocketFlags.None, targetAddress);\nvar length = await receiveTaskNew;\n\nConsole.WriteLine(Encoding.UTF8.GetString(buffer, 0, length) + \" from \" + receivedAddress);\n\/\/ Prints:\n\/\/ Hello world! from InterNetwork:16:{233,121,127,0,0,1,0,0,0,0,0,0,0,0}<\/code><\/pre>\n<p>On top of that, work with <code>SocketAddress<\/code> was improved in <a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/86872\">dotnet\/runtime#86872<\/a>. <code>SocketAddress<\/code> now has several additional members that make it more useful on its own:<\/p>\n<ul>\n<li>getter <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.socketaddress.buffer?view=net-8.0\"><code>Buffer<\/code><\/a>: to access the whole underlying address buffer.<\/li>\n<li>setter <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.socketaddress.size?view=net-8.0\"><code>Size<\/code><\/a>: to be able to adjust the above mentioned buffer size (only to a smaller size).<\/li>\n<li>static <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.socketaddress.getmaximumaddresssize?view=net-8.0\"><code>GetMaximumAddressSize<\/code><\/a>: to get the necessary buffer size based on the address type.<\/li>\n<li>interface <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.socketaddress.equals?view=net-8.0#system-net-socketaddress-equals(system-net-socketaddress)\"><code>IEquatable&lt;SocketAddress&gt;<\/code><\/a>: <code>SocketAddress<\/code> 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).<\/li>\n<\/ul>\n<p>And lastly, some of the internally made copies of the IP address data were removed to make it more performant.<\/p>\n<h2>Networking Primitives<\/h2>\n<h3>MIME Types<\/h3>\n<p>Adding missing MIME types was one of the most upvoted issues in the networking space (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/1489\">dotnet\/runtime#1489<\/a>). This was a mostly community-driven change that lead to the <a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/85807\">dotnet\/runtime#85807<\/a> 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 (<a href=\"https:\/\/www.iana.org\/assignments\/media-types\/media-types.xhtml\">IANA Media Types<\/a>). For this preparatory work, we&#8217;d like to thank community contributors <a href=\"https:\/\/github.com\/Bilal-io\">Bilal-io<\/a> and <a href=\"https:\/\/github.com\/mmarinchenko\">mmarinchenko<\/a>.<\/p>\n<h3>IPNetwork<\/h3>\n<p>Another new API addition in .NET 8 is the new type <code>IPNetwork<\/code> (<a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\/79946\">dotnet\/runtime#79946<\/a>). The struct allows specifying classless IP sub-networks as defined in <a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc4632\">RFC 4632<\/a>. For example:<\/p>\n<ul>\n<li><code>127.0.0.0\/8<\/code> for a class-less definition corresponding to a class A subnet.<\/li>\n<li><code>42.42.128.0\/17<\/code> for a class-less subnet of 2<sup>15<\/sup> addresses.<\/li>\n<li><code>2a01:110:8012::\/100<\/code> for IPv6 subnet of 2<sup>28<\/sup> addresses.<\/li>\n<\/ul>\n<p>The new API offers construction either from an <code>IPAddress<\/code> and prefix length with the <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.ipnetwork.-ctor?view=net-8.0#system-net-ipnetwork-ctor(system-net-ipaddress-system-int32)\">constructor<\/a> or by parsing from string via <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.ipnetwork.tryparse?view=net-8.0\"><code>TryParse<\/code><\/a> or <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.ipnetwork.parse?view=net-8.0\"><code>Parse<\/code><\/a>. On top of that, it allows checking if an <code>IPAddress<\/code> belongs to the subnet with <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.net.ipnetwork.contains?view=net-8.0\"><code>Contains<\/code><\/a> method. Sample usage can look like this:<\/p>\n<pre><code class=\"language-csharp\">\/\/ IPv4 with manual construction.\nIPNetwork ipNet = new IPNetwork(new IPAddress(new byte[] { 127, 0, 0, 0 }), 8);\nIPAddress ip1 = new IPAddress(new byte[] { 255, 0, 0, 1 });\nIPAddress ip2 = new IPAddress(new byte[] { 127, 0, 0, 10 });\nConsole.WriteLine($\"{ip1} {(ipNet.Contains(ip1) ? \"belongs\" : \"doesn't belong\")} to {ipNet}\");\nConsole.WriteLine($\"{ip2} {(ipNet.Contains(ip2) ? \"belongs\" : \"doesn't belong\")} to {ipNet}\");\n\/\/ Prints:\n\/\/ 255.0.0.1 doesn't belong to 127.0.0.0\/8\n\/\/ 127.0.0.10 belongs to 127.0.0.0\/8\n\n\/\/ IPv6 with parsing.\nIPNetwork ipNet = IPNetwork.Parse(\"2a01:110:8012::\/96\");\nIPAddress ip1 = IPAddress.Parse(\"2a01:110:8012::1742:4244\");\nIPAddress ip2 = IPAddress.Parse(\"2a01:110:8012:1010:914e:2451:16ff:ffff\");\nConsole.WriteLine($\"{ip1} {(ipNet.Contains(ip1) ? \"belongs\" : \"doesn't belong\")} to {ipNet}\");\nConsole.WriteLine($\"{ip2} {(ipNet.Contains(ip2) ? \"belongs\" : \"doesn't belong\")} to {ipNet}\");\n\/\/ Prints:\n\/\/ 2a01:110:8012::1742:4244 belongs to 2a01:110:8012::\/96\n\/\/ 2a01:110:8012:1010:914e:2451:16ff:ffff doesn't belong to 2a01:110:8012::\/96<\/code><\/pre>\n<p>Note that this type should not be confused with the <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.aspnetcore.httpoverrides.ipnetwork\"><code>Microsoft.AspNetCore.HttpOverrides.IPNetwork<\/code><\/a> class that existed in ASP.NET Core since 1.0. We expect that ASP.NET APIs will eventually migrate to the new <code>System.Net.IPNetwork<\/code> type (<a href=\"https:\/\/github.com\/dotnet\/aspnetcore\/issues\/46157\">dotnet\/aspnetcore#46157<\/a>).<\/p>\n<h2>Final Notes<\/h2>\n<p>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&#8217;re more interested in performance improvements, you should check out <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/performance-improvements-in-net-8\/#networking\">networking section<\/a> in Stephen&#8217;s huge performance blog post. And if you have any questions or find any bugs, you can reach out to us in <a href=\"https:\/\/github.com\/dotnet\/runtime\/issues\">dotnet\/runtime<\/a> repository.<\/p>\n<p>Lastly, I&#8217;d like to thank my co-authors:<\/p>\n<ul>\n<li><a href=\"https:\/\/github.com\/antonfirsov\">@antonfirsov<\/a> who wrote <a href=\"#metrics\">Metrics<\/a>.<\/li>\n<li><a href=\"https:\/\/github.com\/CarnaViire\">@CarnaViire<\/a> who wrote <a href=\"#httpclientfactory\">HttpClientFactory<\/a>.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Introducing new networking features in .NET 8 including HTTP space, metrics, sockets and more!<\/p>\n","protected":false},"author":47956,"featured_media":49865,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[685,7591],"tags":[7701,7676,7773,7774],"class_list":["post-49504","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-dotnet","category-networking","tag-dotnet-8","tag-http","tag-metrics","tag-sockets"],"acf":[],"blog_post_summary":"<p>Introducing new networking features in .NET 8 including HTTP space, metrics, sockets and more!<\/p>\n","_links":{"self":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts\/49504","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/users\/47956"}],"replies":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/comments?post=49504"}],"version-history":[{"count":0,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/posts\/49504\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/media\/49865"}],"wp:attachment":[{"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/media?parent=49504"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/categories?post=49504"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/dotnet\/wp-json\/wp\/v2\/tags?post=49504"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}