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>
371 lines
14 KiB
C#
371 lines
14 KiB
C#
using AvParser.Core.Proxies;
|
|
|
|
namespace AvParser.Core.Tests.Proxies;
|
|
|
|
public class ProxyPoolTests
|
|
{
|
|
private static readonly ProxyOptions PoolMode = new()
|
|
{
|
|
HealthCheck = ProxyHealthCheck.Pool,
|
|
Rotation = ProxyRotation.Sticky,
|
|
};
|
|
|
|
private static ProxyPool Build(
|
|
out FakeProxySource source,
|
|
out FakeProxyProbe probe,
|
|
out FakeTimeProvider clock,
|
|
ProxyOptions? options = null,
|
|
params string[] hosts
|
|
)
|
|
{
|
|
source = new FakeProxySource(ProxySourceKind.Feed);
|
|
source.Endpoints.AddRange(hosts.Select(host => ProxyFactory.Endpoint(host)));
|
|
|
|
probe = new FakeProxyProbe();
|
|
clock = new FakeTimeProvider();
|
|
|
|
return new ProxyPool([source], probe, options ?? PoolMode, clock);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_loads_every_source()
|
|
{
|
|
var pool = Build(out _, out _, out _, hosts: ["a", "b", "c"]);
|
|
|
|
var count = await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
count.ShouldBe(3);
|
|
pool.Entries.Count.ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_drops_duplicates_across_sources()
|
|
{
|
|
var feed = new FakeProxySource(ProxySourceKind.Feed);
|
|
feed.Endpoints.Add(ProxyFactory.Endpoint("shared"));
|
|
|
|
var custom = new FakeProxySource(ProxySourceKind.Custom) { Id = "custom" };
|
|
custom.Endpoints.Add(ProxyFactory.Endpoint("shared"));
|
|
|
|
var pool = new ProxyPool([feed, custom], new FakeProxyProbe(), PoolMode, new FakeTimeProvider());
|
|
|
|
(await pool.RefreshAsync(TestContext.Current.CancellationToken)).ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_keeps_what_the_pool_already_learned()
|
|
{
|
|
var pool = Build(out _, out _, out var clock, hosts: ["a"]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
pool.Entries[0].RecordSuccess(clock.GetUtcNow(), TimeSpan.FromMilliseconds(120));
|
|
|
|
// Free lists are republished every few minutes; a reload that reset every counter would
|
|
// throw away the only real evidence the app has.
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
pool.Entries[0].SuccessCount.ShouldBe(1);
|
|
pool.Entries[0].Latency!.Value.TotalMilliseconds.ShouldBe(120);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_forgets_addresses_that_left_the_feed()
|
|
{
|
|
var pool = Build(out var source, out _, out _, hosts: ["a", "b"]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
source.Endpoints.RemoveAll(endpoint => endpoint.Host == "b");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
pool.Entries.Select(entry => entry.Endpoint.Host).ShouldBe(["a"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_applies_the_protocol_filter()
|
|
{
|
|
var source = new FakeProxySource(ProxySourceKind.Feed);
|
|
source.Endpoints.Add(ProxyFactory.Endpoint("http", protocol: ProxyProtocol.Http));
|
|
source.Endpoints.Add(ProxyFactory.Endpoint("socks", protocol: ProxyProtocol.Socks5));
|
|
|
|
var options = PoolMode with { Protocols = ProxyProtocolFilter.Socks5 };
|
|
var pool = new ProxyPool([source], new FakeProxyProbe(), options, new FakeTimeProvider());
|
|
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
pool.Entries.ShouldHaveSingleItem().Endpoint.Protocol.ShouldBe(ProxyProtocol.Socks5);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_skips_the_feed_when_it_is_switched_off()
|
|
{
|
|
var pool = Build(out var source, out _, out _, PoolMode with { UseFeed = false }, "a");
|
|
|
|
(await pool.RefreshAsync(TestContext.Current.CancellationToken)).ShouldBe(0);
|
|
source.GetCallCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Acquire_returns_null_for_an_empty_pool() =>
|
|
(await Build(out _, out _, out _).AcquireAsync(TestContext.Current.CancellationToken)).ShouldBeNull();
|
|
|
|
[Fact]
|
|
public async Task Pool_mode_hands_out_without_probing()
|
|
{
|
|
var pool = Build(out _, out var probe, out _, hosts: ["a"]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
|
|
lease.ShouldNotBeNull();
|
|
probe.ProbeCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Lazy_mode_skips_past_dead_proxies()
|
|
{
|
|
var options = PoolMode with { HealthCheck = ProxyHealthCheck.Lazy, Rotation = ProxyRotation.RoundRobin };
|
|
var pool = Build(out _, out var probe, out _, options, "dead1", "dead2", "alive");
|
|
|
|
probe.Set(ProxyFactory.Endpoint("alive"), alive: true);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
|
|
lease.ShouldNotBeNull();
|
|
lease.Endpoint.Host.ShouldBe("alive");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Lazy_mode_gives_up_after_the_configured_number_of_attempts()
|
|
{
|
|
var options = PoolMode with
|
|
{
|
|
HealthCheck = ProxyHealthCheck.Lazy,
|
|
Rotation = ProxyRotation.RoundRobin,
|
|
LazyProbeAttempts = 2,
|
|
};
|
|
var pool = Build(out _, out var probe, out _, options, "a", "b", "c", "d", "e");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
(await pool.AcquireAsync(TestContext.Current.CancellationToken)).ShouldBeNull();
|
|
probe.ProbeCount.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Lazy_mode_trusts_a_proxy_already_known_to_be_alive()
|
|
{
|
|
var options = PoolMode with { HealthCheck = ProxyHealthCheck.Lazy };
|
|
var pool = Build(out _, out var probe, out var clock, options, "a");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
pool.Entries[0].RecordProbe(clock.GetUtcNow(), alive: true, TimeSpan.FromMilliseconds(10), null);
|
|
|
|
await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
|
|
probe.ProbeCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_failing_proxy_is_quarantined_and_comes_back_later()
|
|
{
|
|
var options = PoolMode with
|
|
{
|
|
FailuresBeforeQuarantine = 1,
|
|
BaseQuarantine = TimeSpan.FromSeconds(30),
|
|
MaxQuarantine = TimeSpan.FromMinutes(15),
|
|
};
|
|
var pool = Build(out _, out _, out var clock, options, "a");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
lease.ShouldNotBeNull();
|
|
lease.ReportFailure("boom");
|
|
|
|
// Sidelined immediately...
|
|
(await pool.AcquireAsync(TestContext.Current.CancellationToken)).ShouldBeNull();
|
|
|
|
clock.Advance(TimeSpan.FromSeconds(31));
|
|
|
|
// ...and available again once the window expires.
|
|
(await pool.AcquireAsync(TestContext.Current.CancellationToken)).ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_quarantine_window_grows_with_repeated_failures()
|
|
{
|
|
var options = PoolMode with
|
|
{
|
|
FailuresBeforeQuarantine = 1,
|
|
BaseQuarantine = TimeSpan.FromSeconds(10),
|
|
MaxQuarantine = TimeSpan.FromHours(1),
|
|
};
|
|
var pool = Build(out _, out _, out var clock, options, "a");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
var entry = pool.Entries[0];
|
|
|
|
entry.RecordFailure(clock.GetUtcNow(), options.BaseQuarantine, options.MaxQuarantine, 1);
|
|
var first = entry.QuarantinedUntilUtc!.Value - clock.GetUtcNow();
|
|
|
|
entry.RecordFailure(clock.GetUtcNow(), options.BaseQuarantine, options.MaxQuarantine, 1);
|
|
var second = entry.QuarantinedUntilUtc!.Value - clock.GetUtcNow();
|
|
|
|
second.ShouldBeGreaterThan(first);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_quarantine_window_is_capped()
|
|
{
|
|
var pool = Build(out _, out _, out var clock, hosts: ["a"]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
var entry = pool.Entries[0];
|
|
|
|
for (var i = 0; i < 40; i++)
|
|
{
|
|
entry.RecordFailure(clock.GetUtcNow(), TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(5), 1);
|
|
}
|
|
|
|
(entry.QuarantinedUntilUtc!.Value - clock.GetUtcNow()).ShouldBeLessThanOrEqualTo(TimeSpan.FromMinutes(5));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_success_clears_the_quarantine()
|
|
{
|
|
var pool = Build(out _, out _, out var clock, PoolMode with { FailuresBeforeQuarantine = 1 }, "a");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
var first = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
first!.ReportFailure();
|
|
clock.Advance(TimeSpan.FromMinutes(1));
|
|
|
|
var second = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
second!.ReportSuccess(TimeSpan.FromMilliseconds(50));
|
|
|
|
pool.Entries[0].QuarantinedUntilUtc.ShouldBeNull();
|
|
pool.Entries[0].ConsecutiveFailures.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disposing_a_lease_without_a_verdict_says_nothing_about_the_proxy()
|
|
{
|
|
var pool = Build(out _, out _, out _, hosts: ["a"]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
using (await pool.AcquireAsync(TestContext.Current.CancellationToken))
|
|
{
|
|
// A cancelled operation is not the proxy's fault.
|
|
}
|
|
|
|
pool.Entries[0].FailureCount.ShouldBe(0);
|
|
pool.Entries[0].SuccessCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_lease_reports_only_once()
|
|
{
|
|
var pool = Build(out _, out _, out _, hosts: ["a"]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
lease!.ReportSuccess();
|
|
lease.ReportFailure("late");
|
|
|
|
pool.Entries[0].SuccessCount.ShouldBe(1);
|
|
pool.Entries[0].FailureCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sweep_probes_everything_and_counts_the_survivors()
|
|
{
|
|
var pool = Build(out _, out var probe, out _, hosts: ["a", "b", "c"]);
|
|
probe.Set(ProxyFactory.Endpoint("a"), alive: true);
|
|
probe.Set(ProxyFactory.Endpoint("c"), alive: true);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
var reports = new List<ProxySweepProgress>();
|
|
var alive = await pool.SweepAsync(
|
|
new SynchronousProgress<ProxySweepProgress>(reports.Add),
|
|
TestContext.Current.CancellationToken
|
|
);
|
|
|
|
alive.ShouldBe(2);
|
|
probe.ProbeCount.ShouldBe(3);
|
|
reports.Count.ShouldBe(3);
|
|
reports[^1].Fraction.ShouldBe(1d);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sweep_on_an_empty_pool_reports_completion_rather_than_hanging()
|
|
{
|
|
var pool = Build(out _, out _, out _);
|
|
var reports = new List<ProxySweepProgress>();
|
|
|
|
var alive = await pool.SweepAsync(
|
|
new SynchronousProgress<ProxySweepProgress>(reports.Add),
|
|
TestContext.Current.CancellationToken
|
|
);
|
|
|
|
alive.ShouldBe(0);
|
|
reports.ShouldHaveSingleItem().Total.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Changing_the_rotation_strategy_takes_effect()
|
|
{
|
|
var pool = Build(out _, out _, out _, hosts: ["a", "b", "c"]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
var sticky = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
var stickyAgain = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
stickyAgain!.Endpoint.Key.ShouldBe(sticky!.Endpoint.Key);
|
|
|
|
pool.Configure(PoolMode with { Rotation = ProxyRotation.RoundRobin });
|
|
|
|
var first = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
var second = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
second!.Endpoint.Key.ShouldNotBe(first!.Endpoint.Key);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Selection_prefers_proxies_known_to_be_alive()
|
|
{
|
|
var pool = Build(out _, out _, out var clock, hosts: ["slow", "fast"]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
var slow = pool.Entries.Single(entry => entry.Endpoint.Host == "slow");
|
|
var fast = pool.Entries.Single(entry => entry.Endpoint.Host == "fast");
|
|
slow.RecordProbe(clock.GetUtcNow(), alive: true, TimeSpan.FromMilliseconds(900), null);
|
|
fast.RecordProbe(clock.GetUtcNow(), alive: true, TimeSpan.FromMilliseconds(30), null);
|
|
|
|
var lease = await pool.AcquireAsync(TestContext.Current.CancellationToken);
|
|
|
|
lease!.Endpoint.Host.ShouldBe("fast");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Changed_fires_when_the_pool_moves()
|
|
{
|
|
var pool = Build(out _, out _, out _, hosts: ["a"]);
|
|
var fired = 0;
|
|
pool.Changed += (_, _) => fired++;
|
|
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
fired.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Invalid_options_are_rejected_rather_than_misbehaving_quietly()
|
|
{
|
|
Should.Throw<ArgumentOutOfRangeException>(() => new ProxyOptions { ProbeConcurrency = 0 }.Validated());
|
|
Should.Throw<ArgumentOutOfRangeException>(() => new ProxyOptions { LazyProbeAttempts = 0 }.Validated());
|
|
Should.Throw<ArgumentOutOfRangeException>(() => new ProxyOptions { ProbeTimeout = TimeSpan.Zero }.Validated());
|
|
Should.Throw<ArgumentOutOfRangeException>(() =>
|
|
new ProxyOptions
|
|
{
|
|
BaseQuarantine = TimeSpan.FromHours(2),
|
|
MaxQuarantine = TimeSpan.FromMinutes(1),
|
|
}.Validated()
|
|
);
|
|
}
|
|
}
|