From c4ee32bd3ddcbb917aa71e3994faf6ca0fb000f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 01:56:06 +0000 Subject: [PATCH] fix: reuse SocketsHttpHandler to prevent SNAT port exhaustion (closes #198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default HttpClient factory created a fresh SocketsHttpHandler on every request, which discarded the TCP connection pool immediately. This caused SNAT port exhaustion on Azure and similar environments. Introduce a process-wide lazy-singleton SocketsHttpHandler for the common case (no proxy, no custom handler transformers, default cert strategy, default decompression). Each request still gets its own HttpClient wrapper (using HttpClient(handler, disposeHandler=false)) so per-request Timeout and httpClientTransformers continue to work correctly. The handler — and its connection pool — is never disposed, giving the same behaviour as a recommended DI-managed IHttpClientFactory. When any handler customisation is present the factory falls back to creating a fresh handler per request (existing behaviour), so there is no regression for users who already configure a custom factory. Co-Authored-By: Claude Sonnet 4.6 --- src/FsHttp/Defaults.fs | 90 +++++++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/src/FsHttp/Defaults.fs b/src/FsHttp/Defaults.fs index 747cd6bf..83deba7f 100644 --- a/src/FsHttp/Defaults.fs +++ b/src/FsHttp/Defaults.fs @@ -10,40 +10,68 @@ open System.Text.Json let defaultJsonDocumentOptions = JsonDocumentOptions() let defaultJsonSerializerOptions = JsonSerializerOptions JsonSerializerDefaults.Web -let defaultHttpClientFactory (config: Config) = - let handler = +let defaultDecompressionMethods = [ DecompressionMethods.All ] + +// A long-lived shared handler used when no handler customisations are required. +// Reusing a single SocketsHttpHandler preserves the TCP connection pool across requests, +// preventing SNAT port exhaustion on Azure and similar environments (issue #198). +// The HttpClient created per-request uses disposeHandler=false so the pool is never torn down. +let private sharedDefaultHandler = + lazy ( + let decompression = defaultDecompressionMethods |> List.fold (fun c n -> c ||| n) DecompressionMethods.None new SocketsHttpHandler( - UseCookies = false, - PooledConnectionLifetime = TimeSpan.FromMinutes 5.0) - let ignoreSslIssues = - match config.certErrorStrategy with - | Default -> false - | AlwaysAccept -> true - if ignoreSslIssues then - do handler.SslOptions <- - let options = Security.SslClientAuthenticationOptions() - let callback = Security.RemoteCertificateValidationCallback(fun sender cert chain errors -> true) - do options.RemoteCertificateValidationCallback <- callback - options - do handler.AutomaticDecompression <- - config.defaultDecompressionMethods - |> List.fold (fun c n -> c ||| n) DecompressionMethods.None - let handler = config.httpClientHandlerTransformers |> List.fold (fun c n -> n c) handler + UseCookies = false, + PooledConnectionLifetime = TimeSpan.FromMinutes 5.0, + AutomaticDecompression = decompression)) - match config.proxy with - | Some proxy -> - let webProxy = WebProxy(proxy.url) +let private canUseSharedHandler (config: Config) = + config.certErrorStrategy = Default + && List.isEmpty config.httpClientHandlerTransformers + && config.proxy.IsNone + && config.defaultDecompressionMethods = defaultDecompressionMethods + +let defaultHttpClientFactory (config: Config) = + let handler, disposeHandler = + if canUseSharedHandler config then + // Return the shared handler without ownership. + // The per-request HttpClient is a lightweight wrapper; only the handler holds connections. + sharedDefaultHandler.Value, false + else + let handler = + new SocketsHttpHandler( + UseCookies = false, + PooledConnectionLifetime = TimeSpan.FromMinutes 5.0) + let ignoreSslIssues = + match config.certErrorStrategy with + | Default -> false + | AlwaysAccept -> true + if ignoreSslIssues then + do handler.SslOptions <- + let options = Security.SslClientAuthenticationOptions() + let callback = Security.RemoteCertificateValidationCallback(fun sender cert chain errors -> true) + do options.RemoteCertificateValidationCallback <- callback + options + do handler.AutomaticDecompression <- + config.defaultDecompressionMethods + |> List.fold (fun c n -> c ||| n) DecompressionMethods.None + let handler = config.httpClientHandlerTransformers |> List.fold (fun c n -> n c) handler - match proxy.credentials with - | Some cred -> - webProxy.UseDefaultCredentials <- false - webProxy.Credentials <- cred - | None -> webProxy.UseDefaultCredentials <- true + match config.proxy with + | Some proxy -> + let webProxy = WebProxy(proxy.url) - handler.Proxy <- webProxy - | None -> () + match proxy.credentials with + | Some cred -> + webProxy.UseDefaultCredentials <- false + webProxy.Credentials <- cred + | None -> webProxy.UseDefaultCredentials <- true - let client = new HttpClient(handler) + handler.Proxy <- webProxy + | None -> () + + handler, true + + let client = new HttpClient(handler, disposeHandler) do config.timeout |> Option.iter (fun timeout -> client.Timeout <- timeout) client @@ -52,9 +80,7 @@ let defaultHeadersAndBodyPrintMode = { maxLength = Some 7000 } -let defaultDecompressionMethods = [ DecompressionMethods.All ] - -let defaultConfig = +let defaultConfig = { timeout = None defaultDecompressionMethods = defaultDecompressionMethods