| """ |
| Error Path Test Configuration |
| |
| Shared fixtures and utilities for testing error handling code paths |
| that are rarely executed in normal operation but critical for production reliability. |
| |
| Error paths tested: |
| - Database errors (IntegrityError, OperationalError, DataError) |
| - Network errors (ConnectionError, TimeoutError) |
| - LLM errors (APIError, RateLimitError, AuthenticationError) |
| - Filesystem errors (IOError, OSError) |
| - Invalid input handling (TypeError, ValueError, KeyError) |
| - Race conditions and threading errors |
| - Resource exhaustion (memory, connections, file handles) |
| """ |
|
|
| import pytest |
| import logging |
| from unittest.mock import patch, MagicMock, AsyncMock |
| from unittest.mock import _patch as _patch_type |
| from typing import Any, Dict, List, Optional, Type, Callable |
| from contextlib import contextmanager |
| from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError, DataError |
| from sqlalchemy.orm import Session |
| import time |
|
|
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.fixture |
| def mock_database_error(): |
| """ |
| Patch database operations to raise SQLAlchemy exceptions. |
| |
| Usage: |
| def test_constraint_violation(mock_database_error): |
| mock_database_error(patch_target, IntegrityError("unique constraint")) |
| # Test that code handles IntegrityError gracefully |
| """ |
| def _patch(target: str, exception: Exception, call_count: int = 1): |
| """ |
| Patch target to raise exception on Nth call. |
| |
| Args: |
| target: Import path to patch (e.g., "core.database.get_db_session") |
| exception: Exception to raise |
| call_count: Raise on Nth call (1 = first call, 2 = second call) |
| """ |
| calls = [0] |
|
|
| def side_effect(*args, **kwargs): |
| calls[0] += 1 |
| if calls[0] == call_count: |
| raise exception |
| |
| if hasattr(exception, '__class__') and exception.__class__.__name__ == 'IntegrityError': |
| |
| return MagicMock() |
| return MagicMock() |
|
|
| return patch(target, side_effect=side_effect) |
|
|
| return _patch |
|
|
|
|
| @pytest.fixture |
| def mock_network_error(): |
| """ |
| Patch HTTP/network operations to raise connection errors. |
| |
| Usage: |
| def test_api_timeout(mock_network_error): |
| mock_network_error("requests.get", TimeoutError("Connection timed out")) |
| # Test retry logic or graceful degradation |
| """ |
| def _patch(target: str, exception: Exception = ConnectionError("Network unreachable")): |
| """ |
| Patch target to raise network exception. |
| |
| Args: |
| target: Import path to patch |
| exception: Network exception to raise (default: ConnectionError) |
| """ |
| return patch(target, side_effect=exception) |
|
|
| return _patch |
|
|
|
|
| @pytest.fixture |
| def mock_llm_error(): |
| """ |
| Patch LLM client operations to raise API errors. |
| |
| Usage: |
| def test_llm_rate_limit(mock_llm_error): |
| mock_llm_error("openai.OpenAI.chat.completions.create", rate_limit_error) |
| # Test fallback to next provider |
| """ |
| def _patch(target: str, error_type: str = "rate_limit"): |
| """ |
| Patch LLM client to raise specific error type. |
| |
| Args: |
| target: Import path to LLM client method |
| error_type: One of "rate_limit", "auth", "timeout", "server_error" |
| """ |
| errors = { |
| "rate_limit": Exception("Rate limit exceeded (429)"), |
| "auth": Exception("Unauthorized (401)"), |
| "timeout": TimeoutError("Request timed out"), |
| "server_error": Exception("Internal server error (500)"), |
| "context_window": Exception("Context window exceeded"), |
| } |
|
|
| exception = errors.get(error_type, errors["server_error"]) |
| return patch(target, side_effect=exception) |
|
|
| return _patch |
|
|
|
|
| @pytest.fixture |
| def mock_filesystem_error(): |
| """ |
| Patch file operations to raise IO errors. |
| |
| Usage: |
| def test_file_not_found(mock_filesystem_error): |
| mock_filesystem_error("builtins.open", FileNotFoundError("File not found")) |
| # Test graceful fallback |
| """ |
| def _patch(target: str, exception: Exception = IOError("Disk error")): |
| """ |
| Patch file operation to raise IO exception. |
| |
| Args: |
| target: Import path to file operation (e.g., "builtins.open") |
| exception: IO exception to raise |
| """ |
| return patch(target, side_effect=exception) |
|
|
| return _patch |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.fixture |
| def assert_logs_error(caplog): |
| """ |
| Verify that an error was logged with correct level and message. |
| |
| Usage: |
| def test_error_logging(caplog, assert_logs_error): |
| with caplog.at_level(logging.ERROR): |
| # Trigger error |
| service.method_that_fails() |
| # Verify error was logged |
| assert_logs_error(caplog, "expected error message") |
| """ |
| def _verify(caplog_object, expected_message: str = None, level: int = logging.ERROR): |
| """ |
| Verify error log entry exists. |
| |
| Args: |
| caplog_object: pytest caplog fixture |
| expected_message: Substring to match in log message (optional) |
| level: Expected log level (default: ERROR) |
| """ |
| assert caplog_object.records, f"No log records found" |
|
|
| |
| level_matches = [r for r in caplog_object.records if r.levelno == level] |
| assert level_matches, f"No {logging.getLevelName(level)} logs found. Got levels: {[r.levelno for r in caplog_object.records]}" |
|
|
| if expected_message: |
| |
| message_matches = [r for r in level_matches if expected_message.lower() in r.message.lower()] |
| assert message_matches, f"No log entry contains '{expected_message}'. Messages: {[r.message for r in level_matches]}" |
|
|
| return True |
|
|
| return _verify |
|
|
|
|
| @pytest.fixture |
| def assert_graceful_degradation(): |
| """ |
| Verify service remains operational after error. |
| |
| Usage: |
| def test_graceful_degradation(assert_graceful_degradation, db_session): |
| cache = GovernanceCache(max_size=100, ttl_seconds=60) |
| # Trigger error |
| with pytest.raises(IntegrityError): |
| db_session.commit() |
| # Verify cache still works |
| assert_graceful_degradation(cache, "get", agent_id="test", action_type="stream_chat") |
| """ |
| def _verify(service, method: str, *args, **kwargs): |
| """ |
| Verify service method still works after error. |
| |
| Args: |
| service: Service instance to test |
| method: Method name to call |
| *args: Positional args for method |
| **kwargs: Keyword args for method |
| """ |
| method_obj = getattr(service, method, None) |
| assert method_obj is not None, f"Service has no method '{method}'" |
|
|
| |
| try: |
| result = method_obj(*args, **kwargs) |
| logger.info(f"Service degraded gracefully: {method}() returned {type(result)}") |
| return True |
| except Exception as e: |
| logger.error(f"Service did NOT degrade gracefully: {method}() raised {type(e).__name__}: {e}") |
| raise AssertionError(f"Service method '{method}' failed after error: {e}") |
|
|
| return _verify |
|
|
|
|
| @pytest.fixture |
| def assert_error_message_contains(): |
| """ |
| Verify exception message contains expected pattern. |
| |
| Usage: |
| def test_error_message(assert_error_message_contains): |
| with pytest.raises(ValueError) as exc_info: |
| raise ValueError("Invalid agent ID: must be string") |
| assert_error_message_contains(exc_info, "Invalid agent ID") |
| """ |
| def _verify(exc_info: pytest.ExceptionInfo, pattern: str, use_regex: bool = False): |
| """ |
| Verify exception message contains pattern. |
| |
| Args: |
| exc_info: pytest exception info from pytest.raises() |
| pattern: String or regex pattern to match |
| use_regex: True if pattern is regex (default: False for substring) |
| """ |
| message = str(exc_info.value) |
|
|
| if use_regex: |
| import re |
| assert re.search(pattern, message, re.IGNORECASE), f"Exception message does not match regex '{pattern}': {message}" |
| else: |
| assert pattern.lower() in message.lower(), f"Exception message does not contain '{pattern}': {message}" |
|
|
| return True |
|
|
| return _verify |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.fixture |
| def corrupt_cache_entry(): |
| """ |
| Create a corrupted cache entry for testing cache recovery. |
| |
| Usage: |
| def test_corrupted_cache_entry(corrupt_cache_entry): |
| cache = GovernanceCache(max_size=100, ttl_seconds=60) |
| corrupted = corrupt_cache_entry() # Missing 'data' key |
| cache._cache["test:stream_chat"] = corrupted |
| # Test that cache handles corruption gracefully |
| """ |
| def _create(corruption_type: str = "missing_data") -> Dict[str, Any]: |
| """ |
| Create corrupted cache entry. |
| |
| Args: |
| corruption_type: Type of corruption |
| - "missing_data": Entry lacks 'data' key |
| - "missing_timestamp": Entry lacks 'cached_at' key |
| - "invalid_data": Entry has non-dict data |
| - "expired": Entry has ancient timestamp |
| """ |
| corrupted_entries = { |
| "missing_data": {"cached_at": time.time()}, |
| "missing_timestamp": {"data": {"allowed": True}}, |
| "invalid_data": ["not", "a", "dict"], |
| "expired": {"data": {"allowed": True}, "cached_at": 0}, |
| } |
|
|
| return corrupted_entries.get(corruption_type, corrupted_entries["missing_data"]) |
|
|
| return _create |
|
|
|
|
| @pytest.fixture |
| def invalid_agent_id(): |
| """ |
| Generate invalid agent_id values for testing input validation. |
| |
| Usage: |
| def test_invalid_agent_id(invalid_agent_id): |
| cache = GovernanceCache(max_size=100, ttl_seconds=60) |
| for invalid_id in invalid_agent_id(): |
| with pytest.raises((TypeError, ValueError)): |
| cache.get(invalid_id, "stream_chat") |
| """ |
| def _generate(format_type: str = "all") -> List[Any]: |
| """ |
| Generate invalid agent_id values. |
| |
| Args: |
| format_type: Type of invalid values to return |
| - "type": Wrong types (int, list, dict, None) |
| - "empty": Empty strings, whitespace |
| - "special": Special characters, unicode |
| - "all": All of the above (default) |
| |
| Returns: |
| List of invalid agent_id values |
| """ |
| invalid_values = { |
| "type": [12345, None, ["list"], {"dict": "value"}, 3.14], |
| "empty": ["", " ", "\t", "\n"], |
| "special": ["agent@example.com", "agent/with/slashes", "agent\\with\\backslashes", "agent with spaces"], |
| } |
|
|
| if format_type == "all": |
| all_invalid = [] |
| for values in invalid_values.values(): |
| all_invalid.extend(values) |
| return all_invalid |
|
|
| return invalid_values.get(format_type, []) |
|
|
| return _generate |
|
|
|
|
| @pytest.fixture |
| def malformed_database_result(): |
| """ |
| Create malformed database query result for testing ORM error handling. |
| |
| Usage: |
| def test_malformed_result(malformed_database_result, db_session): |
| malformed = malformed_database_result("missing_column") |
| with patch.object(db_session, 'query', return_value=malformed): |
| # Test that code handles missing columns gracefully |
| """ |
| def _create(malformation_type: str = "missing_column"): |
| """ |
| Create malformed database result. |
| |
| Args: |
| malformation_type: Type of malformation |
| - "missing_column": Result lacks required column |
| - "wrong_type": Column has wrong type |
| - "null_value": Required column is None |
| - "empty_result": Query returns empty list |
| """ |
| |
| mock_result = MagicMock() |
|
|
| if malformation_type == "missing_column": |
| |
| mock_result.configure_mock(**{"id": "test-agent", "status": "STUDENT"}) |
| |
| del mock_result.name |
|
|
| elif malformation_type == "wrong_type": |
| mock_result.configure_mock(**{"id": 12345, "status": "STUDENT"}) |
|
|
| elif malformation_type == "null_value": |
| mock_result.configure_mock(**{"id": None, "status": "STUDENT"}) |
|
|
| elif malformation_type == "empty_result": |
| mock_result = [] |
|
|
| return mock_result |
|
|
| return _create |
|
|
|
|
| @pytest.fixture |
| def race_condition_scenario(): |
| """ |
| Simulate race condition for testing thread safety. |
| |
| Usage: |
| def test_concurrent_write(race_condition_scenario): |
| cache = GovernanceCache(max_size=100, ttl_seconds=60) |
| def write_operation(): |
| cache.set("agent-1", "stream_chat", {"allowed": True}) |
| # Run concurrent writes |
| race_condition_scenario(write_operation, num_threads=10) |
| """ |
| def _simulate(operation: Callable, num_threads: int = 5, delay_ms: int = 10): |
| """ |
| Simulate concurrent execution of operation. |
| |
| Args: |
| operation: Function to execute concurrently |
| num_threads: Number of threads to spawn |
| delay_ms: Delay between thread starts (ms) to increase race likelihood |
| """ |
| import threading |
| import time |
|
|
| threads = [] |
| errors = [] |
|
|
| def thread_func(): |
| try: |
| time.sleep(delay_ms / 1000.0) |
| operation() |
| except Exception as e: |
| errors.append(e) |
|
|
| |
| for _ in range(num_threads): |
| thread = threading.Thread(target=thread_func) |
| threads.append(thread) |
| thread.start() |
|
|
| |
| for thread in threads: |
| thread.join(timeout=5.0) |
|
|
| |
| if errors: |
| raise AssertionError(f"Race condition detected: {len(errors)} threads raised exceptions: {errors}") |
|
|
| return True |
|
|
| return _simulate |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.fixture |
| def database_constraint_error(): |
| """ |
| Create database constraint violation for testing. |
| |
| Usage: |
| def test_unique_constraint(database_constraint_error, db_session): |
| agent1 = AgentRegistry(id="duplicate-id", name="Agent1") |
| db_session.add(agent1) |
| db_session.commit() |
| agent2 = AgentRegistry(id="duplicate-id", name="Agent2") |
| db_session.add(agent2) |
| with pytest.raises(IntegrityError): |
| db_session.commit() |
| """ |
| def _create(constraint_type: str = "unique") -> Exception: |
| """ |
| Create database constraint error. |
| |
| Args: |
| constraint_type: Type of constraint |
| - "unique": Unique constraint violation |
| - "foreign_key": Foreign key violation |
| - "not_null": NOT NULL violation |
| - "check": CHECK constraint violation |
| """ |
| messages = { |
| "unique": "duplicate key value violates unique constraint", |
| "foreign_key": "insert or update on table violates foreign key constraint", |
| "not_null": "null value in column violates not-null constraint", |
| "check": "new row for relation violates check constraint", |
| } |
|
|
| orig = Exception(messages[constraint_type]) |
| return IntegrityError("INSERT INTO agents", {}, orig) |
|
|
| return _create |
|
|
|
|
| @pytest.fixture |
| def database_connection_error(): |
| """ |
| Simulate database connection failure. |
| |
| Usage: |
| def test_db_connection_failure(database_connection_error): |
| with patch("core.database.create_engine", side_effect=database_connection_error()): |
| # Test connection error handling |
| """ |
| def _create(error_type: str = "connection_refused") -> Exception: |
| """ |
| Create database connection error. |
| |
| Args: |
| error_type: Type of connection error |
| - "connection_refused": Server not reachable |
| - "timeout": Connection timeout |
| - "auth_failed": Authentication failed |
| - "pool_exhausted": Connection pool exhausted |
| """ |
| messages = { |
| "connection_refused": "could not connect to server: Connection refused", |
| "timeout": "server closed the connection unexpectedly", |
| "auth_failed": "password authentication failed", |
| "pool_exhausted": "connection pool exhausted", |
| } |
|
|
| return OperationalError(messages[error_type], {}, None) |
|
|
| return _create |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.fixture |
| def llm_provider_error(): |
| """ |
| Create LLM provider error for testing fallback logic. |
| |
| Usage: |
| def test_provider_fallback(llm_provider_error): |
| with patch("core.llm.byok_handler.OpenAI", side_effect=llm_provider_error("auth")): |
| # Test fallback to next provider |
| """ |
| def _create(error_type: str = "rate_limit") -> Exception: |
| """ |
| Create LLM provider error. |
| |
| Args: |
| error_type: Type of LLM error |
| - "rate_limit": Rate limit exceeded (429) |
| - "auth": Invalid API key (401) |
| - "timeout": Request timeout |
| - "server_error": Internal server error (500) |
| - "context_window": Prompt too long |
| """ |
| errors = { |
| "rate_limit": Exception("Rate limit exceeded (429)"), |
| "auth": Exception("Unauthorized (401): Invalid API key"), |
| "timeout": TimeoutError("Request timed out after 30s"), |
| "server_error": Exception("Internal server error (500)"), |
| "context_window": Exception("This model's maximum context length is"), |
| } |
|
|
| return errors.get(error_type, errors["server_error"]) |
|
|
| return _create |
|
|
|
|
| |
| |
| |
|
|
|
|
| @pytest.fixture |
| def async_error_context(): |
| """ |
| Context manager for testing async error paths. |
| |
| Usage: |
| async def test_async_error(async_error_context): |
| async with async_error_context(ValueError("Expected error")): |
| await async_function_that_fails() |
| """ |
| @contextmanager |
| def _context(expected_exception: Exception): |
| """ |
| Context manager for async error testing. |
| |
| Args: |
| expected_exception: Exception expected to be raised |
| """ |
| try: |
| yield |
| except Exception as e: |
| if type(e).__name__ == type(expected_exception).__name__: |
| logger.info(f"Caught expected error: {type(e).__name__}") |
| raise |
| else: |
| logger.error(f"Unexpected error type: {type(e).__name__} vs {type(expected_exception).__name__}") |
| raise |
|
|
| return _context |
|
|