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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user