Home > Uncategorized > The Static HttpClient That Wouldn’t Rotate: A Tale of Pooled Connections

The Static HttpClient That Wouldn’t Rotate: A Tale of Pooled Connections

The symptom

A production .NET service had been running fine for months. It made outbound HTTP calls through a rotating proxy provider — the kind that promises a new exit IP for each request. Then one day, requests started timing out. Not failing cleanly with a 4xx or 5xx. Timing out. Each one sat there for thirty seconds or more before giving up.

The fix that worked, every time, was restarting IIS.

That’s an interesting fix. It tells you the problem isn’t with the code’s logic — the code is the same after the restart as before. It tells you the problem is with state that the application is accumulating over its lifetime. And it tells you that whatever that state is, it lives somewhere inside the application’s process, not on disk or in a database.

The setup

Here’s the shape of the code:

csharp

private static readonly HttpClient _httpClient = new HttpClient(new HttpClientHandler
{
Proxy = _rotatingProxy,
UseProxy = true
});

A static HttpClient. A rotating proxy. By every piece of Microsoft guidance written in the last decade, this is correct. “Don’t create a new HttpClient per request — it’ll exhaust your sockets.” So a static instance it is.

The intent: every outbound request goes through the proxy, the proxy assigns a different exit IP each time, and the upstream service sees a varied stream of source addresses rather than a single hammering client.

The reality, as we’ll see, was very different.

Anti-pattern #1: assuming HttpClient opens a new connection per request

This is the foundational misunderstanding. HttpClient does not open a new TCP connection for each request. It maintains a connection pool, keyed by destination host, and reuses pooled connections wherever possible. This is HTTP keep-alive doing its job — and it’s a good thing for the general case, because TCP and TLS handshakes are expensive.

But here’s the consequence for a rotating proxy: rotation happens per TCP connection, not per HTTP request. If your HttpClient opens one TCP connection on the first request and then reuses that connection for every subsequent request, you get one exit IP, forever. Your “rotating” proxy is effectively a sticky proxy, because nothing is ever telling it you want a new connection.

The static HttpClient makes this worse, because the connection pool lives for the lifetime of the process. There is no natural moment at which the pooled connection gets replaced. It just sits there, being reused, until something forces it to close.

Anti-pattern #2: no timeout configured

HttpClient.Timeout defaults to 100 seconds. Most developers never set it. For most APIs this is fine — if the call is going to succeed, it’ll succeed in well under 100 seconds.

But “100 seconds” becomes a serious problem when something on the network silently fails. A pooled TCP connection that’s been killed by an intermediate firewall, a load balancer that’s stopped routing your traffic, a remote server that’s decided to tarpit you — none of these produce a clean error. They produce a hang. And your code will sit in that hang for the full timeout duration.

The combination of “no timeout” + “pooled connection that’s gone bad” is the textbook recipe for the mysterious thirty-second-plus pause that I described at the top. The connection looks fine to your code. The pool hands it out. The request goes out. Nothing comes back. You wait.

Anti-pattern #3: silent bans look identical to network glitches

Modern anti-abuse systems rarely respond to suspected scraping or hammering with a clean 403 Forbidden. A 403 is a gift to the attacker — it tells them immediately that they need to rotate. Far more effective is to do nothing: accept the TCP connection, accept the request, and then never send a response. The attacker’s client hangs. Their thread is consumed. Their queue backs up. Their logs fill with timeouts that look indistinguishable from random network failures.

If you’re not expecting this behaviour, you’ll spend a lot of time chasing phantom network problems, blaming your cloud provider, blaming your proxy, blaming DNS. The truth — that you’ve been quietly added to a deny list — is invisible from the symptoms alone.

When you combine this with the pooled-connection problem, you get a particularly nasty failure mode: you get one exit IP from your proxy, you hammer the upstream API from that single IP, the upstream API decides you’re abusive and starts tarpitting that IP, and now every single request hangs because they’re all going through the same pooled connection to the same banned IP. The only way out is to break the connection — which an IIS restart conveniently does as a side effect.

