odoo_mqtt/iot_bridge/exceptions.py
matthias.lotz ff4ef2f563 Phase 3: Complete type safety & logging unification (3.1-3.2)
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
2026-02-18 23:54:27 +01:00

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)