Si alguna vez has encontrado un SocketException: Address already in use o has visto cómo una aplicación en .NET se queda sin sockets disponibles después de unas horas de trabajo con un proxy, el problema casi siempre radica en el uso incorrecto de HttpClient. En este artículo, analizaremos cómo configurar un proxy a través de SocketsHttpHandler, conectar SOCKS5 y organizar la rotación de IP para que el pool de conexiones no se convierta en un cuello de botella.
Por qué HttpClient "consume" sockets
Un error clásico es crear una nueva instancia de HttpClient para cada solicitud dentro de using. Al trabajar con proxies, este problema se agrava: cada conexión a través de un servidor proxy requiere un handshake TCP separado, y si además cambias el proxy sobre la marcha, el pool de conexiones no se reutiliza, y los sockets quedan en estado TIME_WAIT.
En Windows, esto es especialmente crítico: por defecto, hay un número limitado de puertos dinámicos disponibles (generalmente 16384), y si la aplicación analiza datos a través de un proxy con alta intensidad de solicitudes, todos los puertos pueden agotarse en cuestión de minutos. En Linux, la situación es un poco más suave gracias a la configuración del núcleo, pero el problema persiste si no se reutilizan las conexiones.
El antiguo HttpClientHandler, disponible desde los tiempos de .NET Framework, no proporciona un control flexible sobre el ciclo de vida de las conexiones. Por esta razón, desde .NET Core 2.1 se recomienda utilizar SocketsHttpHandler — está completamente escrito en código administrado y proporciona acceso directo al pool de conexiones, a los timeouts de DNS y a la configuración del proxy.
SocketsHttpHandler: para qué sirve
SocketsHttpHandler es la capa de transporte sobre la que se basa HttpClient por defecto en Linux y macOS desde .NET Core 2.1, y a partir de .NET 5 — también en Windows. A diferencia del antiguo WinHttpHandler, este:
- permite establecer
PooledConnectionLifetime— el tiempo de vida de la conexión en el pool, lo que es crítico al trabajar con proxies rotativos; - proporciona acceso a
IWebProxya través del delegadoProxy, que se puede sobrescribir programáticamente sobre la marcha; - soporta
ConnectCallback— un hook de bajo nivel para reemplazar el transporte, que es necesario para SOCKS5; - libera correctamente los sockets al expirar el
PooledConnectionIdleTimeout, sin dejar basura en el sistema.
La configuración básica se ve así:
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
MaxConnectionsPerServer = 50,
AutomaticDecompression = DecompressionMethods.All
};
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
La regla principal: HttpClient con este handler debe ser un singleton o creado a través de IHttpClientFactory — no lo recrees para cada solicitud, de lo contrario, todo el sentido del pool de conexiones se pierde.
Configuración del proxy a través de SocketsHttpHandler
Para proxies HTTP/HTTPS, es suficiente establecer la propiedad Proxy a nivel de handler. Esto funciona para proxies de centros de datos y proxies residenciales que proporcionan acceso a través del protocolo HTTP CONNECT:
var proxy = new WebProxy("http://proxy-host:8000")
{
Credentials = new NetworkCredential("login", "password")
};
var handler = new SocketsHttpHandler
{
Proxy = proxy,
UseProxy = true,
PooledConnectionLifetime = TimeSpan.FromMinutes(3)
};
var client = new HttpClient(handler);
var response = await client.GetAsync("https://example.com");
Console.WriteLine(await response.Content.ReadAsStringAsync());
Si necesitas conectar diferentes proxies para diferentes hosts (por ejemplo, la autenticación a un dominio se hace directamente, y el scraping — a través de un proxy), utiliza un delegado en lugar de un objeto estático:
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(),
UseProxy = true
};
handler.Proxy = new CustomProxy(uri =>
{
return uri.Host.Contains("target-site")
? new Uri("http://proxy-host:8000")
: null;
});
Para tareas de scraping en marketplaces — por ejemplo, monitoreo de precios en Wildberries o Ozon — a menudo se combinan ambos enfoques: el tráfico principal pasa a través de proxies de centros de datos como proxies de centros de datos, mientras que las solicitudes a servicios de captcha o autenticación — directamente.
Soporte para SOCKS5 en .NET
A partir de .NET 6, SocketsHttpHandler obtuvo soporte nativo para SOCKS4/SOCKS4a/SOCKS5 al especificar el esquema en la URI del proxy — no se requieren bibliotecas externas:
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
{
Credentials = new NetworkCredential("login", "password")
},
UseProxy = true
};
var client = new HttpClient(handler);
var response = await client.GetAsync("https://httpbin.org/ip");
Un matiz importante: antes de .NET 6, esta posibilidad no existía, y los desarrolladores tenían que escribir su propio ConnectCallback, que realizaba manualmente el handshake de SOCKS5 antes de establecer la conexión TLS. Si tu proyecto aún está en .NET Core 3.1 o .NET 5, este camino sigue siendo relevante:
var handler = new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true
};
await socket.ConnectAsync("proxy-host", 1080, cancellationToken);
await PerformSocks5Handshake(socket, context.DnsEndPoint, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
};
En la práctica, para proyectos de producción es más fácil actualizar a .NET 6+ y utilizar la implementación integrada — está cubierta por pruebas en tiempo de ejecución y no requiere soporte propio para el protocolo de handshake.
Rotación de proxies sin recrear HttpClient
Un error común al implementar la rotación de IP es crear un nuevo HttpClient (y, por lo tanto, un nuevo handler y un nuevo pool de conexiones) en cada cambio de proxy. Esto garantiza la acumulación de sockets no cerrados. El enfoque correcto es implementar tu propio IWebProxy, que devuelva una dirección de proxy diferente en cada llamada:
public class RotatingProxy : IWebProxy
{
private readonly string[] _proxies;
private int _index = -1;
public RotatingProxy(string[] proxies) => _proxies = proxies;
public ICredentials Credentials { get; set; }
public Uri GetProxy(Uri destination)
{
var next = Interlocked.Increment(ref _index) % _proxies.Length;
return new Uri(_proxies[next]);
}
public bool IsBypassed(Uri host) => false;
}
Este clase se conecta al mismo SocketsHttpHandler:
var handler = new SocketsHttpHandler
{
Proxy = new RotatingProxy(new[]
{
"http://proxy1:8000",
"http://proxy2:8000",
"http://proxy3:8000"
}),
UseProxy = true,
PooledConnectionLifetime = TimeSpan.FromSeconds(60)
};
var client = new HttpClient(handler);
Este esquema reutiliza el pool interno de sockets, pero cada solicitud realmente pasa a través de una nueva IP. Si estás construyendo pools de conexiones paralelas para cada proxy (por velocidad), establece un PooledConnectionLifetime razonable — generalmente de 30 a 90 segundos, para que las conexiones no caduquen más rápido que la IP en el lado del proveedor de proxy. Para tareas como el scraping de Avito o la recopilación de datos de la API de Instagram, es más conveniente utilizar proxies residenciales con rotación en el lado del proveedor — entonces en el código no es necesario cambiar la dirección, basta con mantener un "punto de entrada" con un cambio automático de IP de salida.
PooledConnectionLifetime y rotación de DNS
Si el proveedor de proxy utiliza rotación de DNS (un nombre de host se resuelve en diferentes IP en cada solicitud), es crítico establecer un PooledConnectionLifetime limitado. Por defecto, es Infinite, y .NET mantendrá la conexión a la antigua IP durante el tiempo que sea necesario, ignorando las actualizaciones de DNS.
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromSeconds(30), // recreamos la conexión
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(15),
MaxConnectionsPerServer = 20
};
Esto es especialmente importante para proxies móviles, donde el cambio de IP por parte del proveedor puede ocurrir cada 1-10 minutos en el marco de la rotación del operador de telefonía móvil. Si mantienes la conexión más tiempo, las solicitudes seguirán yendo a través de una IP ya liberada, y a veces — incluso recibirán un error de timeout. Para tales escenarios, los proxies móviles son muy adecuados — ofrecen la mayor confianza por parte de los sistemas antifraude, pero requieren una configuración cuidadosa de TTL de conexiones a nivel de cliente.
Un valor corto de PooledConnectionLifetime no es gratuito — cada recreación de la conexión significa un nuevo TCP+TLS handshake, lo que añade 50-200 ms de latencia. El equilibrio entre la frescura de la IP y el rendimiento se determina empíricamente: para tareas de monitoreo de precios, 60-120 segundos son suficientes, para la agricultura de cuentas de alta frecuencia — a veces se necesitan 10-20 segundos.
Comparación de tipos de proxies para aplicaciones .NET
| Tipo de proxy | Protocolo | PooledConnectionLifetime recomendado | Tareas |
|---|---|---|---|
| Proxies de centros de datos | HTTP/HTTPS, SOCKS5 | 5-10 minutos | Scraping, integraciones de API, alta velocidad |
| Proxies residenciales | HTTP/HTTPS, SOCKS5 | 30-90 segundos | Monitoreo de precios, elusión de bloqueos geográficos |
| Proxies móviles | HTTP/HTTPS, SOCKS5 | 10-30 segundos | Automatización en redes sociales, API sensibles a antifraude |
IHttpClientFactory y clientes tipados con proxy
En proyectos de ASP.NET Core, se recomienda registrar HttpClient a través de IHttpClientFactory — esto libera al desarrollador de la responsabilidad de gestionar el ciclo de vida de los handlers y reutiliza automáticamente los SocketsHttpHandler internos.
builder.Services.AddHttpClient("ProxyClient")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
Proxy = new WebProxy(new Uri("socks5://proxy-host:1080"))
{
Credentials = new NetworkCredential("login", "password")
},
UseProxy = true,
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
Presta atención a SetHandlerLifetime — este es un mecanismo separado para la rotación de los propios handlers dentro de la fábrica (por defecto 2 minutos), que no debe confundirse con PooledConnectionLifetime de una conexión individual. La fábrica crea periódicamente un nuevo handler, pero las conexiones antiguas continúan existiendo mientras no se completen las solicitudes activas — el recolector de basura las limpiará por sí mismo.
Para escenarios con múltiples pools de proxies, es conveniente registrar varios clientes nombrados — por ejemplo, uno para EE. UU. y otro para Europa, si la tarea es probar anuncios de Google Ads o Facebook Ads desde diferentes regiones:
builder.Services.AddHttpClient("US-Proxy")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
Proxy = new WebProxy("http://us-proxy-host:8000"),
UseProxy = true
});
builder.Services.AddHttpClient("EU-Proxy")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
Proxy = new WebProxy("http://eu-proxy-host:8000"),
UseProxy = true
});
Diagnóstico de fugas de sockets
Si sospechas que hay acumulación de sockets, no adivines — verifica. En Linux, una forma rápida de ver la cantidad de conexiones abiertas del proceso:
# cantidad de conexiones en TIME_WAIT
ss -tan state time-wait | wc -l
# todas las conexiones de un proceso específico (PID)
ls -la /proc/{PID}/fd | grep socket | wc -l
En Windows, una estadística similar se obtiene con el comando netstat -ano filtrando por el PID del proceso. Para un diagnóstico más profundo a nivel de .NET, utiliza la herramienta integrada dotnet-counters, que muestra métricas de System.Net.Http en tiempo real:
dotnet-counters monitor -p {PID} --counters System.Net.Http
# contadores clave:
# current-connections
# connections-duration
# requests-failed-rate
Si current-connections crece de manera monótona y nunca disminuye — es un signo directo de que el handler se está recreando con demasiada frecuencia, o que PooledConnectionIdleTimeout está configurado en Infinite, y las solicitudes dejan de completarse correctamente (por ejemplo, el cuerpo de la respuesta no se lee completamente, y la conexión no regresa al pool).
Otra causa común de fugas es un response.Content olvidado, que no fue leído o no fue desechado. Siempre utiliza using var response = ... o una llamada explícita a Dispose() después de terminar de trabajar con la respuesta.
Conclusión
El trabajo correcto con proxies en .NET se basa en tres principios: utiliza SocketsHttpHandler en lugar de implementaciones obsoletas, mantén HttpClient como singleton (o a través de IHttpClientFactory), y realiza la rotación de proxies a través de un IWebProxy personalizado, en lugar de recrear todo el cliente. A partir de .NET 6, SOCKS5 se admite de forma nativa, lo que elimina la necesidad de escribir un protocolo de handshake propio.
Si tu aplicación analiza marketplaces o recopila datos a través de API con alta intensidad de solicitudes, considera los proxies de centros de datos — ofrecen la máxima velocidad con la configuración correcta del pool de conexiones. Para tareas donde la anonimidad y la similitud con el tráfico de usuarios normales son importantes, es mejor optar por proxies residenciales, y para trabajar con API móviles y plataformas sociales — proxies móviles con un corto tiempo de vida de conexiones en el código.