feat: listener retry override, pool protocol filter, conn pool docs

- Per-listener `retries` overrides global default (0 = inherit)
- Pool-level `allowed_protos` filters proxies during merge
- Connection pooling documented in CHEATSHEET.md
- Both features exposed in /config and /status API responses
- 12 new tests (config parsing, API exposure, merge filtering)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 20:35:14 +01:00
parent c1c92ddc39
commit 3593481b30
13 changed files with 674 additions and 120 deletions

View File

@@ -13,6 +13,7 @@ from s5p.api import (
_handle_tor_newnym,
_json_response,
_parse_request,
_render_openmetrics,
_route,
)
from s5p.config import ChainHop, Config, ListenerConfig, PoolSourceConfig, ProxyPoolConfig
@@ -222,6 +223,87 @@ class TestHandleConfigAuth:
assert "s3cret" not in str(body)
class TestHandleStatusRetries:
"""Test retries in /status listener entries."""
def test_retries_present_when_set(self):
config = Config(
listeners=[
ListenerConfig(
listen_host="0.0.0.0", listen_port=1080,
retries=5,
),
],
)
ctx = _make_ctx(config=config)
_, body = _handle_status(ctx)
assert body["listeners"][0]["retries"] == 5
def test_retries_absent_when_zero(self):
config = Config(
listeners=[
ListenerConfig(listen_host="0.0.0.0", listen_port=1080),
],
)
ctx = _make_ctx(config=config)
_, body = _handle_status(ctx)
assert "retries" not in body["listeners"][0]
class TestHandleConfigRetries:
"""Test retries in /config listener entries."""
def test_retries_present_when_set(self):
config = Config(
listeners=[
ListenerConfig(
listen_host="0.0.0.0", listen_port=1080,
retries=7,
),
],
)
ctx = _make_ctx(config=config)
_, body = _handle_config(ctx)
assert body["listeners"][0]["retries"] == 7
def test_retries_absent_when_zero(self):
config = Config(
listeners=[
ListenerConfig(listen_host="0.0.0.0", listen_port=1080),
],
)
ctx = _make_ctx(config=config)
_, body = _handle_config(ctx)
assert "retries" not in body["listeners"][0]
class TestHandleConfigAllowedProtos:
"""Test allowed_protos in /config pool entries."""
def test_allowed_protos_present(self):
pp = ProxyPoolConfig(
sources=[],
allowed_protos=["socks5"],
)
config = Config(
proxy_pools={"socks_only": pp},
listeners=[ListenerConfig()],
)
ctx = _make_ctx(config=config)
_, body = _handle_config(ctx)
assert body["proxy_pools"]["socks_only"]["allowed_protos"] == ["socks5"]
def test_allowed_protos_absent_when_empty(self):
pp = ProxyPoolConfig(sources=[])
config = Config(
proxy_pools={"default": pp},
listeners=[ListenerConfig()],
)
ctx = _make_ctx(config=config)
_, body = _handle_config(ctx)
assert "allowed_protos" not in body["proxy_pools"]["default"]
class TestHandleStatusPools:
"""Test GET /status with multiple named pools."""
@@ -292,20 +374,136 @@ class TestHandleStatusMultiPool:
class TestHandleMetrics:
"""Test GET /metrics handler."""
"""Test GET /metrics handler (OpenMetrics format)."""
def test_returns_dict(self):
def test_returns_openmetrics_string(self):
ctx = _make_ctx()
ctx["metrics"].connections = 42
ctx["metrics"].bytes_in = 1024
status, body = _handle_metrics(ctx)
assert status == 200
assert body["connections"] == 42
assert body["bytes_in"] == 1024
assert "uptime" in body
assert "rate" in body
assert "latency" in body
assert "listener_latency" in body
assert isinstance(body, str)
assert body.rstrip().endswith("# EOF")
def test_counter_values(self):
ctx = _make_ctx()
ctx["metrics"].connections = 42
ctx["metrics"].bytes_in = 1024
_, body = _handle_metrics(ctx)
assert "s5p_connections_total 42" in body
assert "s5p_bytes_in_total 1024" in body
class TestRenderOpenMetrics:
"""Test OpenMetrics text rendering."""
def test_eof_terminator(self):
ctx = _make_ctx()
text = _render_openmetrics(ctx)
assert text.rstrip().endswith("# EOF")
assert text.endswith("\n")
def test_type_declarations(self):
ctx = _make_ctx()
text = _render_openmetrics(ctx)
assert "# TYPE s5p_connections counter" in text
assert "# TYPE s5p_active_connections gauge" in text
assert "# TYPE s5p_uptime_seconds gauge" in text
def test_help_lines(self):
ctx = _make_ctx()
text = _render_openmetrics(ctx)
assert "# HELP s5p_connections Total connection attempts." in text
assert "# HELP s5p_active_connections Currently open connections." in text
def test_counter_values(self):
ctx = _make_ctx()
ctx["metrics"].connections = 100
ctx["metrics"].success = 95
ctx["metrics"].failed = 5
ctx["metrics"].retries = 10
ctx["metrics"].auth_failures = 2
ctx["metrics"].bytes_in = 4096
ctx["metrics"].bytes_out = 8192
text = _render_openmetrics(ctx)
assert "s5p_connections_total 100" in text
assert "s5p_connections_success_total 95" in text
assert "s5p_connections_failed_total 5" in text
assert "s5p_retries_total 10" in text
assert "s5p_auth_failures_total 2" in text
assert "s5p_bytes_in_total 4096" in text
assert "s5p_bytes_out_total 8192" in text
def test_gauge_values(self):
ctx = _make_ctx()
ctx["metrics"].active = 7
text = _render_openmetrics(ctx)
assert "s5p_active_connections 7" in text
assert "s5p_uptime_seconds " in text
assert "s5p_connection_rate " in text
def test_no_latency_when_empty(self):
ctx = _make_ctx()
text = _render_openmetrics(ctx)
assert "s5p_chain_latency_seconds" not in text
def test_latency_summary(self):
ctx = _make_ctx()
for i in range(1, 101):
ctx["metrics"].latency.record(i / 1000)
text = _render_openmetrics(ctx)
assert "# TYPE s5p_chain_latency_seconds summary" in text
assert 's5p_chain_latency_seconds{quantile="0.5"}' in text
assert 's5p_chain_latency_seconds{quantile="0.95"}' in text
assert 's5p_chain_latency_seconds{quantile="0.99"}' in text
assert "s5p_chain_latency_seconds_count 100" in text
assert "s5p_chain_latency_seconds_sum " in text
def test_listener_latency_summary(self):
ctx = _make_ctx()
tracker = ctx["metrics"].get_listener_latency("0.0.0.0:1080")
for i in range(1, 51):
tracker.record(i / 1000)
text = _render_openmetrics(ctx)
assert "# TYPE s5p_listener_chain_latency_seconds summary" in text
assert (
's5p_listener_chain_latency_seconds{listener="0.0.0.0:1080",'
'quantile="0.5"}'
) in text
assert (
's5p_listener_chain_latency_seconds_count{listener="0.0.0.0:1080"} 50'
) in text
def test_pool_gauges_multi(self):
pool_a = MagicMock()
pool_a.alive_count = 5
pool_a.count = 10
pool_a.name = "clean"
pool_b = MagicMock()
pool_b.alive_count = 3
pool_b.count = 8
pool_b.name = "mitm"
ctx = _make_ctx(pools={"clean": pool_a, "mitm": pool_b})
text = _render_openmetrics(ctx)
assert '# TYPE s5p_pool_proxies_alive gauge' in text
assert 's5p_pool_proxies_alive{pool="clean"} 5' in text
assert 's5p_pool_proxies_alive{pool="mitm"} 3' in text
assert 's5p_pool_proxies_total{pool="clean"} 10' in text
assert 's5p_pool_proxies_total{pool="mitm"} 8' in text
def test_pool_gauges_single(self):
pool = MagicMock()
pool.alive_count = 12
pool.count = 20
ctx = _make_ctx(pool=pool)
text = _render_openmetrics(ctx)
assert "s5p_pool_proxies_alive 12" in text
assert "s5p_pool_proxies_total 20" in text
def test_no_pool_metrics_when_unconfigured(self):
ctx = _make_ctx()
text = _render_openmetrics(ctx)
assert "s5p_pool_proxies" not in text
class TestHandlePool: