Phase 3.1: Type Safety - Add bridge_types.py for shared type aliases (EventDict, PowerWatts, Timestamp, DeviceID) - Define protocols for callbacks and message parsers - Strict type annotations on all core modules (session_detector, event_queue, device_manager) - Fix Optional handling and type guards throughout codebase - Achieve full mypy compliance: 0 errors across 47 source files Phase 3.2: Logging Unification - Migrate from stdlib logging to pure structlog across all runtime modules - Convert all logs to structured event+fields format (snake_case event names) - Remove f-string and printf-style logger calls - Add contextvars support for per-request correlation - Implement FastAPI middleware to bind request_id, http_method, http_path - Propagate X-Request-ID header in responses - Remove stdlib logging imports except setup layer (utils/logging.py) - Ensure log-level consistency across all modules Files Modified: - iot_bridge/bridge_types.py (new) - Central type definitions - iot_bridge/core/* - Type safety and logging unification - iot_bridge/clients/* - Structured logging with request context - iot_bridge/parsers/* - Type-safe parsing with structured logs - iot_bridge/utils/logging.py - Pure structlog setup with contextvars - iot_bridge/api/server.py - Added request correlation middleware - iot_bridge/tests/* - Test fixtures updated for type safety - iot_bridge/OPTIMIZATION_PLAN.md - Phase 3 status updated Validation: - mypy . → 0 errors (47 files) - All unit tests pass - Runtime behavior unchanged - API response headers include X-Request-ID
382 lines
10 KiB
Python
382 lines
10 KiB
Python
"""
|
|
Custom exceptions for IoT Bridge.
|
|
|
|
This module defines a hierarchy of exceptions for better error handling
|
|
and debugging. All exceptions inherit from BridgeError base class.
|
|
|
|
Exception Hierarchy:
|
|
BridgeError (base)
|
|
├── ConfigurationError
|
|
├── ConnectionError
|
|
│ ├── MQTTConnectionError
|
|
│ └── OdooAPIError
|
|
├── DeviceError
|
|
│ ├── DeviceNotFoundError
|
|
│ └── ParserError
|
|
└── ValidationError
|
|
└── ConfigValidationError
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
|
|
class BridgeError(Exception):
|
|
"""
|
|
Base exception for all IoT Bridge errors.
|
|
|
|
All custom exceptions should inherit from this class to enable
|
|
catching all bridge-specific errors with a single except clause.
|
|
|
|
Attributes:
|
|
message: Human-readable error description
|
|
details: Optional dictionary with additional context
|
|
"""
|
|
|
|
def __init__(self, message: str, details: dict | None = None):
|
|
"""
|
|
Initialize bridge error.
|
|
|
|
Args:
|
|
message: Error description
|
|
details: Optional context dict (e.g., {"device_id": "xyz"})
|
|
"""
|
|
self.message = message
|
|
self.details = details or {}
|
|
super().__init__(message)
|
|
|
|
def __str__(self) -> str:
|
|
"""Return formatted error message with details."""
|
|
if self.details:
|
|
details_str = ", ".join(f"{k}={v}" for k, v in self.details.items())
|
|
return f"{self.message} [{details_str}]"
|
|
return self.message
|
|
|
|
|
|
# ============================================================================
|
|
# Configuration Errors
|
|
# ============================================================================
|
|
|
|
|
|
class ConfigurationError(BridgeError):
|
|
"""
|
|
Raised when configuration is invalid or missing.
|
|
|
|
Examples:
|
|
- Missing required configuration file
|
|
- Invalid YAML syntax
|
|
- Missing required configuration fields
|
|
"""
|
|
|
|
def __init__(self, message: str, path: str | None = None, details: dict | None = None):
|
|
"""
|
|
Initialize configuration error.
|
|
|
|
Args:
|
|
message: Error description
|
|
path: Optional path to configuration file
|
|
details: Additional context
|
|
"""
|
|
self.path = path
|
|
full_details = details or {}
|
|
if path:
|
|
full_details["path"] = path
|
|
super().__init__(message, full_details)
|
|
|
|
|
|
class ConfigValidationError(ConfigurationError):
|
|
"""
|
|
Raised when configuration validation fails.
|
|
|
|
Examples:
|
|
- Invalid port number (e.g., port=-1)
|
|
- Invalid URL format
|
|
- Invalid device configuration
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
field: str | None = None,
|
|
value: Any = None,
|
|
path: str | None = None,
|
|
details: dict | None = None,
|
|
):
|
|
"""
|
|
Initialize config validation error.
|
|
|
|
Args:
|
|
message: Error description
|
|
field: Name of the invalid field
|
|
value: The invalid value
|
|
path: Optional path to configuration file
|
|
details: Additional context
|
|
"""
|
|
self.field = field
|
|
self.value = value
|
|
full_details = details or {}
|
|
if field:
|
|
full_details["field"] = field
|
|
if value is not None:
|
|
full_details["value"] = value
|
|
super().__init__(message, path, full_details)
|
|
|
|
|
|
# ============================================================================
|
|
# Connection Errors
|
|
# ============================================================================
|
|
|
|
|
|
class ConnectionError(BridgeError):
|
|
"""
|
|
Base class for connection-related errors.
|
|
|
|
Examples:
|
|
- Network timeouts
|
|
- Connection refused
|
|
- Authentication failures
|
|
"""
|
|
|
|
def __init__(self, message: str, service: str | None = None, details: dict | None = None):
|
|
"""
|
|
Initialize connection error.
|
|
|
|
Args:
|
|
message: Error description
|
|
service: Service name (e.g., 'mqtt', 'odoo')
|
|
details: Additional context
|
|
"""
|
|
self.service = service
|
|
full_details = details or {}
|
|
if service:
|
|
full_details["service"] = service
|
|
super().__init__(message, full_details)
|
|
|
|
|
|
class MQTTConnectionError(ConnectionError):
|
|
"""
|
|
Raised when MQTT broker connection fails.
|
|
|
|
Examples:
|
|
- Cannot connect to MQTT broker
|
|
- Authentication failed
|
|
- Connection lost unexpectedly
|
|
- Subscribe/publish failures
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
broker: str | None = None,
|
|
port: int | None = None,
|
|
details: dict | None = None,
|
|
):
|
|
"""
|
|
Initialize MQTT connection error.
|
|
|
|
Args:
|
|
message: Error description
|
|
broker: MQTT broker address
|
|
port: MQTT broker port
|
|
details: Additional context
|
|
"""
|
|
self.broker = broker
|
|
self.port = port
|
|
full_details = details or {}
|
|
if broker:
|
|
full_details["broker"] = broker
|
|
if port:
|
|
full_details["port"] = port
|
|
super().__init__(message, service="mqtt", details=full_details)
|
|
|
|
|
|
class OdooAPIError(ConnectionError):
|
|
"""
|
|
Raised when Odoo API communication fails.
|
|
|
|
Examples:
|
|
- HTTP request failures
|
|
- Authentication errors
|
|
- Invalid API responses
|
|
- Network timeouts
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
status_code: int | None = None,
|
|
response: dict | None = None,
|
|
details: dict | None = None,
|
|
):
|
|
"""
|
|
Initialize Odoo API error.
|
|
|
|
Args:
|
|
message: Error description
|
|
status_code: HTTP status code (if applicable)
|
|
response: API response body (if available)
|
|
details: Additional context
|
|
"""
|
|
self.status_code = status_code
|
|
self.response = response
|
|
full_details = details or {}
|
|
if status_code:
|
|
full_details["status_code"] = status_code
|
|
if response:
|
|
full_details["response"] = response
|
|
super().__init__(message, service="odoo", details=full_details)
|
|
|
|
|
|
# ============================================================================
|
|
# Device Errors
|
|
# ============================================================================
|
|
|
|
|
|
class DeviceError(BridgeError):
|
|
"""
|
|
Base class for device-related errors.
|
|
|
|
Examples:
|
|
- Device not configured
|
|
- Parser errors
|
|
- Invalid device state
|
|
"""
|
|
|
|
def __init__(self, message: str, device_id: str | None = None, details: dict | None = None):
|
|
"""
|
|
Initialize device error.
|
|
|
|
Args:
|
|
message: Error description
|
|
device_id: Optional device identifier
|
|
details: Additional context
|
|
"""
|
|
self.device_id = device_id
|
|
full_details = details or {}
|
|
if device_id:
|
|
full_details["device_id"] = device_id
|
|
super().__init__(message, full_details)
|
|
|
|
|
|
class DeviceNotFoundError(DeviceError):
|
|
"""
|
|
Raised when a device is not found in configuration.
|
|
|
|
Examples:
|
|
- Received MQTT message for unconfigured device
|
|
- Device removed during runtime
|
|
"""
|
|
|
|
def __init__(self, device_id: str, message: str | None = None):
|
|
"""
|
|
Initialize device not found error.
|
|
|
|
Args:
|
|
device_id: The device identifier that was not found
|
|
message: Optional custom message
|
|
"""
|
|
self.device_id = device_id
|
|
msg = message or f"Device not found: {device_id}"
|
|
super().__init__(msg, device_id=device_id)
|
|
|
|
|
|
class ParserError(DeviceError):
|
|
"""
|
|
Raised when message parsing fails.
|
|
|
|
Examples:
|
|
- Invalid message format
|
|
- Unsupported device type
|
|
- Missing required fields in payload
|
|
"""
|
|
|
|
def __init__(self, message: str, topic: str | None = None, payload: dict | None = None):
|
|
"""
|
|
Initialize parser error.
|
|
|
|
Args:
|
|
message: Error description
|
|
topic: MQTT topic (if applicable)
|
|
payload: Message payload (if applicable)
|
|
"""
|
|
self.topic = topic
|
|
self.payload = payload
|
|
details: dict[str, Any] = {}
|
|
if topic:
|
|
details["topic"] = topic
|
|
if payload:
|
|
details["payload_keys"] = list(payload.keys()) if isinstance(payload, dict) else None
|
|
super().__init__(message, details=details)
|
|
|
|
|
|
# ============================================================================
|
|
# Validation Errors
|
|
# ============================================================================
|
|
|
|
|
|
class ValidationError(BridgeError):
|
|
"""
|
|
Base class for validation errors.
|
|
|
|
Examples:
|
|
- Invalid input data
|
|
- Schema validation failures
|
|
- Business rule violations
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
field: str | None = None,
|
|
value: Any = None,
|
|
details: dict | None = None,
|
|
):
|
|
"""
|
|
Initialize validation error.
|
|
|
|
Args:
|
|
message: Error description
|
|
field: Name of the field that failed validation
|
|
value: The invalid value
|
|
details: Additional context
|
|
"""
|
|
self.field = field
|
|
self.value = value
|
|
full_details = details or {}
|
|
if field:
|
|
full_details["field"] = field
|
|
if value is not None:
|
|
full_details["value"] = value
|
|
super().__init__(message, full_details)
|
|
|
|
|
|
# ============================================================================
|
|
# Utility Functions
|
|
# ============================================================================
|
|
|
|
|
|
def wrap_exception(exc: Exception, context: str) -> BridgeError:
|
|
"""
|
|
Wrap a generic exception in a BridgeError with context.
|
|
|
|
This is useful for converting standard library exceptions (e.g., OSError,
|
|
ValueError) into BridgeError instances while preserving the original
|
|
error message and adding context.
|
|
|
|
Args:
|
|
exc: The original exception
|
|
context: Description of what was being done when error occurred
|
|
|
|
Returns:
|
|
BridgeError instance wrapping the original exception
|
|
|
|
Example:
|
|
try:
|
|
with open(config_file) as f:
|
|
config = yaml.load(f)
|
|
except (OSError, yaml.YAMLError) as e:
|
|
raise wrap_exception(e, f"Loading config from {config_file}")
|
|
"""
|
|
message = f"{context}: {str(exc)}"
|
|
details = {"original_exception": type(exc).__name__, "original_message": str(exc)}
|
|
return BridgeError(message, details)
|