Полное руководство по HTTP-прокси в C# и .NET 8: HttpClient, Polly и пулы ротации

Практическое руководство для .NET-разработчиков: настройка HttpClient с прокси, SocketsHttpHandler, Polly для отказоустойчивости, параллельный скрапинг и TLS-конфигурация. Минимум 6 примеров кода.

Полное руководство по HTTP-прокси в C# и .NET 8: HttpClient, Polly и пулы ротации

Работа с HTTP-прокси в .NET 8+ требует понимания нескольких уровней абстракции: от базового WebProxy до продвинутых сценариев с ротацией IP и TLS-пиннингом. Это руководство покажет, как правильно настроить HttpClient, интегрировать Polly для обработки сбоев и построить production-ready сервис ротации прокси.

Базовая настройка: HttpClientHandler + WebProxy

Классический способ использования прокси в .NET — через HttpClientHandler с настроенным WebProxy. Этот подход поддерживает аутентификацию, обход локальных адресов и кастомные настройки.

using System.Net;
using System.Net.Http;

// Создаём прокси с учётными данными
var proxy = new WebProxy("http://gate.proxyhat.com:8080", bypassOnLocal: true)
{
    Credentials = new NetworkCredential("user-country-US", "PASSWORD"),
    BypassList = new[] { "localhost", "127.0.0.1", "*.internal.local" }
};

// Настраиваем HttpClientHandler
var handler = new HttpClientHandler
{
    Proxy = proxy,
    UseProxy = true,
    AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
    AllowAutoRedirect = true,
    MaxAutomaticRedirections = 5
};

// HttpClient предназначен для повторного использования
var httpClient = new HttpClient(handler)
{
    Timeout = TimeSpan.FromSeconds(30)
};

// Использование
var response = await httpClient.GetAsync("https://httpbin.org/ip");
var content = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Response: {content}");

Важно: HttpClient следует создавать один раз и переиспользовать. Создание нового экземпляра на каждый запрос приводит к исчерпанию сокетов (socket exhaustion).

Когда использовать HttpClientHandler

  • Простые сценарии с фиксированным прокси
  • Legacy-проекты (.NET Framework)
  • Когда не нужна динамическая смена прокси

SocketsHttpHandler: современный подход в .NET 8+

SocketsHttpHandler — более производительная и гибкая альтернатива HttpClientHandler. Ключевое преимущество: возможность настройки прокси на уровне каждого запроса через HttpMessageInvoker или callback-функции.

using System.Net;
using System.Net.Http;

var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy("http://gate.proxyhat.com:8080")
    {
        Credentials = new NetworkCredential("user-country-DE", "PASSWORD")
    },
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromMinutes(5), // Важно для ротации!
    PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
    EnableMultipleHttp2Connections = true,
    AutomaticDecompression = DecompressionMethods.All
};

var httpClient = new HttpClient(handler);

// Пример с несколькими запросами
for (int i = 0; i < 10; i++)
{
    var response = await httpClient.GetAsync("https://api.ipify.org?format=json");
    var json = await response.Content.ReadAsStringAsync();
    Console.WriteLine($"Request {i}: {json}");
}

Per-request прокси через ConnectCallback

Для динамической смены прокси на каждый запрос используйте ConnectCallback:

using System.Net;
using System.Net.Http;
using System.Net.Sockets;

// Пул прокси (пример для residential-прокси ProxyHat)
var proxyEndpoints = new[]
{
    ("gate.proxyhat.com", 8080, "user-country-US"),
    ("gate.proxyhat.com", 8080, "user-country-GB"),
    ("gate.proxyhat.com", 8080, "user-country-DE")
};

var random = new Random();

