Add a proxy pool with rotation, liveness checks and a management page

The parser will need to move between proxies, so this adds the module it will
sit on: pluggable sources, a pool that hands proxies out and learns from the
outcome, three rotation strategies, and a page to drive it.

Sources are IProxySource implementations. The public proxifly/free-proxy-list
feed is fetched as the combined all/data.json through jsDelivr and filtered
locally — one conditional request beats four per-protocol ones that can disagree
mid-publish — and cached for the five minutes upstream takes to regenerate. A
feed that is down keeps serving its last payload rather than emptying the pool.
The user's own list lives in proxies.custom.json beside the settings, takes a
pasted blob, and names the lines it could not parse instead of quietly dropping
them.

Both knobs the pool exposes are settings, as asked: rotation is Sticky (default,
the only one that keeps site sessions coherent), RoundRobin or WeightedRandom;
liveness is either a parallel sweep of the whole pool or a probe at hand-out
time. Free lists are a few percent alive, so skipping verification entirely
means mostly waiting on timeouts.

Two invariants worth keeping, both of which cost a bug to find:

Availability is decided by the quarantine, never by Health. Excluding everything
that has ever failed made the quarantine window dead code and discarded proxies
permanently on their first hiccup, which is exactly wrong for addresses that
flap constantly. Health only orders the candidates now.

A probe verdict does not touch the success/failure counters. Those are about
real requests, and letting a sweep over a few thousand proxies rewrite them
would drown the evidence weighted selection reads.

SOCKS needs no extra package — .NET resolves socks4/socks4a/socks5 in WebProxy —
but a proxifly record with "protocol": "https" is still an HTTP proxy reached
over http:// with CONNECT, not an https:// scheme.

