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:
co-authored by
Claude Opus 5
parent
aeafe0af36
commit
9bf2ea5532
@@ -0,0 +1,142 @@
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Proxies.Selection;
|
||||
|
||||
namespace AvParser.Core.Tests.Proxies;
|
||||
|
||||
public class ProxySelectionTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Sticky_keeps_returning_the_same_proxy()
|
||||
{
|
||||
var strategy = new StickyProxySelection();
|
||||
ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")];
|
||||
|
||||
var first = strategy.Pick(candidates);
|
||||
|
||||
strategy.Pick(candidates).ShouldBeSameAs(first);
|
||||
strategy.Pick(candidates).ShouldBeSameAs(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sticky_moves_on_after_a_failure()
|
||||
{
|
||||
var strategy = new StickyProxySelection();
|
||||
ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")];
|
||||
|
||||
var first = strategy.Pick(candidates)!;
|
||||
strategy.Report(first, success: false);
|
||||
|
||||
var remaining = candidates.Where(entry => !ReferenceEquals(entry, first)).ToArray();
|
||||
strategy.Pick(remaining).ShouldNotBeSameAs(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sticky_lets_go_when_its_pick_leaves_the_candidate_set()
|
||||
{
|
||||
var strategy = new StickyProxySelection();
|
||||
var a = ProxyFactory.Entry("a");
|
||||
var b = ProxyFactory.Entry("b");
|
||||
|
||||
strategy.Pick([a, b]).ShouldBeSameAs(a);
|
||||
|
||||
// A refresh or a quarantine can drop the current pick under us.
|
||||
strategy.Pick([b]).ShouldBeSameAs(b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sticky_survives_a_success_report()
|
||||
{
|
||||
var strategy = new StickyProxySelection();
|
||||
ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")];
|
||||
|
||||
var first = strategy.Pick(candidates)!;
|
||||
strategy.Report(first, success: true);
|
||||
|
||||
strategy.Pick(candidates).ShouldBeSameAs(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundRobin_cycles_through_every_candidate()
|
||||
{
|
||||
var strategy = new RoundRobinProxySelection();
|
||||
ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b"), ProxyFactory.Entry("c")];
|
||||
|
||||
var picked = Enumerable.Range(0, 6).Select(_ => strategy.Pick(candidates)!.Endpoint.Host).ToArray();
|
||||
|
||||
picked.ShouldBe(["a", "b", "c", "a", "b", "c"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundRobin_handles_a_shrinking_candidate_list()
|
||||
{
|
||||
var strategy = new RoundRobinProxySelection();
|
||||
ProxyEntry[] three = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b"), ProxyFactory.Entry("c")];
|
||||
|
||||
strategy.Pick(three);
|
||||
strategy.Pick(three);
|
||||
strategy.Pick(three);
|
||||
|
||||
// The cursor is now past the end of the smaller list; this must not throw.
|
||||
Should.NotThrow(() => strategy.Pick([three[0]]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_strategy_returns_null_for_an_empty_pool()
|
||||
{
|
||||
foreach (var rotation in Enum.GetValues<ProxyRotation>())
|
||||
{
|
||||
ProxySelectionStrategyFactory.Create(rotation, new Random(1)).Pick([]).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weighted_selection_favours_proxies_that_have_worked()
|
||||
{
|
||||
var good = ProxyFactory.Entry("good", score: 5);
|
||||
var bad = ProxyFactory.Entry("bad", score: 5);
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
good.RecordSuccess(Now);
|
||||
bad.RecordFailure(Now, TimeSpan.Zero, TimeSpan.Zero, failuresBeforeQuarantine: int.MaxValue);
|
||||
}
|
||||
|
||||
var strategy = new WeightedRandomProxySelection(new Random(20260813));
|
||||
var picks = Enumerable.Range(0, 400).Count(_ => strategy.Pick([good, bad])!.Endpoint.Host == "good");
|
||||
|
||||
// Not asserting an exact split — this is a random draw. The point is the bias exists and
|
||||
// is decisive, not that it hits a particular number.
|
||||
picks.ShouldBeGreaterThan(340);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weight_never_drops_to_zero_so_a_bad_proxy_can_recover()
|
||||
{
|
||||
var hopeless = ProxyFactory.Entry("hopeless");
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
hopeless.RecordFailure(Now, TimeSpan.Zero, TimeSpan.Zero, failuresBeforeQuarantine: int.MaxValue);
|
||||
}
|
||||
|
||||
WeightedRandomProxySelection.WeightOf(hopeless).ShouldBeGreaterThan(0d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unproven_proxy_starts_at_even_odds()
|
||||
{
|
||||
// Before any evidence, success rate is 0.5 rather than 0 — otherwise a freshly loaded
|
||||
// pool would have every weight pinned to the floor and selection would be arbitrary.
|
||||
ProxyFactory.Entry("fresh").SuccessRate.ShouldBe(0.5d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_factory_builds_what_it_was_asked_for()
|
||||
{
|
||||
foreach (var rotation in Enum.GetValues<ProxyRotation>())
|
||||
{
|
||||
ProxySelectionStrategyFactory.Create(rotation).Kind.ShouldBe(rotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user