103 lines
4.3 KiB
Python
103 lines
4.3 KiB
Python
|
|
"""Tests for circuit breaker + OpenRouter fallback — Issue #6.
|
||
|
|
|
||
|
|
Circuit tracks Sidecar failures, falls back to OpenRouter when open,
|
||
|
|
resets on successful Sidecar interaction.
|
||
|
|
Uses conftest.py patches for URL mocking.
|
||
|
|
"""
|
||
|
|
import asyncio
|
||
|
|
import pytest
|
||
|
|
from httpx import Response, ASGITransport, AsyncClient
|
||
|
|
import respx
|
||
|
|
|
||
|
|
import main
|
||
|
|
|
||
|
|
|
||
|
|
class TestCircuitBreaker:
|
||
|
|
"""Tests for the circuit breaker mechanism."""
|
||
|
|
|
||
|
|
def test_circuit_closed_initially(self):
|
||
|
|
"""Circuit starts closed (allows Sidecar requests)."""
|
||
|
|
assert main._circuit_open is False
|
||
|
|
assert main._recovery_attempts == 0
|
||
|
|
|
||
|
|
def test_circuit_opens_after_max_failures(self):
|
||
|
|
"""Circuit opens after MAX_RECOVERY_ATTEMPTS failures."""
|
||
|
|
for i in range(main.MAX_RECOVERY_ATTEMPTS):
|
||
|
|
main.circuit_record_failure()
|
||
|
|
assert main._circuit_open is True
|
||
|
|
assert main._recovery_attempts == main.MAX_RECOVERY_ATTEMPTS
|
||
|
|
|
||
|
|
def test_circuit_resets_on_success(self):
|
||
|
|
"""Circuit resets after a successful Sidecar interaction."""
|
||
|
|
# Fill up recovery attempts to trigger open circuit
|
||
|
|
for _ in range(main.MAX_RECOVERY_ATTEMPTS):
|
||
|
|
main.circuit_record_failure()
|
||
|
|
assert main._circuit_open is True
|
||
|
|
main.circuit_reset()
|
||
|
|
assert main._circuit_open is False
|
||
|
|
|
||
|
|
def test_circuit_allows_request_when_closed(self):
|
||
|
|
"""Circuit allows Sidecar request when closed."""
|
||
|
|
main.circuit_reset()
|
||
|
|
result = asyncio.run(main.circuit_breaker_check())
|
||
|
|
assert result is True
|
||
|
|
|
||
|
|
def test_circuit_blocks_when_open(self):
|
||
|
|
"""Circuit blocks Sidecar request when open."""
|
||
|
|
for _ in range(main.MAX_RECOVERY_ATTEMPTS):
|
||
|
|
main.circuit_record_failure()
|
||
|
|
result = asyncio.run(main.circuit_breaker_check())
|
||
|
|
assert result is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestOpenRouterFallback:
|
||
|
|
"""Tests for OpenRouter as fallback backend."""
|
||
|
|
|
||
|
|
def test_router_uses_openrouter_when_circuit_open(self):
|
||
|
|
"""When circuit is open, router tries OpenRouter."""
|
||
|
|
async def run_test():
|
||
|
|
with respx.mock:
|
||
|
|
# Sidecar is down
|
||
|
|
respx.get("http://localhost:8081/models/status").mock(
|
||
|
|
side_effect=Exception("connection refused")
|
||
|
|
)
|
||
|
|
# OpenRouter works
|
||
|
|
respx.post("https://openrouter.ai/v1/chat/completions").mock(
|
||
|
|
return_value=Response(200, json={"choices": [{"message": {"content": "Hello from OR"}}]})
|
||
|
|
)
|
||
|
|
transport = ASGITransport(app=main.app)
|
||
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||
|
|
resp = await ac.post(
|
||
|
|
"/v1/chat/completions",
|
||
|
|
json={"model": "qwen-3-8b", "messages": [{"role": "user", "content": "hi"}]},
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
data = resp.json()
|
||
|
|
assert data["choices"][0]["message"]["content"] == "Hello from OR"
|
||
|
|
asyncio.run(run_test())
|
||
|
|
|
||
|
|
|
||
|
|
class TestDeprecatedHeaderRemoved:
|
||
|
|
"""Verify x-intelligence-level header is removed."""
|
||
|
|
|
||
|
|
def test_proxy_ignores_intelligence_level_header(self):
|
||
|
|
"""Router does not route based on x-intelligence-level: High."""
|
||
|
|
async def run_test():
|
||
|
|
with respx.mock:
|
||
|
|
respx.get("http://localhost:8081/models/status").mock(
|
||
|
|
return_value=Response(200, json={"active_profile": "qwen-3-8b", "llama_server_running": True})
|
||
|
|
)
|
||
|
|
# Should route to Main PC regardless of header
|
||
|
|
respx.post("http://localhost:8080/v1/chat/completions").mock(
|
||
|
|
return_value=Response(200, json={"choices": [{"message": {"content": "Hello"}}]})
|
||
|
|
)
|
||
|
|
transport = ASGITransport(app=main.app)
|
||
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||
|
|
resp = await ac.post(
|
||
|
|
"/v1/chat/completions",
|
||
|
|
json={"model": "qwen-3-8b", "messages": [{"role": "user", "content": "hi"}]},
|
||
|
|
headers={"x-intelligence-level": "High"}, # Should be ignored
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
asyncio.run(run_test())
|