var handler = new SocketsHttpHandler
{
    ConnectCallback = async (context, cancellationToken) =>
    {
        var (host, port, user) = proxyEndpoints[random.Next(proxyEndpoints.Length)];
        
        var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        var proxyEndpoint = new DnsEndPoint(host, port);
        
        await socket.ConnectAsync(proxyEndpoint, cancellationToken);
        
        // Отправляем CONNECT для HTTPS
        var connectRequest = $"CONNECT {context.DnsEndPoint.Host}:{context.DnsEndPoint.Port} HTTP/1.1\r\n" +
                            $"Proxy-Authorization: Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:PASSWORD"))}\r\n" +
                            "\r\n";
        
        var connectBytes = Encoding.ASCII.GetBytes(connectRequest);
        await socket.SendAsync(new ArraySegment<byte>(connectBytes), SocketFlags.None);
        
        // Читаем ответ прокси
        var buffer = new byte[1024];
        var received = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), SocketFlags.None);
        var response = Encoding.ASCII.GetString(buffer, 0, received);
        
        if (!response.Contains("200"))
            throw new InvalidOperationException($"Proxy connection failed: {response}");
        
        return new NetworkStream(socket, ownsSocket: true);
    },
    PooledConnectionLifetime = TimeSpan.FromSeconds(30) // Короткое время жизни для ротации
};

var client = new HttpClient(handler);

Polly: отказоустойчивость для HTTP-запросов

Polly — библиотека для обработки временных сбоев. Комбинация retry и circuit breaker критична для скрапинга через residential-прокси, где отказы — норма.

using Polly;
using Polly.Retry;
using Polly.CircuitBreaker;
using System.Net.Http.Json;

// Стратегия retry с экспоненциальной задержкой
var retryStrategy = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(r => (int)r.StatusCode >= 500 || r.StatusCode == System.Net.HttpStatusCode.TooManyRequests),
        Delay = TimeSpan.FromSeconds(2),
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
        OnRetry = args =>
        {
            Console.WriteLine($"Retry {args.AttemptNumber} after {args.RetryDelay}");
            return ValueTask.CompletedTask;
        }
    })
    .Build();

// Circuit breaker для защиты от каскадных сбоев
var circuitBreaker = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(r => (int)r.StatusCode >= 500),
        FailureRatio = 0.5,
        MinimumThroughput = 10,
        BreakDuration = TimeSpan.FromSeconds(30),
        OnOpened = args =>
        {
            Console.WriteLine($"Circuit opened due to: {args.Outcome}");
            return ValueTask.CompletedTask;
        }
    })
    .Build();

// Комбинированная стратегия
var resiliencePipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(r => (int)r.StatusCode >= 500 || r.StatusCode == System.Net.HttpStatusCode.TooManyRequests),
        Delay = TimeSpan.FromSeconds(1),
        MaxRetryAttempts = 3,
        BackoffType = DelayBackoffType.Exponential
    })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
    {
        FailureRatio = 0.6,
        MinimumThroughput = 5,
        BreakDuration = TimeSpan.FromMinutes(1)
    })
    .Build();

// Использование с HttpClient
var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy("http://gate.proxyhat.com:8080")
    {
        Credentials = new NetworkCredential("user-country-US", "PASSWORD")
    }
};

var httpClient = new HttpClient(handler);

async Task<T?> ExecuteWithResilience<T>(string url, CancellationToken ct = default)
{
    var response = await resiliencePipeline.ExecuteAsync(
        async ctx => await httpClient.GetAsync(url, ctx),
        ct);
    
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
}

Parallel.ForEachAsync: параллельный скрапинг

.NET 8 предоставляет Parallel.ForEachAsync — удобный API для параллельного выполнения асинхронных операций с ограничением степени параллелизма.

using System.Collections.Concurrent;
using System.Diagnostics;

// Конфигурация параллелизма
var maxDegreeOfParallelism = Environment.ProcessorCount * 2;
var urls = Enumerable.Range(0, 100)
    .Select(i => $"https://httpbin.org/delay/{Random.Shared.Next(1, 3)}")
    .ToList();

var results = new ConcurrentBag<(int Index, string Result, TimeSpan Duration)>();
var errors = new ConcurrentBag<(int Index, Exception Error)>();

// Создаём HttpClient с прокси
var handler = new SocketsHttpHandler
{
    Proxy = new WebProxy("http://gate.proxyhat.com:8080")
    {
        Credentials = new NetworkCredential("user-country-US", "PASSWORD")
    },
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),
    MaxConnectionsPerServer = maxDegreeOfParallelism
};

using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };

var sw = Stopwatch.StartNew();