115 new tests. Also fixes a pre-existing flake: a command gated on another
command's IsExecuting cannot be driven straight after its Execute() completes,
because IsExecuting is published on the output scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 17:22:30 +03:00
co-authored by Claude Opus 5
parent aeafe0af36
commit 9bf2ea5532
48 changed files with 4422 additions and 5 deletions
+340
View File
@@ -0,0 +1,340 @@
using AvParser.Core.Proxies.Selection;
namespace AvParser.Core.Proxies;
/// <inheritdoc cref="IProxyPool" />
public sealed class ProxyPool : IProxyPool
{
private readonly IReadOnlyList<IProxySource> _sources;
private readonly IProxyProbe _probe;
private readonly TimeProvider _time;
private readonly Random? _random;
private readonly Lock _gate = new();
private readonly Dictionary<string, ProxyEntry> _byKey = new(StringComparer.Ordinal);
private List<ProxyEntry> _entries = [];
private IProxySelectionStrategy _strategy;
private ProxyOptions _options;
/// <summary>Creates a pool over the given sources.</summary>
/// <param name="sources">Every list the app knows about; order decides nothing.</param>
/// <param name="probe">Liveness checker.</param>
/// <param name="options">Initial options.</param>
/// <param name="timeProvider">Clock; tests inject a fake one to exercise quarantine expiry.</param>
/// <param name="random">Randomness for weighted selection; tests seed it.</param>
public ProxyPool(
IEnumerable<IProxySource> sources,
IProxyProbe probe,
ProxyOptions? options = null,
TimeProvider? timeProvider = null,
Random? random = null
)
{
ArgumentNullException.ThrowIfNull(sources);
_sources = sources.ToArray();
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
_time = timeProvider ?? TimeProvider.System;
_random = random;
_options = (options ?? new ProxyOptions()).Validated();
_strategy = ProxySelectionStrategyFactory.Create(_options.Rotation, random);
}
/// <inheritdoc />
public IReadOnlyList<ProxyEntry> Entries
{
get
{
lock (_gate)
{
return _entries;
}
}
}
/// <inheritdoc />
public ProxyOptions Options
{
get
{
lock (_gate)
{
return _options;
}
}
}
/// <inheritdoc />
public event EventHandler? Changed;
/// <inheritdoc />
public void Configure(ProxyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var validated = options.Validated();
lock (_gate)
{
var rotationChanged = validated.Rotation != _options.Rotation;
_options = validated;
if (rotationChanged)
{
_strategy = ProxySelectionStrategyFactory.Create(validated.Rotation, _random);
}
}
RaiseChanged();
}
/// <inheritdoc />
public async Task<int> RefreshAsync(CancellationToken cancellationToken = default)
{
var options = Options;
var collected = new List<(ProxyEndpoint Endpoint, ProxySourceKind Kind)>();
foreach (var source in _sources)
{
if (source.Kind == ProxySourceKind.Feed && !options.UseFeed)
{
continue;
}
var proxies = await source.GetProxiesAsync(cancellationToken).ConfigureAwait(false);
foreach (var endpoint in proxies)
{
collected.Add((endpoint, source.Kind));
}
}
int count;
lock (_gate)
{
var next = new List<ProxyEntry>(collected.Count);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var (endpoint, kind) in collected)
{
if (!Matches(endpoint, options) || !seen.Add(endpoint.Key))
{
continue;
}
// Reuse the existing entry so a refresh does not wipe out everything the pool
// has learned — free lists are republished every few minutes.
if (_byKey.TryGetValue(endpoint.Key, out var existing))
{
next.Add(existing);
}
else
{
var created = new ProxyEntry(endpoint, kind);
_byKey[endpoint.Key] = created;
next.Add(created);
}
}
foreach (var key in _byKey.Keys.Where(key => !seen.Contains(key)).ToArray())
{
_byKey.Remove(key);
}
_entries = next;
_strategy.Reset();
count = next.Count;
}
RaiseChanged();
return count;
}
/// <inheritdoc />
public async Task<ProxyLease?> AcquireAsync(CancellationToken cancellationToken = default)
{
var options = Options;
if (options.HealthCheck == ProxyHealthCheck.Pool)
{
var entry = SelectAvailable();
return entry is null ? null : new ProxyLease(this, entry);
}
// Lazy: verify the pick before handing it over, stepping past dead ones.
for (var attempt = 0; attempt < options.LazyProbeAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
var entry = SelectAvailable();
if (entry is null)
{
return null;
}
if (entry.Health == ProxyHealthState.Alive)
{
return new ProxyLease(this, entry);
}
var result = await _probe.ProbeAsync(entry.Endpoint, options, cancellationToken).ConfigureAwait(false);
var now = _time.GetUtcNow();
if (result.Alive)
{
entry.RecordProbe(now, alive: true, result.Latency, error: null);
RaiseChanged();
return new ProxyLease(this, entry);
}
// Same reasoning as the sweep: this is a probe verdict, so it sidelines the entry
// without inflating the request counters that weighted selection reads.
entry.RecordProbe(now, alive: false, latency: null, result.Error, options.BaseQuarantine);
lock (_gate)
{
_strategy.Report(entry, success: false);
}
RaiseChanged();
}
return null;
}
/// <inheritdoc />
public async Task<int> SweepAsync(
IProgress<ProxySweepProgress>? progress = null,
CancellationToken cancellationToken = default
)
{
var options = Options;
var targets = Entries;
var total = targets.Count;
if (total == 0)
{
progress?.Report(new ProxySweepProgress(0, 0, 0));
return 0;
}
using var limiter = new SemaphoreSlim(options.ProbeConcurrency, options.ProbeConcurrency);
var checkedCount = 0;
var aliveCount = 0;
var work = targets.Select(async entry =>
{
await limiter.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var result = await _probe.ProbeAsync(entry.Endpoint, options, cancellationToken).ConfigureAwait(false);
// A failed sweep probe sidelines the entry for one base window rather than
// counting as a request failure: it is evidence about the proxy, not usage of it.
entry.RecordProbe(
_time.GetUtcNow(),
result.Alive,
result.Latency,
result.Error,
quarantineOnFailure: options.BaseQuarantine
);
if (result.Alive)
{
Interlocked.Increment(ref aliveCount);
}
}
finally
{
limiter.Release();
progress?.Report(
new ProxySweepProgress(
Interlocked.Increment(ref checkedCount),
total,
Volatile.Read(ref aliveCount)
)
);
}
});
await Task.WhenAll(work).ConfigureAwait(false);
lock (_gate)
{
// A sweep can invalidate a sticky pick, so make the strategy re-choose.
_strategy.Reset();
}
RaiseChanged();
return aliveCount;
}
/// <summary>Records the outcome of a lease. Called by <see cref="ProxyLease"/>.</summary>
internal void ReportOutcome(ProxyEntry entry, bool success, TimeSpan? latency, string? error)
{
var options = Options;
var now = _time.GetUtcNow();
if (success)
{
entry.RecordSuccess(now, latency);
}
else
{
entry.RecordFailure(
now,
options.BaseQuarantine,
options.MaxQuarantine,
options.FailuresBeforeQuarantine,
error
);
}
lock (_gate)
{
_strategy.Report(entry, success);
}
RaiseChanged();
}
/// <summary>Whether an endpoint passes the protocol and country filters.</summary>
public static bool Matches(ProxyEndpoint endpoint, ProxyOptions options)
{
ArgumentNullException.ThrowIfNull(endpoint);
ArgumentNullException.ThrowIfNull(options);
if (
options.Protocols != ProxyProtocolFilter.None
&& !options.Protocols.HasFlag(ProxyOptions.ToFlag(endpoint.Protocol))
)
{
return false;
}
return options.Countries.Count == 0
|| (
endpoint.Country is { } country && options.Countries.Contains(country, StringComparer.OrdinalIgnoreCase)
);
}
private ProxyEntry? SelectAvailable()
{
lock (_gate)
{
var now = _time.GetUtcNow();
// Best-first ordering: sticky selection takes the head of this list, and the other
// strategies benefit from healthy proxies being clustered at the front.
var candidates = _entries
.Where(entry => entry.IsAvailable(now))
.OrderByDescending(entry => entry.Health == ProxyHealthState.Alive)
.ThenBy(entry => entry.Latency ?? TimeSpan.MaxValue)
.ThenByDescending(entry => entry.Endpoint.Score)
.ToArray();
return _strategy.Pick(candidates);
}
}
private void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
}