feat: add managed proxy pool with health testing
ProxyPool replaces ProxySource with: - Multiple sources: HTTP APIs and text files (one proxy URL per line) - Deduplication by proto://host:port - Health testing: full chain test with configurable concurrency - Mass-failure guard: skip eviction when >90% fail - Background loops for periodic refresh and health checks - JSON state persistence with atomic writes (warm starts) - Backward compat: ProxySource still works for legacy configs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
175
tests/test_pool.py
Normal file
175
tests/test_pool.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Tests for the managed proxy pool."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from s5p.config import ChainHop, PoolSourceConfig, ProxyPoolConfig
|
||||
from s5p.pool import ProxyEntry, ProxyPool
|
||||
|
||||
|
||||
class TestProxyEntry:
|
||||
"""Test ProxyEntry defaults."""
|
||||
|
||||
def test_defaults(self):
|
||||
hop = ChainHop(proto="socks5", host="1.2.3.4", port=1080)
|
||||
entry = ProxyEntry(hop=hop)
|
||||
assert entry.alive is False
|
||||
assert entry.fails == 0
|
||||
assert entry.tests == 0
|
||||
|
||||
|
||||
class TestProxyPoolMerge:
|
||||
"""Test proxy deduplication and merge."""
|
||||
|
||||
def test_merge_dedup(self):
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
proxies = [
|
||||
ChainHop(proto="socks5", host="1.2.3.4", port=1080),
|
||||
ChainHop(proto="socks5", host="1.2.3.4", port=1080),
|
||||
ChainHop(proto="socks5", host="5.6.7.8", port=1080),
|
||||
]
|
||||
pool._merge(proxies)
|
||||
assert pool.count == 2
|
||||
|
||||
def test_merge_updates_existing(self):
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
hop = ChainHop(proto="socks5", host="1.2.3.4", port=1080)
|
||||
pool._merge([hop])
|
||||
first_seen = pool._proxies["socks5://1.2.3.4:1080"].last_seen
|
||||
|
||||
# merge again -- last_seen should update
|
||||
time.sleep(0.01)
|
||||
pool._merge([hop])
|
||||
assert pool._proxies["socks5://1.2.3.4:1080"].last_seen >= first_seen
|
||||
assert pool.count == 1
|
||||
|
||||
|
||||
class TestProxyPoolGet:
|
||||
"""Test proxy selection."""
|
||||
|
||||
def test_get_empty(self):
|
||||
import asyncio
|
||||
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
result = asyncio.run(pool.get())
|
||||
assert result is None
|
||||
|
||||
def test_get_returns_alive(self):
|
||||
import asyncio
|
||||
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
hop = ChainHop(proto="socks5", host="1.2.3.4", port=1080)
|
||||
pool._proxies["socks5://1.2.3.4:1080"] = ProxyEntry(hop=hop, alive=True)
|
||||
pool._rebuild_alive()
|
||||
result = asyncio.run(pool.get())
|
||||
assert result is not None
|
||||
assert result.host == "1.2.3.4"
|
||||
|
||||
|
||||
class TestProxyPoolFetchFile:
|
||||
"""Test file source parsing."""
|
||||
|
||||
def test_parse_file(self, tmp_path):
|
||||
proxy_file = tmp_path / "proxies.txt"
|
||||
proxy_file.write_text(
|
||||
"# comment\n"
|
||||
"socks5://1.2.3.4:1080\n"
|
||||
"socks5://user:pass@5.6.7.8:1080\n"
|
||||
"http://proxy.example.com:8080\n"
|
||||
"\n"
|
||||
" # another comment\n"
|
||||
)
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
src = PoolSourceConfig(file=str(proxy_file))
|
||||
result = pool._fetch_file_sync(src)
|
||||
assert len(result) == 3
|
||||
assert result[0].proto == "socks5"
|
||||
assert result[0].host == "1.2.3.4"
|
||||
assert result[1].username == "user"
|
||||
assert result[1].password == "pass"
|
||||
assert result[2].proto == "http"
|
||||
|
||||
def test_parse_file_with_proto_filter(self, tmp_path):
|
||||
proxy_file = tmp_path / "proxies.txt"
|
||||
proxy_file.write_text(
|
||||
"socks5://1.2.3.4:1080\n"
|
||||
"http://proxy.example.com:8080\n"
|
||||
)
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
src = PoolSourceConfig(file=str(proxy_file), proto="socks5")
|
||||
result = pool._fetch_file_sync(src)
|
||||
assert len(result) == 1
|
||||
assert result[0].proto == "socks5"
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
cfg = ProxyPoolConfig(sources=[])
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
src = PoolSourceConfig(file=str(tmp_path / "nonexistent.txt"))
|
||||
result = pool._fetch_file_sync(src)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestProxyPoolPersistence:
|
||||
"""Test state save/load."""
|
||||
|
||||
def test_save_and_load(self, tmp_path):
|
||||
state_file = str(tmp_path / "pool.json")
|
||||
cfg = ProxyPoolConfig(sources=[], state_file=state_file)
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
|
||||
hop = ChainHop(proto="socks5", host="1.2.3.4", port=1080)
|
||||
pool._proxies["socks5://1.2.3.4:1080"] = ProxyEntry(
|
||||
hop=hop, alive=True, fails=0, tests=5, last_ok=1000.0,
|
||||
)
|
||||
pool._rebuild_alive()
|
||||
pool._save_state()
|
||||
|
||||
# load into a fresh pool
|
||||
pool2 = ProxyPool(cfg, [], timeout=10.0)
|
||||
pool2._load_state()
|
||||
assert pool2.count == 1
|
||||
assert pool2.alive_count == 1
|
||||
entry = pool2._proxies["socks5://1.2.3.4:1080"]
|
||||
assert entry.hop.host == "1.2.3.4"
|
||||
assert entry.tests == 5
|
||||
assert entry.alive is True
|
||||
|
||||
def test_corrupt_state(self, tmp_path):
|
||||
state_file = tmp_path / "pool.json"
|
||||
state_file.write_text("{invalid json")
|
||||
cfg = ProxyPoolConfig(sources=[], state_file=str(state_file))
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
pool._load_state()
|
||||
assert pool.count == 0
|
||||
|
||||
def test_missing_state(self, tmp_path):
|
||||
cfg = ProxyPoolConfig(sources=[], state_file=str(tmp_path / "missing.json"))
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
pool._load_state() # should not raise
|
||||
assert pool.count == 0
|
||||
|
||||
def test_state_with_auth(self, tmp_path):
|
||||
state_file = str(tmp_path / "pool.json")
|
||||
cfg = ProxyPoolConfig(sources=[], state_file=state_file)
|
||||
pool = ProxyPool(cfg, [], timeout=10.0)
|
||||
|
||||
hop = ChainHop(
|
||||
proto="socks5", host="1.2.3.4", port=1080,
|
||||
username="user", password="pass",
|
||||
)
|
||||
pool._proxies["socks5://1.2.3.4:1080"] = ProxyEntry(hop=hop, alive=True)
|
||||
pool._save_state()
|
||||
|
||||
pool2 = ProxyPool(cfg, [], timeout=10.0)
|
||||
pool2._load_state()
|
||||
entry = pool2._proxies["socks5://1.2.3.4:1080"]
|
||||
assert entry.hop.username == "user"
|
||||
assert entry.hop.password == "pass"
|
||||
Reference in New Issue
Block a user