await Parallel.ForEachAsync(
    urls.Select((url, index) => (url, index)),
    new ParallelOptions { MaxDegreeOfParallelism = maxDegreeOfParallelism },
    async (item, cancellationToken) =>
    {
        try
        {
            var requestSw = Stopwatch.StartNew();
            var response = await httpClient.GetAsync(item.url, cancellationToken);
            var content = await response.Content.ReadAsStringAsync(cancellationToken);
            requestSw.Stop();
            
            results.Add((item.index, $"OK: {response.StatusCode}", requestSw.Elapsed));
        }
        catch (Exception ex)
        {
            errors.Add((item.index, ex));
        }
    });

sw.Stop();

Console.WriteLine($"Completed: {results.Count}/{urls.Count} in {sw.Elapsed.TotalSeconds:F2}s");
Console.WriteLine($"Errors: {errors.Count}");
Console.WriteLine($"Avg time per request: {results.Average(r => r.Duration.TotalMilliseconds):F0}ms");

// Выводим ошибки для анализа
foreach (var error in errors.Take(5))
{
    Console.WriteLine($"Error at {error.Index}: {error.Error.Message}");
}

DI-сервис ротации прокси для production

Для production-систем нужен централизованный сервис управления прокси с поддержкой ротации, health-check и интеграцией с DI-контейнером .NET.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;

// Интерфейс сервиса
public interface IProxyPoolService
{
    Task<HttpClient> CreateClientAsync(string? sessionKey = null, CancellationToken ct = default);
    void ReportSuccess(string proxyKey);
    void ReportFailure(string proxyKey, Exception? error = null);
    IReadOnlyList<ProxyStats> GetStats();
}

public record ProxyStats(string Key, int Successes, int Failures, double SuccessRate);

// Конфигурация
public class ProxyPoolOptions
{
    public string Gateway { get; set; } = "gate.proxyhat.com";
    public int HttpPort { get; set; } = 8080;
    public string Username { get; set; } = "";
    public string Password { get; set; } = "";
    public string[] Countries { get; set; } = ["US", "GB", "DE", "FR"];
    public TimeSpan ConnectionLifetime { get; set; } = TimeSpan.FromMinutes(5);
    public int MaxConnectionsPerProxy { get; set; } = 10;
    public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromMinutes(1);
}

// Реализация
public class ProxyPoolService : IProxyPoolService, IDisposable
{
    private readonly ProxyPoolOptions _options;
    private readonly ILogger<ProxyPoolService> _logger;
    private readonly ConcurrentDictionary<string, ProxyEntry> _proxies = new();
    private readonly ConcurrentDictionary<string, HttpClient> _clients = new();
    private readonly Timer _healthCheckTimer;
    private int _roundRobinIndex = 0;

    public ProxyPoolService(ProxyPoolOptions options, ILogger<ProxyPoolService> logger)
    {
        _options = options;
        _logger = logger;
        
        // Инициализация прокси для каждой страны
        foreach (var country in options.Countries)
        {
            var key = $"{country}-{Guid.NewGuid():N}"[..8];
            _proxies[key] = new ProxyEntry(country, options);
        }
        
        _healthCheckTimer = new Timer(PerformHealthCheck, null, 
            options.HealthCheckInterval, options.HealthCheckInterval);
    }

    public async Task<HttpClient> CreateClientAsync(string? sessionKey, CancellationToken ct)
    {
        var proxyKey = sessionKey ?? GetNextProxyKey();
        
        if (_clients.TryGetValue(proxyKey, out var existingClient))
            return existingClient;
        
        var entry = _proxies[proxyKey];
        var client = CreateHttpClient(entry);
        _clients[proxyKey] = client;
        
        return client;
    }

    private HttpClient CreateHttpClient(ProxyEntry entry)
    {
        var proxyUrl = $"http://{entry.Username}:{_options.Password}@{_options.Gateway}:{_options.HttpPort}";
        var proxy = new WebProxy(proxyUrl);
        
        var handler = new SocketsHttpHandler
        {
            Proxy = proxy,
            UseProxy = true,
            PooledConnectionLifetime = _options.ConnectionLifetime,
            MaxConnectionsPerServer = _options.MaxConnectionsPerProxy,
            AutomaticDecompression = DecompressionMethods.All,
            ConnectTimeout = TimeSpan.FromSeconds(30)
        };
        
        return new HttpClient(handler)
        {
            Timeout = TimeSpan.FromSeconds(60)
        };
    }

