← Назад к блогу

Прокси в .NET HttpClient: SocketsHttpHandler, SOCKS5 и ротация без утечки сокетов

Разбираем, как правильно настроить прокси в .NET HttpClient с SocketsHttpHandler, подключить SOCKS5 и организовать ротацию IP без socket exhaustion.

📅18 октября 2026 г.

Если вы когда-нибудь ловили SocketException: Address already in use или видели, как у приложения на .NET внезапно заканчиваются доступные сокеты после нескольких часов работы с прокси, — проблема почти всегда в неправильном использовании HttpClient. В этой статье разберём, как настроить прокси через SocketsHttpHandler, подключить SOCKS5 и организовать ротацию IP так, чтобы пул соединений не превращался в узкое место.

Почему HttpClient «съедает» сокеты

Классическая ошибка — создавать новый экземпляр HttpClient на каждый запрос внутри using. При работе с прокси эта проблема усугубляется: каждое соединение через прокси-сервер требует отдельного TCP handshake, а если вы ещё и меняете прокси на лету, пул соединений не переиспользуется, и сокеты зависают в состоянии TIME_WAIT.

На Windows это особенно критично — по умолчанию доступно ограниченное число динамических портов (обычно 16384), и если приложение парсит данные через прокси с высокой интенсивностью запросов, все порты могут закончиться за считанные минуты. На Linux ситуация немного мягче за счёт настроек ядра, но проблема сохраняется, если не переиспользовать соединения.

Старый HttpClientHandler, доступный ещё со времён .NET Framework, не даёт гибкого контроля над жизненным циклом соединений. Именно поэтому начиная с .NET Core 2.1 рекомендуется использовать SocketsHttpHandler — он написан полностью на managed-коде и даёт прямой доступ к пулу соединений, таймаутам DNS и настройкам прокси.

SocketsHttpHandler: зачем он нужен

SocketsHttpHandler — это транспортный уровень, на котором с .NET Core 2.1 построен HttpClient по умолчанию на Linux и macOS, а начиная с .NET 5 — и на Windows. В отличие от старого WinHttpHandler, он:

  • позволяет задать PooledConnectionLifetime — время жизни соединения в пуле, что критично при работе с ротируемыми прокси;
  • даёт доступ к IWebProxy через делегат Proxy, который можно переопределить программно на лету;
  • поддерживает ConnectCallback — низкоуровневый хук для подмены транспорта, что нужно для SOCKS5;
  • корректно освобождает сокеты по истечении PooledConnectionIdleTimeout, не оставляя мусор в системе.

Базовая настройка выглядит так:

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)
};

Главное правило: HttpClient с этим handler-ом должен быть singleton или создаваться через IHttpClientFactory — не пересоздавайте его на каждый запрос, иначе весь смысл пула соединений теряется.

Настройка прокси через SocketsHttpHandler

Для HTTP/HTTPS-прокси достаточно задать свойство Proxy на уровне handler-а. Это работает для прокси дата-центров и резидентных прокси, которые отдают доступ по протоколу 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());

Если нужно подключать разные прокси для разных хостов (например, аутентификация к одному домену идёт напрямую, а скрапинг — через прокси), используйте делегат вместо статичного объекта:

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;
});

Для задач парсинга маркетплейсов — например, мониторинга цен на Wildberries или Ozon — часто комбинируют оба подхода: основной трафик идёт через прокси дата-центров как через прокси дата-центров, а запросы к капча-сервисам или авторизации — напрямую.

Поддержка SOCKS5 в .NET

Начиная с .NET 6, SocketsHttpHandler получил нативную поддержку SOCKS4/SOCKS4a/SOCKS5 через указание схемы в URI прокси — никаких сторонних библиотек не требуется:

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");

Важный нюанс: до .NET 6 такой возможности не было, и разработчикам приходилось писать собственный ConnectCallback, который вручную проводил SOCKS5-рукопожатие перед установлением TLS-соединения. Если ваш проект ещё на .NET Core 3.1 или .NET 5, этот путь всё ещё актуален:

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);
    }
};

На практике для продакшн-проектов проще обновиться до .NET 6+ и использовать встроенную реализацию — она покрыта тестами в рантайме и не требует самостоятельной поддержки протокола рукопожатия.

Ротация прокси без пересоздания HttpClient

Частая ошибка при реализации ротации IP — создание нового HttpClient (а значит, и нового handler-а, и нового пула соединений) на каждую смену прокси. Это гарантированно приводит к накоплению незакрытых сокетов. Правильный подход — реализовать собственный IWebProxy, который возвращает разный адрес прокси при каждом вызове:

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;
}

Подключается этот класс всё к тому же единственному 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);

Такая схема переиспользует внутренний пул сокетов, но при этом каждый запрос действительно уходит через новый IP. Если вы собираете параллельные пулы соединений на каждый прокси (для скорости), выставляйте разумный PooledConnectionLifetime — обычно 30-90 секунд, чтобы соединения не устаревали быстрее, чем IP на стороне провайдера прокси. Для задач вроде парсинга Авито или сбора данных из Instagram API удобнее использовать резидентные прокси с ротацией на стороне провайдера — тогда в коде вообще не нужно менять адрес, достаточно держать одну «входную точку» с автоматической заменой выходного IP.

