← Terug naar blog
6 min
Proxy's wisselen in .NET zonder steeds een nieuwe HttpClient te maken

Proxy's wisselen in .NET zonder steeds een nieuwe HttpClient te maken

Een praktische manier om in .NET per request een andere proxy te kiezen, zonder named clients of handler-mutatie.
  • .NET
  • C#
  • Scraping
  • Proxies

Inhoudsopgave

  1. De omslachtige oplossing
  2. IWebProxy
  3. Simpele implementatie
  4. Fouten blijven normaal
  5. Credentials
  6. In Dependency Injection
  7. Getest

Ik was bezig met een scraper en liep tegen iets aan dat in eerste instantie simpel klonk: per request een andere proxy gebruiken.

Niet alleen een andere poort op dezelfde host, maar soms ook een compleet andere proxyserver. De lijst met proxy’s zou uiteindelijk uit een database kunnen komen. Sommige proxy’s moesten tijdelijk uitgezet kunnen worden als ze fouten gaven. En de scraper moest ondertussen gewoon door kunnen blijven draaien.

Mijn eerste gedachte was: dan pas ik de proxy toch gewoon aan voordat ik de volgende request doe?

handler.Proxy = new WebProxy("http://proxy-a.example:8080");
await client.GetAsync(url);

handler.Proxy = new WebProxy("http://proxy-b.example:8080");
await client.GetAsync(url);

Dat werkt dus niet. Zodra een handler gebruikt is, kun je dit soort properties niet meer aanpassen. Je krijgt dan bijvoorbeeld:

InvalidOperationException: This instance has already started one or more requests.
Properties can only be modified before sending the first request.

Dat is op zich logisch. De handler beheert connection pools en allerlei interne state. Als je daar tijdens gebruik aan gaat sleutelen, wordt het rommelig.

De omslachtige oplossing

De volgende oplossing ligt ook voor de hand: maak gewoon een HttpClient per proxy.

In ASP.NET kom je dan al snel uit op named clients:

services.AddHttpClient("proxy-1")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://proxy-1.example:8080"),
        UseProxy = true
    });

services.AddHttpClient("proxy-2")
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://proxy-2.example:8080"),
        UseProxy = true
    });

Voor een paar vaste proxy’s kan dat best. Maar voor scraping voelt het al snel verkeerd.

Proxy’s zijn in zo’n systeem meestal geen statische configuratie. Het is gewoon data. Je haalt ze uit een database, je houdt bij welke proxy’s falen, je zet slechte proxy’s tijdelijk uit, je voegt nieuwe toe. Dan wil je niet voor elke mogelijke proxy een aparte clientregistratie hebben.

Wat ik wilde was eigenlijk dit:

  • een HttpClient die ik kan hergebruiken
  • een handler die niet telkens opnieuw gemaakt hoeft te worden
  • per request kunnen bepalen welke proxy gebruikt wordt
  • proxy’s uit een pool of database kunnen halen
  • geen named client per proxy

Daar blijkt .NET al een interface voor te hebben.

IWebProxy

SocketsHttpHandler.Proxy hoeft geen gewone WebProxy te zijn. Je kunt daar ook je eigen implementatie van IWebProxy neerzetten.

Die interface is klein:

Uri? GetProxy(Uri destination);

Daar zit de oplossing. De handler blijft hetzelfde, maar op het moment dat er een request gedaan wordt, vraagt .NET aan jouw proxy-object welke proxy het moet gebruiken.

Dus je verandert niet steeds de handler. Je laat GetProxy steeds een ander antwoord geven.

Simpele implementatie

Een minimale versie ziet er zo uit:

public sealed class RotatingProxy : IWebProxy
{
    private readonly IProxyProvider _provider;

    public RotatingProxy(IProxyProvider provider)
    {
        _provider = provider;
    }

    public ICredentials? Credentials { get; set; }

    public Uri? GetProxy(Uri destination)
    {
        return _provider.GetNextProxy(destination);
    }

    public bool IsBypassed(Uri host)
    {
        return false;
    }
}

De provider mag daarna zelf weten waar de proxy vandaan komt. Dat kan een lijstje in memory zijn, maar net zo goed een database-backed pool met health checks.

Bijvoorbeeld round-robin:

public interface IProxyProvider
{
    Uri? GetNextProxy(Uri destination);
}

public sealed class RoundRobinProxyProvider : IProxyProvider
{
    private readonly object _lock = new();
    private readonly IReadOnlyList<Uri> _proxies;
    private int _index;