    private string GetNextProxyKey()
    {
        var keys = _proxies.Keys.ToList();
        var index = Interlocked.Increment(ref _roundRobinIndex) % keys.Count;
        return keys[index];
    }

    public void ReportSuccess(string proxyKey)
    {
        if (_proxies.TryGetValue(proxyKey, out var entry))
            entry.RecordSuccess();
    }

    public void ReportFailure(string proxyKey, Exception? error)
    {
        if (_proxies.TryGetValue(proxyKey, out var entry))
        {
            entry.RecordFailure();
            _logger.LogWarning(error, "Proxy {Key} failed", proxyKey);
        }
    }

    public IReadOnlyList<ProxyStats> GetStats()
        => _proxies.Select(p => new ProxyStats(
            p.Key, 
            p.Value.Successes, 
            p.Value.Failures, 
            p.Value.SuccessRate)).ToList();

    private void PerformHealthCheck(object? state)
    {
        foreach (var (key, entry) in _proxies)
        {
            if (entry.SuccessRate < 0.3 && entry.TotalRequests > 10)
            {
                _logger.LogWarning("Proxy {Key} has low success rate: {Rate:P}", key, entry.SuccessRate);
            }
        }
    }

    public void Dispose()
    {
        _healthCheckTimer.Dispose();
        foreach (var client in _clients.Values)
            client.Dispose();
    }

    private class ProxyEntry
    {
        public string Country { get; }
        public string Username { get; }
        private int _successes;
        private int _failures;

        public ProxyEntry(string country, ProxyPoolOptions options)
        {
            Country = country;
            Username = $"{options.Username}-country-{country}";
        }

        public int Successes => _successes;
        public int Failures => _failures;
        public int TotalRequests => _successes + _failures;
        public double SuccessRate => TotalRequests > 0 ? (double)_successes / TotalRequests : 1.0;

        public void RecordSuccess() => Interlocked.Increment(ref _successes);
        public void RecordFailure() => Interlocked.Increment(ref _failures);
    }
}

// Регистрация в DI
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddProxyPool(
        this IServiceCollection services, 
        Action<ProxyPoolOptions> configure)
    {
        services.Configure(configure);
        services.AddSingleton<IProxyPoolService, ProxyPoolService>();
        return services;
    }
}

// Использование в Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProxyPool(options =>
{
    options.Username = "your-username";
    options.Password = "your-password";
    options.Countries = ["US", "GB", "DE", "FR", "JP"];
    options.ConnectionLifetime = TimeSpan.FromMinutes(3);
});

var app = builder.Build();

app.MapGet("/scrape", async (IProxyPoolService proxyPool) =>
{
    var client = await proxyPool.CreateClientAsync();
    try
    {
        var result = await client.GetStringAsync("https://api.ipify.org?format=json");
        proxyPool.ReportSuccess("current-proxy-key");
        return Results.Ok(result);
    }
    catch (Exception ex)
    {
        proxyPool.ReportFailure("current-proxy-key", ex);
        return Results.Problem(ex.Message);
    }
});

app.Run();

TLS и сертификаты: SslClientAuthenticationOptions

При работе через прокси важно правильно настроить TLS. Для сценариев с самоподписанными сертификатами или corporate MITM-прокси нужен кастомный валидатор.

using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;

// Вариант 1: Игнорирование SSL-ошибок (только для разработки!)
var insecureHandler = new SocketsHttpHandler
{
    Proxy = new WebProxy("http://gate.proxyhat.com:8080")
    {
        Credentials = new NetworkCredential("user-country-US", "PASSWORD")
    },
    SslOptions = new SslClientAuthenticationOptions
    {
        RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
        {
            // ВНИМАНИЕ: Не используйте в production!
            return true;
        },
        EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
    }
};

// Вариант 2: Certificate pinning (production-ready)
public class CertificatePinningHandler : DelegatingHandler
{
    private readonly HashSet<string> _allowedPublicKeys;

