Spaces:
Running
Running
| """ | |
| 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, | |
| } | |
| 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 ββββββββββββββββββββββββββββββββ | |
| 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 βββββββββββββββββββββββββββββββββββββββββ | |
| 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) ββββββββββββββββββββββββββββ | |
| 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 ββββββββββββββββββ | |
| 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 βββββββββββββββββββ | |
| 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 | |