PooledConnectionLifetime и DNS-ротация

Если провайдер прокси использует DNS-ротацию (один хостнейм резолвится в разные IP при каждом обращении), критично выставить ограниченный PooledConnectionLifetime. По умолчанию он равен Infinite, и .NET будет держать соединение к старому IP сколько угодно долго, игнорируя обновления DNS.

var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromSeconds(30), // пересоздаём соединение
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(15),
    MaxConnectionsPerServer = 20
};

Это особенно важно для мобильных прокси, где смена IP провайдером может происходить каждые 1-10 минут в рамках ротации оператора сотовой связи. Если держать соединение дольше, запросы продолжат идти через уже освобождённый IP, а иногда — и вовсе получат ошибку таймаута. Для подобных сценариев хорошо подходят мобильные прокси — они дают наивысшее доверие со стороны антифрод-систем, но требуют аккуратной настройки TTL соединений именно на уровне клиента.

Короткое значение PooledConnectionLifetime не бесплатно — каждое пересоздание соединения means новый TCP+TLS handshake, что добавляет 50-200 мс задержки. Баланс между свежестью IP и производительностью подбирается эмпирически: для задач мониторинга цен достаточно 60-120 секунд, для высокочастотного фарма аккаунтов — иногда нужно 10-20 секунд.

Сравнение типов прокси для .NET-приложений

Тип прокси Протокол Рекомендуемый PooledConnectionLifetime Задачи
Прокси дата-центров HTTP/HTTPS, SOCKS5 5-10 минут Парсинг, API-интеграции, высокая скорость
Резидентные прокси HTTP/HTTPS, SOCKS5 30-90 секунд Мониторинг цен, обход гео-блокировок
Мобильные прокси HTTP/HTTPS, SOCKS5 10-30 секунд Автоматизация в соцсетях, антифрод-чувствительные API

IHttpClientFactory и typed clients с прокси

В ASP.NET Core проектах рекомендуется регистрировать HttpClient через IHttpClientFactory — это снимает с разработчика ответственность за управление жизненным циклом handler-ов и автоматически переиспользует внутренние SocketsHttpHandler.

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));

Обратите внимание на SetHandlerLifetime — это отдельный механизм ротации самих handler-ов внутри фабрики (по умолчанию 2 минуты), который не стоит путать с PooledConnectionLifetime отдельного соединения. Фабрика периодически создаёт новый handler, но старые соединения продолжают доживать своё время, пока не завершатся активные запросы — garbage collector очистит их сам.

Для сценариев с несколькими пулами прокси удобно регистрировать несколько именованных клиентов — например, отдельно для США и отдельно для Европы, если задача — тестирование рекламы Google Ads или Facebook Ads из разных регионов:

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
    });

Диагностика утечек сокетов

Если подозреваете накопление сокетов, не гадайте — проверяйте. На Linux быстрый способ посмотреть количество открытых соединений процесса:

# количество соединений в TIME_WAIT
ss -tan state time-wait | wc -l

# все соединения конкретного процесса (PID)
ls -la /proc/{PID}/fd | grep socket | wc -l

На Windows аналогичную статистику даёт команда netstat -ano с фильтрацией по PID процесса. Для более глубокой диагностики на уровне .NET используйте встроенный инструмент dotnet-counters, который показывает метрики System.Net.Http в реальном времени:

dotnet-counters monitor -p {PID} --counters System.Net.Http

# ключевые счётчики:
# current-connections
# connections-duration
# requests-failed-rate

Если current-connections растёт монотонно и никогда не снижается — это прямой признак того, что handler пересоздаётся слишком часто, либо PooledConnectionIdleTimeout выставлен в Infinite, а запросы перестают завершаться корректно (например, не читается тело ответа до конца, и соединение не возвращается в пул).

Ещё одна частая причина утечек — забытый response.Content, который не был прочитан или не задиспосился. Всегда используйте using var response = ... или явный вызов Dispose() после завершения работы с ответом.

Заключение

Правильная работа с прокси в .NET строится на трёх принципах: используйте SocketsHttpHandler вместо устаревших реализаций, держите HttpClient как singleton (или через IHttpClientFactory), а ротацию прокси реализуйте через кастомный IWebProxy, а не через пересоздание всего клиента. Начиная с .NET 6 SOCKS5 поддерживается нативно, что избавляет от необходимости писать собственный протокол рукопожатия.

Если ваше приложение парсит маркетплейсы или собирает данные через API с высокой интенсивностью запросов, обратите внимание на прокси дата-центров — они дают максимальную скорость при корректной настройке пула соединений. Для задач, где важна анонимность и схожесть с обычным пользовательским трафиком, лучше подойдут резидентные прокси, а для работы с мобильными API и социальными платформами — мобильные прокси с коротким временем жизни соединений в коде.