    public RoundRobinProxyProvider(IReadOnlyList<Uri> proxies)
    {
        _proxies = proxies;
    }

    public Uri? GetNextProxy(Uri destination)
    {
        lock (_lock)
        {
            if (_proxies.Count == 0)
            {
                return null;
            }

            var proxy = _proxies[_index % _proxies.Count];
            _index++;
            return proxy;
        }
    }
}

Daarna configureer je je client eenmalig:

var provider = new RoundRobinProxyProvider(new[]
{
    new Uri("http://proxy-a.example:8080"),
    new Uri("http://proxy-b.example:8080"),
    new Uri("socks5://proxy-c.example:1080")
});

var handler = new SocketsHttpHandler
{
    UseProxy = true,
    Proxy = new RotatingProxy(provider)
};

var client = new HttpClient(handler);

En daarna gebruik je gewoon diezelfde client:

foreach (var url in urls)
{
    var response = await client.GetAsync(url);
    response.EnsureSuccessStatusCode();
}

Per request kan er nu een andere proxy gekozen worden, zonder dat je de HttpClient of handler opnieuw maakt.

Fouten blijven normaal

Dit betekent niet dat proxy’s ineens betrouwbaar worden. Zeker bij scraping krijg je nog steeds genoeg ellende:

HttpRequestException
TaskCanceledException
connection refused
proxy tunnel failures
authentication failures
TLS failures

Het verschil is dat dit gewone fouten per request zijn. Een kapotte proxy betekent niet dat je HttpClient kapot is. Je vangt de fout af, markeert die proxy als slecht, en probeert de volgende.

try
{
    using var response = await client.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();

    proxyHealth.ReportSuccess();
}
catch (HttpRequestException)
{
    proxyHealth.ReportFailure();
}
catch (TaskCanceledException)
{
    proxyHealth.ReportFailure();
}

In echte code wil je natuurlijk weten welke proxy bij welke request gebruikt is, zodat je de juiste proxy kunt bijwerken in je database. Dat kun je oplossen door je provider met leases te laten werken, of door de gekozen proxy in een request-context bij te houden.

Credentials

Als alle proxy’s dezelfde credentials gebruiken, is het simpel:

var proxy = new RotatingProxy(provider)
{
    Credentials = new NetworkCredential(username, password)
};

Als proxy’s verschillende credentials hebben, moet je daar iets netter mee omgaan. Vaak is CredentialCache genoeg:

var credentials = new CredentialCache();

credentials.Add(
    new Uri("http://proxy-a.example:8080"),
    "Basic",
    new NetworkCredential("user-a", "password-a"));

credentials.Add(
    new Uri("http://proxy-b.example:8080"),
    "Basic",
    new NetworkCredential("user-b", "password-b"));

var proxy = new RotatingProxy(provider)
{
    Credentials = credentials
};

Voor complexere situaties kun je ook zelf ICredentials implementeren.

In Dependency Injection

In DI wordt het dan ook overzichtelijker. De proxyprovider bepaalt welke proxy gebruikt wordt. De HTTP client blijft gewoon stabiel.

services.AddSingleton<IProxyProvider, DatabaseBackedProxyProvider>();

services.AddHttpClient<ScraperClient>()
    .ConfigurePrimaryHttpMessageHandler(sp => new SocketsHttpHandler
    {
        UseProxy = true,
        Proxy = new RotatingProxy(sp.GetRequiredService<IProxyProvider>())
    });

Je database mag daarna veranderen. Je proxypool mag groeien, krimpen, proxy’s uitschakelen en later opnieuw proberen. Daarvoor hoef je niet je HTTP clients opnieuw te registreren.

Getest

Ik heb dit getest met één HttpClient, één SocketsHttpHandler en een eigen IWebProxy.

Eerst met authenticated datacenter proxy’s. Eén client kon meerdere poorten op dezelfde host gebruiken, en het publieke IP veranderde per request.

Daarna met een publieke proxylijst met verschillende hosts en schemes. Met dezelfde client en dezelfde handler gingen requests via verschillende socks4:// en socks5:// proxy’s. Veel gratis proxy’s waren dood of traag, wat niemand zal verbazen. Maar het punt was duidelijk: de client hoefde niet opnieuw gemaakt te worden.

De korte versie: wil je in .NET dynamisch proxy’s wisselen, verander dan niet de handler en maak niet voor elke proxy een nieuwe HttpClient. Laat je IWebProxy per request de proxy kiezen.