feat: add per-proxy backoff after connection failure

Track last_fail timestamp on ProxyEntry. When a connection attempt fails
in server.py, report_failure() records the time. The selection weight
multiplies by min(fail_age/60, 1.0), ramping back from floor over 60s.
Prevents wasting retries on proxies that just failed.
This commit is contained in:
user
2026-02-15 15:53:39 +01:00
parent 0ed9142b1a
commit 4801e70b93
3 changed files with 60 additions and 6 deletions

View File

@@ -33,6 +33,7 @@ class ProxyEntry:
last_seen: float = 0.0
last_ok: float = 0.0
last_test: float = 0.0
last_fail: float = 0.0
fails: int = 0
tests: int = 0
alive: bool = False
@@ -96,13 +97,25 @@ class ProxyPool:
key = random.choices(self._alive_keys, weights=weights)[0]
return self._proxies[key].hop
def report_failure(self, hop: ChainHop) -> None:
"""Record a connection-time failure for a proxy."""
key = f"{hop.proto}://{hop.host}:{hop.port}"
entry = self._proxies.get(key)
if entry:
entry.last_fail = time.time()
@staticmethod
def _weight(entry: ProxyEntry, now: float) -> float:
"""Compute selection weight from recency of last success."""
"""Compute selection weight from recency and failure backoff."""
if entry.last_ok <= 0:
return 0.01
age = now - entry.last_ok
return 1.0 / (1.0 + age / 300.0)
w = 1.0 / (1.0 + age / 300.0)
# penalize proxies that failed since their last health-test success
if entry.last_fail >= entry.last_ok:
fail_age = now - entry.last_fail
w *= min(fail_age / 60.0, 1.0) # ramp back over 60s
return max(w, 0.01)
@property
def count(self) -> int:
@@ -355,6 +368,7 @@ class ProxyPool:
hop=hop,
last_seen=entry.get("last_seen", 0.0),
last_ok=entry.get("last_ok", 0.0),
last_fail=entry.get("last_fail", 0.0),
fails=entry.get("fails", 0),
tests=entry.get("tests", 0),
alive=entry.get("alive", False),
@@ -383,6 +397,7 @@ class ProxyPool:
"password": entry.hop.password,
"last_seen": entry.last_seen,
"last_ok": entry.last_ok,
"last_fail": entry.last_fail,
"fails": entry.fails,
"tests": entry.tests,
"alive": entry.alive,

View File

@@ -103,11 +103,12 @@ async def _handle_client(
for attempt in range(attempts):
effective_chain = list(config.chain)
pool_hop = None
if proxy_pool:
hop = await proxy_pool.get()
if hop:
effective_chain.append(hop)
logger.debug("[%s] +proxy %s", tag, hop)
pool_hop = await proxy_pool.get()
if pool_hop:
effective_chain.append(pool_hop)
logger.debug("[%s] +proxy %s", tag, pool_hop)
try:
t0 = time.monotonic()
@@ -119,6 +120,8 @@ async def _handle_client(
break
except (ProtoError, asyncio.TimeoutError, ConnectionError, OSError) as e:
last_err = e
if pool_hop and isinstance(proxy_pool, ProxyPool):
proxy_pool.report_failure(pool_hop)
if metrics:
metrics.retries += 1
if attempt + 1 < attempts:

View File

@@ -116,6 +116,42 @@ class TestProxyPoolWeight:
entry.last_ok = 0
assert ProxyPool._weight(entry, now) == pytest.approx(0.01)
def test_weight_failure_penalty(self):
hop = ChainHop(proto="socks5", host="1.2.3.4", port=1080)
now = 1000.0
# healthy proxy: weight ~1.0
entry = ProxyEntry(hop=hop, last_ok=now)
assert ProxyPool._weight(entry, now) == pytest.approx(1.0)
# just failed: weight drops to floor (fail_age=0 -> multiplier=0)
entry.last_fail = now
assert ProxyPool._weight(entry, now) == pytest.approx(0.01)
# 30s after failure: base=1/(1+30/300)=0.909, penalty=30/60=0.5 -> ~0.45
assert ProxyPool._weight(entry, now + 30) == pytest.approx(0.4545, abs=0.05)
# 60s after failure: penalty=1.0, only base decay -> 1/(1+60/300)=0.833
assert ProxyPool._weight(entry, now + 60) == pytest.approx(0.833, abs=0.05)
def test_report_failure(self):
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, last_ok=time.time(),
)
assert pool._proxies["socks5://1.2.3.4:1080"].last_fail == 0.0
pool.report_failure(hop)
assert pool._proxies["socks5://1.2.3.4:1080"].last_fail > 0.0
def test_report_failure_unknown_proxy(self):
cfg = ProxyPoolConfig(sources=[])
pool = ProxyPool(cfg, [], timeout=10.0)
hop = ChainHop(proto="socks5", host="9.9.9.9", port=1080)
pool.report_failure(hop) # should not raise
class TestProxyPoolFetchFile:
"""Test file source parsing."""