    public CertificatePinningHandler(HttpMessageHandler innerHandler, IEnumerable<string> allowedPublicKeys)
        : base(innerHandler)
    {
        _allowedPublicKeys = new HashSet<string>(allowedPublicKeys);
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, 
        CancellationToken cancellationToken)
    {
        // SocketsHttpHandler с пиннингом
        var sslOptions = new SslClientAuthenticationOptions
        {
            RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
            {
                if (cert is not X509Certificate2 certificate)
                    return false;
                
                var publicKey = certificate.GetPublicKeyString();
                return _allowedPublicKeys.Contains(publicKey);
            },
            EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
        };

        return await base.SendAsync(request, cancellationToken);
    }
}

// Вариант 3: Custom Root CA (для corporate-сред)
public static SocketsHttpHandler CreateHandlerWithCustomCa(string caCertPath)
{
    var caCertificate = X509Certificate2.CreateFromPemFile(caCertPath);
    
    return new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://gate.proxyhat.com:8080")
        {
            Credentials = new NetworkCredential("user-country-US", "PASSWORD")
        },
        SslOptions = new SslClientAuthenticationOptions
        {
            RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
            {
                if (errors == SslPolicyErrors.None)
                    return true;
                
                // Проверяем, подписан ли сертификат нашим CA
                using var customChain = new X509Chain();
                customChain.ChainPolicy.ExtraStore.Add(caCertificate);
                customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
                
                return customChain.Build((X509Certificate2)cert!);
            },
            EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
        }
    };
}

// Пример использования с pinned сертификатами
var knownPublicKeys = new[]
{
    "3082010A0282010100...", // SHA-256 public key целевого сервера
    "3082010A0282010101..."  // Альтернативный ключ
};

var innerHandler = new SocketsHttpHandler
{
    Proxy = new WebProxy("http://gate.proxyhat.com:8080")
    {
        Credentials = new NetworkCredential("user-country-US", "PASSWORD")
    }
};

var pinnedHandler = new CertificatePinningHandler(innerHandler, knownPublicKeys);
using var secureClient = new HttpClient(pinnedHandler);

// Теперь запросы будут отклонены, если сертификат сервера не совпадает
var response = await secureClient.GetAsync("https://example.com");

Сравнение подходов к прокси в .NET

Подход Производительность Гибкость Сложность Рекомендация
HttpClientHandler + WebProxy Средняя Низкая Низкая Простые сценарии, legacy-код
SocketsHttpHandler Высокая Высокая Средняя Современные .NET 8+ проекты
ConnectCallback Высокая Очень высокая Высокая Per-request ротация прокси
DI ProxyPoolService Высокая Высокая Высокая Enterprise production-системы

Лучшие практики для C# residential proxies

  • Переиспользуйте HttpClient — создавайте один экземпляр на время жизни приложения или через IHttpClientFactory.
  • Настройте PooledConnectionLifetime — для ротации IP через residential-прокси устанавливайте 2–5 минут.
  • Используйте Polly — retry с exponential backoff и circuit breaker обязательны для production.
  • Ограничьте параллелизмMaxConnectionsPerServer и MaxDegreeOfParallelism должны соответствовать лимитам прокси-провайдера.
  • Логируйте метрики — успехи/неудачи, latency, IP-адреса для диагностики.
  • Уважайте robots.txt и ToS — этичный скрапинг снижает риск блокировок.

Key Takeaway: В .NET 8+ SocketsHttpHandler с правильно настроенным PooledConnectionLifetime — лучший выбор для работы с residential-прокси. Комбинируйте с Polly для отказоустойчивости и DI-сервисом для управления пулом.

Заключение

HTTP-прокси в C# и .NET 8 требуют внимания к деталям: правильное управление соединениями, обработка сбоев и TLS-конфигурация. SocketsHttpHandler даёт максимальный контроль, Polly обеспечивает надёжность, а DI-сервис упрощает управление в enterprise-проектах.

Для residential-прокси ProxyHat используйте формат http://user-country-US:PASSWORD@gate.proxyhat.com:8080 и настраивайте PooledConnectionLifetime в соответствии с нужной частотой ротации IP.

Готовы начать? Ознакомьтесь с тарифами ProxyHat и доступными локациями для настройки гео-таргетинга.

Готовы начать?

Доступ к более чем 50 млн резидентных IP в 148+ странах с AI-фильтрацией.

Смотреть ценыРезидентные прокси
← Вернуться в Блог