InferRoute / tests /chaos_test.py
Ypeng12's picture
feat: Kubernetes-ready multi-provider LLM gateway v0.2.0
8a841b2
Raw
History Blame Contribute Delete
11.5 kB
"""
Chaos and failure injection tests for InferRoute.
Tests failure modes without requiring live services. Uses mocks to simulate:
- Primary provider outage (β†’ fallback activates)
- All providers down (β†’ 502 with clear error)
- Redis unavailable (β†’ fail-open on auth and caching)
- Database unavailable (β†’ request still completes, log fails gracefully)
- Cascading circuit breaker trips
- Slow backends (timeout simulation)
These tests verify the system degrades gracefully and circuit breakers
and fallbacks behave correctly under adversarial conditions.
"""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
BASE_HEADERS = {"Authorization": "Bearer sk-inferroute-demo"}
CHAT_BODY = {
"model": "meta-llama/Meta-Llama-3-8B-Instruct",
"messages": [{"role": "user", "content": "Hello from chaos test"}],
"stream": False,
}
@pytest.fixture(scope="module")
def test_client():
"""FastAPI test client with full app (all services mocked)."""
from fastapi.testclient import TestClient
from inferroute.main import app
return TestClient(app)
def _mock_redis():
"""A simple no-op Redis mock that returns None for all gets (cache miss)."""
r = AsyncMock()
r.get.return_value = None
r.set.return_value = True
r.incr.return_value = 1
r.expire.return_value = True
r.delete.return_value = 1
r.ping.return_value = True
r.publish.return_value = 1
r.zadd.return_value = 1
r.zremrangebyrank.return_value = 0
r.zrange.return_value = []
pipeline_mock = AsyncMock()
pipeline_mock.zadd = AsyncMock()
pipeline_mock.zremrangebyrank = AsyncMock()
pipeline_mock.expire = AsyncMock()
pipeline_mock.execute = AsyncMock(return_value=[1, 0, True])
pipeline_mock.__aenter__ = AsyncMock(return_value=pipeline_mock)
pipeline_mock.__aexit__ = AsyncMock(return_value=None)
r.pipeline.return_value = pipeline_mock
r.set.side_effect = lambda key, val, nx=False, ex=None: (None if nx else True)
return r
# ── Test 1: Primary provider outage β†’ fallback ────────────────────────────────
@patch("inferroute.adapters.vllm.VLLMAdapter.generate")
@patch("inferroute.adapters.openai.OpenAIAdapter.generate")
def test_primary_outage_triggers_fallback(mock_openai_gen, mock_vllm_gen, test_client):
"""
Scenario: vLLM (primary) is down. Gateway should fall back to OpenAI.
"""
mock_vllm_gen.side_effect = Exception("Connection refused: vLLM server is down")
mock_openai_gen.return_value = {
"id": "openai-fallback-001",
"object": "chat.completion",
"model": "gpt-4o-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Fallback response"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, "estimated_cost_usd": 0.0001},
"timing": {"ttft_ms": 200.0, "latency_ms": 250.0},
}
mock_redis = _mock_redis()
with patch("inferroute.auth.redis_client", mock_redis), \
patch("inferroute.auth.get_redis_client", return_value=mock_redis), \
patch("inferroute.cache.get_redis_client", return_value=mock_redis), \
patch("inferroute.router.get_redis_client", return_value=mock_redis), \
patch("inferroute.circuit_breaker.get_circuit_breaker") as gcb, \
patch("inferroute.main.async_session"):
cb = AsyncMock()
cb.allow_request.return_value = True
cb.record_failure = AsyncMock()
cb.record_success = AsyncMock()
cb.get_status.return_value = {"state": "CLOSED"}
gcb.return_value = cb
resp = test_client.post("/v1/chat/completions", json=CHAT_BODY, headers=BASE_HEADERS)
assert resp.status_code == 200
data = resp.json()
assert data["choices"][0]["message"]["content"] == "Fallback response"
assert data["route"]["fallback_count"] == 1
assert data["route"]["selected_backend"] == "openai"
# Verify both were attempted
assert mock_vllm_gen.called
assert mock_openai_gen.called
# ── Test 2: All providers down β†’ 502 ─────────────────────────────────────────
@patch("inferroute.adapters.vllm.VLLMAdapter.generate")
@patch("inferroute.adapters.openai.OpenAIAdapter.generate")
def test_all_providers_down_returns_502(mock_openai_gen, mock_vllm_gen, test_client):
"""
Scenario: All backends fail. Gateway should return 502.
"""
mock_vllm_gen.side_effect = Exception("vLLM timeout")
mock_openai_gen.side_effect = Exception("OpenAI rate limit exceeded")
mock_redis = _mock_redis()
with patch("inferroute.auth.redis_client", mock_redis), \
patch("inferroute.auth.get_redis_client", return_value=mock_redis), \
patch("inferroute.cache.get_redis_client", return_value=mock_redis), \
patch("inferroute.router.get_redis_client", return_value=mock_redis), \
patch("inferroute.circuit_breaker.get_circuit_breaker") as gcb, \
patch("inferroute.main.async_session"):
cb = AsyncMock()
cb.allow_request.return_value = True
cb.record_failure = AsyncMock()
cb.record_success = AsyncMock()
gcb.return_value = cb
resp = test_client.post("/v1/chat/completions", json=CHAT_BODY, headers=BASE_HEADERS)
assert resp.status_code == 502
assert "failed" in resp.json()["detail"].lower()
# ── Test 3: Redis down β†’ fail-open (no auth crash) ────────────────────────────
@patch("inferroute.adapters.vllm.VLLMAdapter.generate")
def test_redis_down_fail_open(mock_vllm_gen, test_client):
"""
Scenario: Redis is unavailable. Rate limiting fails open; caching is skipped.
Requests should still succeed.
"""
mock_vllm_gen.return_value = {
"id": "redis-down-001",
"object": "chat.completion",
"model": "meta-llama/Meta-Llama-3-8B-Instruct",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Success despite Redis down"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 8, "total_tokens": 13, "estimated_cost_usd": 0.0},
"timing": {"ttft_ms": 150.0, "latency_ms": 150.0},
}
# Simulate Redis being completely unavailable
with patch("inferroute.auth.get_redis_client", return_value=None), \
patch("inferroute.cache.get_redis_client", return_value=None), \
patch("inferroute.router.get_redis_client", return_value=None), \
patch("inferroute.circuit_breaker.get_circuit_breaker") as gcb, \
patch("inferroute.main.async_session"):
cb = AsyncMock()
cb.allow_request.return_value = True # fail-open
cb.record_success = AsyncMock()
gcb.return_value = cb
resp = test_client.post("/v1/chat/completions", json=CHAT_BODY, headers=BASE_HEADERS)
# Should succeed (fail-open)
assert resp.status_code == 200
assert mock_vllm_gen.called
# ── Test 4: DB down β†’ request completes, log fails silently ──────────────────
@patch("inferroute.adapters.vllm.VLLMAdapter.generate")
def test_db_down_request_still_succeeds(mock_vllm_gen, test_client):
"""
Scenario: PostgreSQL is unavailable. Background log fails, but the response
should still be delivered to the client.
"""
mock_vllm_gen.return_value = {
"id": "db-down-001",
"object": "chat.completion",
"model": "meta-llama/Meta-Llama-3-8B-Instruct",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "DB was down but I still responded"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15, "estimated_cost_usd": 0.0},
"timing": {"ttft_ms": 150.0, "latency_ms": 150.0},
}
mock_redis = _mock_redis()
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit.side_effect = Exception("PostgreSQL connection refused")
mock_session_ctx = AsyncMock()
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
with patch("inferroute.auth.redis_client", mock_redis), \
patch("inferroute.auth.get_redis_client", return_value=mock_redis), \
patch("inferroute.cache.get_redis_client", return_value=mock_redis), \
patch("inferroute.router.get_redis_client", return_value=mock_redis), \
patch("inferroute.circuit_breaker.get_circuit_breaker") as gcb, \
patch("inferroute.main.async_session", return_value=mock_session_ctx):
cb = AsyncMock()
cb.allow_request.return_value = True
cb.record_success = AsyncMock()
gcb.return_value = cb
resp = test_client.post("/v1/chat/completions", json=CHAT_BODY, headers=BASE_HEADERS)
assert resp.status_code == 200
data = resp.json()
assert "DB" in data["choices"][0]["message"]["content"]
# ── Test 5: Circuit breaker prevents calls to OPEN backend ───────────────────
@patch("inferroute.adapters.vllm.VLLMAdapter.generate")
@patch("inferroute.adapters.ollama.OllamaAdapter.generate")
def test_open_cb_routes_to_alternative(mock_ollama_gen, mock_vllm_gen, test_client):
"""
Scenario: vLLM circuit breaker is OPEN (trips). Router should skip it and
route to an available backend instead.
"""
mock_vllm_gen.side_effect = Exception("Should not be called β€” CB is OPEN")
mock_ollama_gen.return_value = {
"id": "cb-test-001",
"object": "chat.completion",
"model": "llama3",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Ollama was chosen after CB tripped"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15, "estimated_cost_usd": 0.0},
"timing": {"ttft_ms": 90.0, "latency_ms": 90.0},
}
mock_redis = _mock_redis()
def make_cb(backend):
cb = AsyncMock()
# vllm CB is OPEN β€” reject; everything else is CLOSED β€” allow
cb.allow_request.return_value = (backend != "vllm")
cb.record_success = AsyncMock()
cb.record_failure = AsyncMock()
cb.get_status.return_value = {
"state": "OPEN" if backend == "vllm" else "CLOSED",
"fail_count": 5 if backend == "vllm" else 0,
}
return cb
with patch("inferroute.auth.redis_client", mock_redis), \
patch("inferroute.auth.get_redis_client", return_value=mock_redis), \
patch("inferroute.cache.get_redis_client", return_value=mock_redis), \
patch("inferroute.router.get_redis_client", return_value=mock_redis), \
patch("inferroute.circuit_breaker.get_circuit_breaker", side_effect=make_cb), \
patch("inferroute.main.async_session"):
resp = test_client.post(
"/v1/chat/completions",
json={"model": "edge/auto", "messages": [{"role": "user", "content": "test"}]},
headers=BASE_HEADERS,
)
assert resp.status_code == 200
# vllm should NOT have been called
assert not mock_vllm_gen.called