What’s actually going on

Putting these three pieces together, the mechanism is:

  1. The application starts and opens its first outbound connection through the proxy. The proxy assigns exit IP X.
  2. .NET pools that connection and reuses it for every subsequent request.
  3. Every request to the upstream service comes from IP X.
  4. Upstream eventually notices the volume from IP X and starts silently dropping requests.
  5. Now every request hangs for the default 100-second timeout, or however long the underlying TCP layer takes to give up.
  6. Restarting the application destroys the connection pool, forces a new TCP connection on the next request, and the proxy assigns a new exit IP. Service is restored — until the cycle repeats.

The root cause is not the proxy. The proxy is doing what it was told. The root cause is that the application is never asking it for a new connection.

The pattern: disable keep-alive when you actually want rotation

The fix is one line:

csharp

client.DefaultRequestHeaders.ConnectionClose = true;

This sets the Connection: close header on every outgoing request. It tells .NET’s connection pool not to reuse the underlying TCP socket after the response. The next request opens a fresh connection. The proxy assigns a fresh exit IP. Rotation now works the way you expected it to work all along.

The complete pattern looks like this:

csharp

private static readonly HttpClient _httpClient = BuildHttpClient();
private static HttpClient BuildHttpClient()
{
var handler = new HttpClientHandler
{
Proxy = _rotatingProxy,
UseProxy = true
};
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(15)
};
client.DefaultRequestHeaders.ConnectionClose = true;
return client;
}

Three things to notice:

The HttpClient is still static. We haven’t abandoned the standard guidance. The object itself is long-lived; it’s just configured not to pool connections internally.

The timeout is now sane. Fifteen seconds is appropriate for most API calls. If something goes wrong — a tarpit, a network glitch, anything — we fail fast and try again, rather than blocking a thread for a minute and a half.

There’s no manual connection management. No tracking of connection age, no recycling logic, no locks around pool rebuilds. The framework handles it because we’ve told it to handle it the right way.

The cost, and when not to do this

Disabling keep-alive isn’t free. Every request now pays the full cost of a TCP handshake and a TLS negotiation. Through a proxy, that’s typically 400–800ms of overhead per request, on top of whatever the upstream service itself takes.

If you’re making thousands of requests per second to the same endpoint and you want a stable identity from the upstream service’s perspective, do not do this. Keep-alive exists for excellent reasons. The pattern in this post is specifically for the case where you have a rotating proxy and you actively want to defeat connection reuse so that rotation happens.

For high-volume scenarios where you want both rotation and performance, the right answer is usually session tokens — most rotating proxy providers let you encode a session identifier in the proxy username, and you can vary that token per request to force rotation without giving up keep-alive on individual sessions. That’s a more involved pattern and a topic for another post.

The broader lesson

The interesting thing about this bug is how each of the three anti-patterns is, in isolation, considered best practice or at least defensible. Static HttpClient is recommended. Default timeouts are what you get if you don’t think about them. And nobody designs their code around the possibility of being silently banned by an upstream service.

It’s the interaction between these three things, plus the presence of a rotating proxy, that produces the failure. And the failure mode — a hang, not an error — is the worst possible signal, because it tells you nothing useful and points you in completely the wrong direction.

The takeaway isn’t “always disable keep-alive” or “always set short timeouts.” The takeaway is that when your code interacts with infrastructure that has its own behaviours — proxies, load balancers, anti-abuse layers — the default settings of your HTTP client may not match the assumptions you’re making about how that infrastructure works. Take a moment to ask: does my code’s connection lifecycle match what I actually want to happen at the network level? If you have a rotating proxy and you’ve never thought about connection pooling, the answer is almost certainly no.

  1. No comments yet.
  1. No trackbacks yet.

Leave a comment