Files
hao-ai-lab-FastVideo/fastvideo/tests/entrypoints/streaming/test_router.py
T

268 lines
9.3 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Router tests — registry semantics + health loop behavior.
Avoids real WebSocket proxying (the bridge requires the ``websockets``
package and two running uvicorn processes); test_server.py already
covers the end-to-end WS protocol against a direct backend.
"""
from __future__ import annotations
import asyncio
import pytest
from fastvideo.entrypoints.streaming.router.config import (
ReplicaEndpoint,
RouterConfig,
)
from fastvideo.entrypoints.streaming.router.registry import (
ReplicaRegistry,
ReplicaStatus,
run_health_check_loop,
)
def _registry(
*,
num_primary: int = 1,
num_secondary: int = 1,
) -> ReplicaRegistry:
replicas = [ReplicaEndpoint(
url=f"http://primary-{i}:8000",
primary=True,
) for i in range(num_primary)
] + [ReplicaEndpoint(
url=f"http://secondary-{i}:8000",
primary=False,
) for i in range(num_secondary)]
return ReplicaRegistry(replicas)
class TestReplicaRegistry:
def test_requires_replicas(self):
with pytest.raises(ValueError):
ReplicaRegistry([])
def test_select_none_when_all_unknown(self):
assert _registry().select() is None
def test_select_prefers_healthy_primary(self):
reg = _registry()
async def promote():
primary = reg.primaries()[0]
await reg.record_success(primary, recovery_threshold=1, latency_ms=1.0)
# Also mark secondary healthy — primary should still win.
sec = next(r for r in reg.all() if not r.primary)
await reg.record_success(sec, recovery_threshold=1, latency_ms=1.0)
return reg.select()
pick = asyncio.run(promote())
assert pick is not None
assert pick.primary
def test_falls_back_to_secondary_when_primary_unhealthy(self):
reg = _registry()
async def run():
primary = reg.primaries()[0]
sec = next(r for r in reg.all() if not r.primary)
# Fail primary past threshold, succeed secondary.
for _ in range(3):
await reg.record_failure(primary, failure_threshold=3, reason="mock")
await reg.record_success(sec, recovery_threshold=1, latency_ms=1.0)
return reg.select()
pick = asyncio.run(run())
assert pick is not None
assert not pick.primary
def test_failure_threshold_transitions_to_unhealthy(self):
reg = _registry()
primary = reg.primaries()[0]
async def run():
for _ in range(2):
await reg.record_failure(primary, failure_threshold=3, reason="x")
assert primary.health.status is not ReplicaStatus.UNHEALTHY
await reg.record_failure(primary, failure_threshold=3, reason="x")
return primary
result = asyncio.run(run())
assert result.health.status is ReplicaStatus.UNHEALTHY
assert result.health.consecutive_failures == 3
def test_recovery_threshold_returns_to_healthy(self):
reg = _registry()
primary = reg.primaries()[0]
async def run():
for _ in range(3):
await reg.record_failure(primary, failure_threshold=3, reason="x")
assert primary.health.status is ReplicaStatus.UNHEALTHY
for _ in range(2):
await reg.record_success(primary, recovery_threshold=2, latency_ms=5.0)
return primary
result = asyncio.run(run())
assert result.health.status is ReplicaStatus.HEALTHY
assert result.health.last_latency_ms == 5.0
def test_record_success_resets_failure_counter(self):
reg = _registry()
primary = reg.primaries()[0]
async def run():
await reg.record_failure(primary, failure_threshold=10, reason="x")
await reg.record_success(primary, recovery_threshold=1, latency_ms=1.0)
return primary
result = asyncio.run(run())
assert result.health.consecutive_failures == 0
class TestHealthCheckLoop:
def test_loop_transitions_replicas_on_probe_results(self):
config = RouterConfig(
replicas=[
ReplicaEndpoint(url="http://a", primary=True),
ReplicaEndpoint(url="http://b"),
],
health_check_interval_seconds=0.01,
failure_threshold=1,
recovery_threshold=1,
)
reg = ReplicaRegistry(config.replicas)
stop_event = asyncio.Event()
calls: list[str] = []
async def probe(url, *, timeout):
calls.append(url)
if "http://a" in url:
return 1.0, None
return 0.0, "mock failure"
async def run() -> None:
task = asyncio.create_task(
run_health_check_loop(
registry=reg,
config=config,
stop_event=stop_event,
http_get=probe,
))
await asyncio.sleep(0.05)
stop_event.set()
await task
asyncio.run(run())
a = reg.get("http://a")
b = reg.get("http://b")
assert a is not None
assert b is not None
assert a.health.status is ReplicaStatus.HEALTHY
assert b.health.status is ReplicaStatus.UNHEALTHY
assert any("/health" in c for c in calls)
class TestRouterApp:
def test_status_endpoint_lists_replicas(self):
from starlette.testclient import TestClient
from fastvideo.entrypoints.streaming.router.main import build_router_app
config = RouterConfig(
replicas=[
ReplicaEndpoint(url="http://a", primary=True),
ReplicaEndpoint(url="http://b"),
],
health_check_interval_seconds=60, # don't actually poll
)
reg = ReplicaRegistry(config.replicas)
app = build_router_app(config, registry=reg)
client = TestClient(app)
response = client.get("/status")
body = response.json()
urls = {r["url"] for r in body["replicas"]}
assert urls == {"http://a", "http://b"}
# Initial status is UNKNOWN.
assert all(r["status"] == "unknown" for r in body["replicas"])
def test_ws_rejects_when_no_healthy_replica(self):
from starlette.testclient import TestClient
from fastvideo.entrypoints.streaming.router.main import build_router_app
config = RouterConfig(
replicas=[ReplicaEndpoint(url="http://a", primary=True)],
health_check_interval_seconds=60,
)
reg = ReplicaRegistry(config.replicas)
app = build_router_app(config, registry=reg)
client = TestClient(app)
with client.websocket_connect("/v1/stream") as ws:
err = ws.receive_json()
assert err["type"] == "error"
assert err["code"] == "gpu_unavailable"
class TestUnknownToHealthyImmediate:
"""Initial probe must promote UNKNOWN -> HEALTHY without waiting for recovery_threshold."""
def test_first_success_promotes_unknown(self):
reg = _registry(num_primary=1, num_secondary=0)
primary = reg.primaries()[0]
assert primary.health.status is ReplicaStatus.UNKNOWN
async def run():
await reg.record_success(primary, recovery_threshold=10, latency_ms=1.0)
asyncio.run(run())
assert primary.health.status is ReplicaStatus.HEALTHY
def test_unhealthy_recovery_still_gated_by_threshold(self):
reg = _registry(num_primary=1, num_secondary=0)
primary = reg.primaries()[0]
async def run():
for _ in range(3):
await reg.record_failure(primary, failure_threshold=3, reason="x")
assert primary.health.status is ReplicaStatus.UNHEALTHY
await reg.record_success(primary, recovery_threshold=2, latency_ms=1.0)
assert primary.health.status is ReplicaStatus.UNHEALTHY # 1/2 successes
await reg.record_success(primary, recovery_threshold=2, latency_ms=1.0)
assert primary.health.status is ReplicaStatus.HEALTHY # 2/2 successes
asyncio.run(run())
class TestConfigValidation:
"""RouterConfig.__post_init__ rejects malformed configs."""
def test_rejects_path_in_url(self):
with pytest.raises(ValueError, match="without a path"):
RouterConfig(replicas=[ReplicaEndpoint(url="http://host:8000/api")])
def test_rejects_query_in_url(self):
with pytest.raises(ValueError, match="query/fragment"):
RouterConfig(replicas=[ReplicaEndpoint(url="http://host:8000?x=1")])
def test_rejects_fragment_in_url(self):
with pytest.raises(ValueError, match="query/fragment"):
RouterConfig(replicas=[ReplicaEndpoint(url="http://host:8000#frag")])
def test_rejects_duplicate_urls(self):
with pytest.raises(ValueError, match="Duplicate"):
RouterConfig(replicas=[
ReplicaEndpoint(url="http://host:8000"),
ReplicaEndpoint(url="http://host:8000"),
])
def test_accepts_trailing_slash(self):
# parsed.path == "/" should be allowed
cfg = RouterConfig(replicas=[ReplicaEndpoint(url="http://host:8000/")])
assert cfg.replicas[0].url == "http://host:8000/"