Michael Hu commited on
Commit
6613cd9
·
1 Parent(s): f7492cb

Implement comprehensive error handling

Browse files
src/application/error_handling/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Error handling utilities for the application layer."""
src/application/error_handling/error_mapper.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Error message mapping for user-friendly error handling."""
2
+
3
+ import logging
4
+ from typing import Dict, Optional, Any
5
+ from enum import Enum
6
+
7
+ from ...domain.exceptions import (
8
+ DomainException,
9
+ InvalidAudioFormatException,
10
+ InvalidTextContentException,
11
+ TranslationFailedException,
12
+ SpeechRecognitionException,
13
+ SpeechSynthesisException,
14
+ InvalidVoiceSettingsException,
15
+ AudioProcessingException
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ErrorSeverity(Enum):
22
+ """Error severity levels."""
23
+ LOW = "low"
24
+ MEDIUM = "medium"
25
+ HIGH = "high"
26
+ CRITICAL = "critical"
27
+
28
+
29
+ class ErrorCategory(Enum):
30
+ """Error categories for classification."""
31
+ VALIDATION = "validation"
32
+ PROCESSING = "processing"
33
+ SYSTEM = "system"
34
+ EXTERNAL = "external"
35
+ CONFIGURATION = "configuration"
36
+
37
+
38
+ class ErrorMapping:
39
+ """Error mapping configuration."""
40
+
41
+ def __init__(self, user_message: str, error_code: str,
42
+ severity: ErrorSeverity, category: ErrorCategory,
43
+ recovery_suggestions: Optional[list] = None,
44
+ technical_details: Optional[str] = None):
45
+ self.user_message = user_message
46
+ self.error_code = error_code
47
+ self.severity = severity
48
+ self.category = category
49
+ self.recovery_suggestions = recovery_suggestions or []
50
+ self.technical_details = technical_details
51
+
52
+
53
+ class ErrorMapper:
54
+ """Maps domain exceptions to user-friendly error messages."""
55
+
56
+ def __init__(self):
57
+ """Initialize error mapper with predefined mappings."""
58
+ self._mappings: Dict[type, ErrorMapping] = {
59
+ # Audio format errors
60
+ InvalidAudioFormatException: ErrorMapping(
61
+ user_message="The uploaded audio file format is not supported. Please use WAV, MP3, FLAC, or OGG format.",
62
+ error_code="INVALID_AUDIO_FORMAT",
63
+ severity=ErrorSeverity.MEDIUM,
64
+ category=ErrorCategory.VALIDATION,
65
+ recovery_suggestions=[
66
+ "Convert your audio file to a supported format (WAV, MP3, FLAC, or OGG)",
67
+ "Check that your file is not corrupted",
68
+ "Ensure the file has a proper audio extension"
69
+ ]
70
+ ),
71
+
72
+ # Text content errors
73
+ InvalidTextContentException: ErrorMapping(
74
+ user_message="The text content is invalid or too long. Please check your input.",
75
+ error_code="INVALID_TEXT_CONTENT",
76
+ severity=ErrorSeverity.MEDIUM,
77
+ category=ErrorCategory.VALIDATION,
78
+ recovery_suggestions=[
79
+ "Ensure text is not empty",
80
+ "Check that text length is within limits (max 10,000 characters)",
81
+ "Verify text encoding is valid"
82
+ ]
83
+ ),
84
+
85
+ # Translation errors
86
+ TranslationFailedException: ErrorMapping(
87
+ user_message="Translation failed. This might be due to unsupported language pairs or service issues.",
88
+ error_code="TRANSLATION_FAILED",
89
+ severity=ErrorSeverity.HIGH,
90
+ category=ErrorCategory.PROCESSING,
91
+ recovery_suggestions=[
92
+ "Try a different target language",
93
+ "Check if the source language is correctly detected",
94
+ "Retry the operation after a few moments",
95
+ "Ensure the text is in a supported language"
96
+ ]
97
+ ),
98
+
99
+ # Speech recognition errors
100
+ SpeechRecognitionException: ErrorMapping(
101
+ user_message="Speech recognition failed. The audio might be unclear or in an unsupported language.",
102
+ error_code="SPEECH_RECOGNITION_FAILED",
103
+ severity=ErrorSeverity.HIGH,
104
+ category=ErrorCategory.PROCESSING,
105
+ recovery_suggestions=[
106
+ "Ensure audio quality is good (clear speech, minimal background noise)",
107
+ "Try a different speech recognition model",
108
+ "Check that the audio contains speech in a supported language",
109
+ "Verify audio file is not corrupted"
110
+ ]
111
+ ),
112
+
113
+ # Speech synthesis errors
114
+ SpeechSynthesisException: ErrorMapping(
115
+ user_message="Text-to-speech generation failed. This might be due to voice availability or text issues.",
116
+ error_code="SPEECH_SYNTHESIS_FAILED",
117
+ severity=ErrorSeverity.HIGH,
118
+ category=ErrorCategory.PROCESSING,
119
+ recovery_suggestions=[
120
+ "Try a different voice",
121
+ "Check if the text contains unsupported characters",
122
+ "Reduce text length if it's very long",
123
+ "Retry with a different TTS provider"
124
+ ]
125
+ ),
126
+
127
+ # Voice settings errors
128
+ InvalidVoiceSettingsException: ErrorMapping(
129
+ user_message="Voice settings are invalid. Please check speed, voice selection, and language settings.",
130
+ error_code="INVALID_VOICE_SETTINGS",
131
+ severity=ErrorSeverity.MEDIUM,
132
+ category=ErrorCategory.VALIDATION,
133
+ recovery_suggestions=[
134
+ "Ensure speed is between 0.5 and 2.0",
135
+ "Select a valid voice from the available options",
136
+ "Check that the language is supported",
137
+ "Verify voice is available for the selected language"
138
+ ]
139
+ ),
140
+
141
+ # General audio processing errors
142
+ AudioProcessingException: ErrorMapping(
143
+ user_message="Audio processing failed. There was an issue processing your audio file.",
144
+ error_code="AUDIO_PROCESSING_FAILED",
145
+ severity=ErrorSeverity.HIGH,
146
+ category=ErrorCategory.PROCESSING,
147
+ recovery_suggestions=[
148
+ "Check that your audio file is valid and not corrupted",
149
+ "Try uploading a different audio file",
150
+ "Ensure file size is within limits",
151
+ "Retry the operation"
152
+ ]
153
+ ),
154
+
155
+ # Validation errors
156
+ ValueError: ErrorMapping(
157
+ user_message="Invalid input provided. Please check your request parameters.",
158
+ error_code="VALIDATION_ERROR",
159
+ severity=ErrorSeverity.MEDIUM,
160
+ category=ErrorCategory.VALIDATION,
161
+ recovery_suggestions=[
162
+ "Check all required fields are provided",
163
+ "Verify parameter values are within valid ranges",
164
+ "Ensure file formats are supported"
165
+ ]
166
+ ),
167
+
168
+ # File size errors
169
+ PermissionError: ErrorMapping(
170
+ user_message="Permission denied. Unable to access required files or directories.",
171
+ error_code="PERMISSION_ERROR",
172
+ severity=ErrorSeverity.HIGH,
173
+ category=ErrorCategory.SYSTEM,
174
+ recovery_suggestions=[
175
+ "Check file permissions",
176
+ "Ensure temporary directory is writable",
177
+ "Contact system administrator if issue persists"
178
+ ]
179
+ ),
180
+
181
+ # Memory errors
182
+ MemoryError: ErrorMapping(
183
+ user_message="Insufficient memory to process the request. Try with a smaller file.",
184
+ error_code="MEMORY_ERROR",
185
+ severity=ErrorSeverity.HIGH,
186
+ category=ErrorCategory.SYSTEM,
187
+ recovery_suggestions=[
188
+ "Use a smaller audio file",
189
+ "Try processing shorter audio segments",
190
+ "Retry the operation later"
191
+ ]
192
+ ),
193
+
194
+ # Timeout errors
195
+ TimeoutError: ErrorMapping(
196
+ user_message="Processing timed out. The operation took too long to complete.",
197
+ error_code="TIMEOUT_ERROR",
198
+ severity=ErrorSeverity.HIGH,
199
+ category=ErrorCategory.SYSTEM,
200
+ recovery_suggestions=[
201
+ "Try with a shorter audio file",
202
+ "Retry the operation",
203
+ "Check system load and try again later"
204
+ ]
205
+ )
206
+ }
207
+
208
+ # Default mapping for unknown errors
209
+ self._default_mapping = ErrorMapping(
210
+ user_message="An unexpected error occurred. Please try again or contact support.",
211
+ error_code="UNKNOWN_ERROR",
212
+ severity=ErrorSeverity.CRITICAL,
213
+ category=ErrorCategory.SYSTEM,
214
+ recovery_suggestions=[
215
+ "Retry the operation",
216
+ "Check your input parameters",
217
+ "Contact support if the issue persists"
218
+ ]
219
+ )
220
+
221
+ def map_exception(self, exception: Exception, context: Optional[Dict[str, Any]] = None) -> ErrorMapping:
222
+ """
223
+ Map an exception to user-friendly error information.
224
+
225
+ Args:
226
+ exception: The exception to map
227
+ context: Optional context information
228
+
229
+ Returns:
230
+ ErrorMapping: Mapped error information
231
+ """
232
+ try:
233
+ # Get mapping for exception type
234
+ mapping = self._mappings.get(type(exception))
235
+
236
+ if mapping is None:
237
+ # Try parent classes for domain exceptions
238
+ for exc_type in type(exception).__mro__:
239
+ if exc_type in self._mappings:
240
+ mapping = self._mappings[exc_type]
241
+ break
242
+
243
+ if mapping is None:
244
+ # Use default mapping
245
+ mapping = self._default_mapping
246
+ logger.warning(f"No mapping found for exception type: {type(exception).__name__}")
247
+
248
+ # Add context-specific information if available
249
+ if context:
250
+ mapping = self._enhance_mapping_with_context(mapping, exception, context)
251
+
252
+ logger.debug(f"Mapped {type(exception).__name__} to {mapping.error_code}")
253
+ return mapping
254
+
255
+ except Exception as e:
256
+ logger.error(f"Error mapping exception {type(exception).__name__}: {e}")
257
+ return self._default_mapping
258
+
259
+ def _enhance_mapping_with_context(self, mapping: ErrorMapping, exception: Exception,
260
+ context: Dict[str, Any]) -> ErrorMapping:
261
+ """
262
+ Enhance error mapping with context-specific information.
263
+
264
+ Args:
265
+ mapping: Base error mapping
266
+ exception: Original exception
267
+ context: Context information
268
+
269
+ Returns:
270
+ ErrorMapping: Enhanced error mapping
271
+ """
272
+ try:
273
+ # Create a copy of the mapping to avoid modifying the original
274
+ enhanced_mapping = ErrorMapping(
275
+ user_message=mapping.user_message,
276
+ error_code=mapping.error_code,
277
+ severity=mapping.severity,
278
+ category=mapping.category,
279
+ recovery_suggestions=mapping.recovery_suggestions.copy(),
280
+ technical_details=mapping.technical_details
281
+ )
282
+
283
+ # Add context-specific enhancements
284
+ if 'file_name' in context:
285
+ enhanced_mapping.user_message += f" (File: {context['file_name']})"
286
+
287
+ if 'operation' in context:
288
+ enhanced_mapping.technical_details = f"Failed during {context['operation']}: {str(exception)}"
289
+
290
+ if 'correlation_id' in context:
291
+ enhanced_mapping.technical_details = (
292
+ f"{enhanced_mapping.technical_details or str(exception)} "
293
+ f"[ID: {context['correlation_id']}]"
294
+ )
295
+
296
+ # Add provider-specific suggestions
297
+ if 'provider' in context:
298
+ provider = context['provider']
299
+ if isinstance(exception, (SpeechRecognitionException, SpeechSynthesisException, TranslationFailedException)):
300
+ enhanced_mapping.recovery_suggestions.append(f"Try switching from {provider} to an alternative provider")
301
+
302
+ # Add file size specific suggestions
303
+ if 'file_size' in context and context['file_size'] > 50 * 1024 * 1024: # 50MB
304
+ enhanced_mapping.recovery_suggestions.insert(0, "Try with a smaller file (under 50MB)")
305
+
306
+ return enhanced_mapping
307
+
308
+ except Exception as e:
309
+ logger.error(f"Error enhancing mapping with context: {e}")
310
+ return mapping
311
+
312
+ def get_error_code_from_exception(self, exception: Exception) -> str:
313
+ """
314
+ Get error code from exception.
315
+
316
+ Args:
317
+ exception: Exception to get code for
318
+
319
+ Returns:
320
+ str: Error code
321
+ """
322
+ mapping = self.map_exception(exception)
323
+ return mapping.error_code
324
+
325
+ def get_user_message_from_exception(self, exception: Exception,
326
+ context: Optional[Dict[str, Any]] = None) -> str:
327
+ """
328
+ Get user-friendly message from exception.
329
+
330
+ Args:
331
+ exception: Exception to get message for
332
+ context: Optional context information
333
+
334
+ Returns:
335
+ str: User-friendly error message
336
+ """
337
+ mapping = self.map_exception(exception, context)
338
+ return mapping.user_message
339
+
340
+ def get_recovery_suggestions(self, exception: Exception,
341
+ context: Optional[Dict[str, Any]] = None) -> list:
342
+ """
343
+ Get recovery suggestions for an exception.
344
+
345
+ Args:
346
+ exception: Exception to get suggestions for
347
+ context: Optional context information
348
+
349
+ Returns:
350
+ list: List of recovery suggestions
351
+ """
352
+ mapping = self.map_exception(exception, context)
353
+ return mapping.recovery_suggestions
354
+
355
+ def add_custom_mapping(self, exception_type: type, mapping: ErrorMapping) -> None:
356
+ """
357
+ Add a custom error mapping.
358
+
359
+ Args:
360
+ exception_type: Exception type to map
361
+ mapping: Error mapping configuration
362
+ """
363
+ self._mappings[exception_type] = mapping
364
+ logger.info(f"Added custom mapping for {exception_type.__name__}")
365
+
366
+ def get_all_error_codes(self) -> list:
367
+ """
368
+ Get all available error codes.
369
+
370
+ Returns:
371
+ list: List of all error codes
372
+ """
373
+ codes = [mapping.error_code for mapping in self._mappings.values()]
374
+ codes.append(self._default_mapping.error_code)
375
+ return list(set(codes))
src/application/error_handling/recovery_manager.py ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Error recovery and fallback mechanisms."""
2
+
3
+ import logging
4
+ import time
5
+ from typing import Dict, List, Optional, Any, Callable, TypeVar, Union
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from functools import wraps
9
+
10
+ from .structured_logger import StructuredLogger, LogContext, get_structured_logger
11
+ from ...domain.exceptions import (
12
+ DomainException,
13
+ SpeechRecognitionException,
14
+ TranslationFailedException,
15
+ SpeechSynthesisException,
16
+ AudioProcessingException
17
+ )
18
+
19
+ logger = get_structured_logger(__name__)
20
+
21
+ T = TypeVar('T')
22
+
23
+
24
+ class RecoveryStrategy(Enum):
25
+ """Recovery strategy types."""
26
+ RETRY = "retry"
27
+ FALLBACK = "fallback"
28
+ CIRCUIT_BREAKER = "circuit_breaker"
29
+ GRACEFUL_DEGRADATION = "graceful_degradation"
30
+
31
+
32
+ class CircuitBreakerState(Enum):
33
+ """Circuit breaker states."""
34
+ CLOSED = "closed"
35
+ OPEN = "open"
36
+ HALF_OPEN = "half_open"
37
+
38
+
39
+ @dataclass
40
+ class RetryConfig:
41
+ """Retry configuration."""
42
+ max_attempts: int = 3
43
+ base_delay: float = 1.0
44
+ max_delay: float = 60.0
45
+ exponential_backoff: bool = True
46
+ jitter: bool = True
47
+ retryable_exceptions: Optional[List[type]] = None
48
+
49
+
50
+ @dataclass
51
+ class CircuitBreakerConfig:
52
+ """Circuit breaker configuration."""
53
+ failure_threshold: int = 5
54
+ recovery_timeout: float = 60.0
55
+ success_threshold: int = 2
56
+ timeout: float = 30.0
57
+
58
+
59
+ @dataclass
60
+ class FallbackConfig:
61
+ """Fallback configuration."""
62
+ fallback_providers: List[str]
63
+ fallback_function: Optional[Callable] = None
64
+ enable_graceful_degradation: bool = True
65
+
66
+
67
+ class CircuitBreaker:
68
+ """Circuit breaker implementation."""
69
+
70
+ def __init__(self, config: CircuitBreakerConfig, name: str = "default"):
71
+ """
72
+ Initialize circuit breaker.
73
+
74
+ Args:
75
+ config: Circuit breaker configuration
76
+ name: Circuit breaker name
77
+ """
78
+ self.config = config
79
+ self.name = name
80
+ self.state = CircuitBreakerState.CLOSED
81
+ self.failure_count = 0
82
+ self.success_count = 0
83
+ self.last_failure_time = 0.0
84
+ self.logger = get_structured_logger(f"{__name__}.CircuitBreaker.{name}")
85
+
86
+ def call(self, func: Callable[..., T], *args, **kwargs) -> T:
87
+ """
88
+ Call function through circuit breaker.
89
+
90
+ Args:
91
+ func: Function to call
92
+ *args: Function arguments
93
+ **kwargs: Function keyword arguments
94
+
95
+ Returns:
96
+ T: Function result
97
+
98
+ Raises:
99
+ Exception: If circuit is open or function fails
100
+ """
101
+ context = LogContext(
102
+ correlation_id=kwargs.get('correlation_id', 'unknown'),
103
+ component=f"circuit_breaker_{self.name}",
104
+ operation=func.__name__
105
+ )
106
+
107
+ if self.state == CircuitBreakerState.OPEN:
108
+ if time.time() - self.last_failure_time < self.config.recovery_timeout:
109
+ self.logger.warning(
110
+ f"Circuit breaker {self.name} is OPEN, rejecting call",
111
+ context=context
112
+ )
113
+ raise AudioProcessingException(f"Circuit breaker {self.name} is open")
114
+ else:
115
+ self.state = CircuitBreakerState.HALF_OPEN
116
+ self.success_count = 0
117
+ self.logger.info(
118
+ f"Circuit breaker {self.name} transitioning to HALF_OPEN",
119
+ context=context
120
+ )
121
+
122
+ try:
123
+ result = func(*args, **kwargs)
124
+ self._on_success(context)
125
+ return result
126
+
127
+ except Exception as e:
128
+ self._on_failure(e, context)
129
+ raise
130
+
131
+ def _on_success(self, context: LogContext) -> None:
132
+ """Handle successful call."""
133
+ if self.state == CircuitBreakerState.HALF_OPEN:
134
+ self.success_count += 1
135
+ if self.success_count >= self.config.success_threshold:
136
+ self.state = CircuitBreakerState.CLOSED
137
+ self.failure_count = 0
138
+ self.logger.info(
139
+ f"Circuit breaker {self.name} transitioning to CLOSED",
140
+ context=context
141
+ )
142
+ elif self.state == CircuitBreakerState.CLOSED:
143
+ self.failure_count = 0
144
+
145
+ def _on_failure(self, exception: Exception, context: LogContext) -> None:
146
+ """Handle failed call."""
147
+ self.failure_count += 1
148
+ self.last_failure_time = time.time()
149
+
150
+ if self.state == CircuitBreakerState.HALF_OPEN:
151
+ self.state = CircuitBreakerState.OPEN
152
+ self.logger.warning(
153
+ f"Circuit breaker {self.name} transitioning to OPEN after failure in HALF_OPEN",
154
+ context=context,
155
+ exception=exception
156
+ )
157
+ elif (self.state == CircuitBreakerState.CLOSED and
158
+ self.failure_count >= self.config.failure_threshold):
159
+ self.state = CircuitBreakerState.OPEN
160
+ self.logger.warning(
161
+ f"Circuit breaker {self.name} transitioning to OPEN after {self.failure_count} failures",
162
+ context=context,
163
+ exception=exception
164
+ )
165
+
166
+
167
+ class RecoveryManager:
168
+ """Manages error recovery and fallback mechanisms."""
169
+
170
+ def __init__(self):
171
+ """Initialize recovery manager."""
172
+ self.circuit_breakers: Dict[str, CircuitBreaker] = {}
173
+ self.logger = get_structured_logger(__name__)
174
+
175
+ # Default retry configurations for different exception types
176
+ self.default_retry_configs = {
177
+ SpeechRecognitionException: RetryConfig(
178
+ max_attempts=2,
179
+ base_delay=2.0,
180
+ retryable_exceptions=[SpeechRecognitionException]
181
+ ),
182
+ TranslationFailedException: RetryConfig(
183
+ max_attempts=3,
184
+ base_delay=1.0,
185
+ retryable_exceptions=[TranslationFailedException]
186
+ ),
187
+ SpeechSynthesisException: RetryConfig(
188
+ max_attempts=2,
189
+ base_delay=1.5,
190
+ retryable_exceptions=[SpeechSynthesisException]
191
+ ),
192
+ ConnectionError: RetryConfig(
193
+ max_attempts=3,
194
+ base_delay=2.0,
195
+ exponential_backoff=True,
196
+ retryable_exceptions=[ConnectionError, TimeoutError]
197
+ )
198
+ }
199
+
200
+ def get_circuit_breaker(self, name: str, config: Optional[CircuitBreakerConfig] = None) -> CircuitBreaker:
201
+ """
202
+ Get or create circuit breaker.
203
+
204
+ Args:
205
+ name: Circuit breaker name
206
+ config: Optional configuration
207
+
208
+ Returns:
209
+ CircuitBreaker: Circuit breaker instance
210
+ """
211
+ if name not in self.circuit_breakers:
212
+ if config is None:
213
+ config = CircuitBreakerConfig()
214
+ self.circuit_breakers[name] = CircuitBreaker(config, name)
215
+
216
+ return self.circuit_breakers[name]
217
+
218
+ def retry_with_backoff(self, func: Callable[..., T],
219
+ config: Optional[RetryConfig] = None,
220
+ correlation_id: Optional[str] = None,
221
+ *args, **kwargs) -> T:
222
+ """
223
+ Execute function with retry and exponential backoff.
224
+
225
+ Args:
226
+ func: Function to execute
227
+ config: Retry configuration
228
+ correlation_id: Correlation ID for logging
229
+ *args: Function arguments
230
+ **kwargs: Function keyword arguments
231
+
232
+ Returns:
233
+ T: Function result
234
+
235
+ Raises:
236
+ Exception: If all retry attempts fail
237
+ """
238
+ if config is None:
239
+ config = RetryConfig()
240
+
241
+ context = LogContext(
242
+ correlation_id=correlation_id or 'unknown',
243
+ component='retry_manager',
244
+ operation=func.__name__
245
+ )
246
+
247
+ last_exception = None
248
+
249
+ for attempt in range(config.max_attempts):
250
+ try:
251
+ if attempt > 0:
252
+ delay = self._calculate_delay(attempt, config)
253
+ self.logger.info(
254
+ f"Retrying {func.__name__} (attempt {attempt + 1}/{config.max_attempts}) after {delay:.2f}s delay",
255
+ context=context
256
+ )
257
+ time.sleep(delay)
258
+
259
+ result = func(*args, **kwargs)
260
+
261
+ if attempt > 0:
262
+ self.logger.info(
263
+ f"Retry successful for {func.__name__} on attempt {attempt + 1}",
264
+ context=context
265
+ )
266
+
267
+ return result
268
+
269
+ except Exception as e:
270
+ last_exception = e
271
+
272
+ # Check if exception is retryable
273
+ if not self._is_retryable_exception(e, config):
274
+ self.logger.error(
275
+ f"Non-retryable exception in {func.__name__}: {type(e).__name__}",
276
+ context=context,
277
+ exception=e
278
+ )
279
+ raise
280
+
281
+ self.logger.warning(
282
+ f"Attempt {attempt + 1}/{config.max_attempts} failed for {func.__name__}: {e}",
283
+ context=context,
284
+ exception=e
285
+ )
286
+
287
+ # All attempts failed
288
+ self.logger.error(
289
+ f"All {config.max_attempts} retry attempts failed for {func.__name__}",
290
+ context=context,
291
+ exception=last_exception
292
+ )
293
+ raise last_exception
294
+
295
+ def execute_with_fallback(self, primary_func: Callable[..., T],
296
+ fallback_funcs: List[Callable[..., T]],
297
+ correlation_id: Optional[str] = None,
298
+ *args, **kwargs) -> T:
299
+ """
300
+ Execute function with fallback options.
301
+
302
+ Args:
303
+ primary_func: Primary function to execute
304
+ fallback_funcs: List of fallback functions
305
+ correlation_id: Correlation ID for logging
306
+ *args: Function arguments
307
+ **kwargs: Function keyword arguments
308
+
309
+ Returns:
310
+ T: Function result
311
+
312
+ Raises:
313
+ Exception: If all functions fail
314
+ """
315
+ context = LogContext(
316
+ correlation_id=correlation_id or 'unknown',
317
+ component='fallback_manager',
318
+ operation=primary_func.__name__
319
+ )
320
+
321
+ # Try primary function first
322
+ try:
323
+ result = primary_func(*args, **kwargs)
324
+ return result
325
+
326
+ except Exception as e:
327
+ self.logger.warning(
328
+ f"Primary function {primary_func.__name__} failed, trying fallbacks",
329
+ context=context,
330
+ exception=e
331
+ )
332
+
333
+ last_exception = e
334
+
335
+ # Try fallback functions
336
+ for i, fallback_func in enumerate(fallback_funcs):
337
+ try:
338
+ self.logger.info(
339
+ f"Trying fallback {i + 1}/{len(fallback_funcs)}: {fallback_func.__name__}",
340
+ context=context
341
+ )
342
+
343
+ result = fallback_func(*args, **kwargs)
344
+
345
+ self.logger.info(
346
+ f"Fallback {fallback_func.__name__} succeeded",
347
+ context=context
348
+ )
349
+
350
+ return result
351
+
352
+ except Exception as fallback_e:
353
+ last_exception = fallback_e
354
+ self.logger.warning(
355
+ f"Fallback {fallback_func.__name__} failed: {fallback_e}",
356
+ context=context,
357
+ exception=fallback_e
358
+ )
359
+
360
+ # All functions failed
361
+ self.logger.error(
362
+ f"All functions failed (primary + {len(fallback_funcs)} fallbacks)",
363
+ context=context,
364
+ exception=last_exception
365
+ )
366
+ raise last_exception
367
+
368
+ def execute_with_circuit_breaker(self, func: Callable[..., T],
369
+ circuit_breaker_name: str,
370
+ config: Optional[CircuitBreakerConfig] = None,
371
+ correlation_id: Optional[str] = None,
372
+ *args, **kwargs) -> T:
373
+ """
374
+ Execute function with circuit breaker protection.
375
+
376
+ Args:
377
+ func: Function to execute
378
+ circuit_breaker_name: Circuit breaker name
379
+ config: Optional circuit breaker configuration
380
+ correlation_id: Correlation ID for logging
381
+ *args: Function arguments
382
+ **kwargs: Function keyword arguments
383
+
384
+ Returns:
385
+ T: Function result
386
+
387
+ Raises:
388
+ Exception: If circuit is open or function fails
389
+ """
390
+ circuit_breaker = self.get_circuit_breaker(circuit_breaker_name, config)
391
+
392
+ # Add correlation ID to kwargs for circuit breaker logging
393
+ if correlation_id:
394
+ kwargs['correlation_id'] = correlation_id
395
+
396
+ return circuit_breaker.call(func, *args, **kwargs)
397
+
398
+ def _calculate_delay(self, attempt: int, config: RetryConfig) -> float:
399
+ """
400
+ Calculate retry delay with exponential backoff and jitter.
401
+
402
+ Args:
403
+ attempt: Attempt number (0-based)
404
+ config: Retry configuration
405
+
406
+ Returns:
407
+ float: Delay in seconds
408
+ """
409
+ if config.exponential_backoff:
410
+ delay = config.base_delay * (2 ** attempt)
411
+ else:
412
+ delay = config.base_delay
413
+
414
+ # Apply maximum delay limit
415
+ delay = min(delay, config.max_delay)
416
+
417
+ # Add jitter to prevent thundering herd
418
+ if config.jitter:
419
+ import random
420
+ delay *= (0.5 + random.random() * 0.5)
421
+
422
+ return delay
423
+
424
+ def _is_retryable_exception(self, exception: Exception, config: RetryConfig) -> bool:
425
+ """
426
+ Check if exception is retryable.
427
+
428
+ Args:
429
+ exception: Exception to check
430
+ config: Retry configuration
431
+
432
+ Returns:
433
+ bool: True if exception is retryable
434
+ """
435
+ if config.retryable_exceptions is None:
436
+ # Default retryable exceptions
437
+ retryable_types = (
438
+ ConnectionError,
439
+ TimeoutError,
440
+ SpeechRecognitionException,
441
+ TranslationFailedException,
442
+ SpeechSynthesisException
443
+ )
444
+ return isinstance(exception, retryable_types)
445
+
446
+ return any(isinstance(exception, exc_type) for exc_type in config.retryable_exceptions)
447
+
448
+ def get_retry_config_for_exception(self, exception: Exception) -> RetryConfig:
449
+ """
450
+ Get appropriate retry configuration for exception type.
451
+
452
+ Args:
453
+ exception: Exception to get config for
454
+
455
+ Returns:
456
+ RetryConfig: Retry configuration
457
+ """
458
+ for exc_type, config in self.default_retry_configs.items():
459
+ if isinstance(exception, exc_type):
460
+ return config
461
+
462
+ # Return default config
463
+ return RetryConfig()
464
+
465
+
466
+ def with_retry(config: Optional[RetryConfig] = None):
467
+ """
468
+ Decorator for automatic retry with backoff.
469
+
470
+ Args:
471
+ config: Optional retry configuration
472
+
473
+ Returns:
474
+ Decorated function
475
+ """
476
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
477
+ @wraps(func)
478
+ def wrapper(*args, **kwargs) -> T:
479
+ recovery_manager = RecoveryManager()
480
+ correlation_id = kwargs.get('correlation_id')
481
+ return recovery_manager.retry_with_backoff(
482
+ func, config, correlation_id, *args, **kwargs
483
+ )
484
+ return wrapper
485
+ return decorator
486
+
487
+
488
+ def with_circuit_breaker(name: str, config: Optional[CircuitBreakerConfig] = None):
489
+ """
490
+ Decorator for circuit breaker protection.
491
+
492
+ Args:
493
+ name: Circuit breaker name
494
+ config: Optional circuit breaker configuration
495
+
496
+ Returns:
497
+ Decorated function
498
+ """
499
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
500
+ @wraps(func)
501
+ def wrapper(*args, **kwargs) -> T:
502
+ recovery_manager = RecoveryManager()
503
+ correlation_id = kwargs.get('correlation_id')
504
+ return recovery_manager.execute_with_circuit_breaker(
505
+ func, name, config, correlation_id, *args, **kwargs
506
+ )
507
+ return wrapper
508
+ return decorator
src/application/error_handling/structured_logger.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured logging with correlation IDs and context management."""
2
+
3
+ import logging
4
+ import json
5
+ import uuid
6
+ import time
7
+ from typing import Dict, Any, Optional, Union
8
+ from datetime import datetime
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass, asdict
11
+ from enum import Enum
12
+
13
+ # Context variable for correlation ID
14
+ correlation_id_context: ContextVar[Optional[str]] = ContextVar('correlation_id', default=None)
15
+
16
+
17
+ class LogLevel(Enum):
18
+ """Log levels."""
19
+ DEBUG = "DEBUG"
20
+ INFO = "INFO"
21
+ WARNING = "WARNING"
22
+ ERROR = "ERROR"
23
+ CRITICAL = "CRITICAL"
24
+
25
+
26
+ @dataclass
27
+ class LogContext:
28
+ """Structured log context."""
29
+ correlation_id: str
30
+ operation: Optional[str] = None
31
+ component: Optional[str] = None
32
+ user_id: Optional[str] = None
33
+ session_id: Optional[str] = None
34
+ request_id: Optional[str] = None
35
+ metadata: Optional[Dict[str, Any]] = None
36
+
37
+ def to_dict(self) -> Dict[str, Any]:
38
+ """Convert to dictionary."""
39
+ return {k: v for k, v in asdict(self).items() if v is not None}
40
+
41
+
42
+ class StructuredLogger:
43
+ """Structured logger with correlation ID support."""
44
+
45
+ def __init__(self, name: str, enable_json_logging: bool = True):
46
+ """
47
+ Initialize structured logger.
48
+
49
+ Args:
50
+ name: Logger name
51
+ enable_json_logging: Whether to use JSON format
52
+ """
53
+ self.logger = logging.getLogger(name)
54
+ self.enable_json_logging = enable_json_logging
55
+ self._setup_formatter()
56
+
57
+ def _setup_formatter(self) -> None:
58
+ """Setup log formatter."""
59
+ if self.enable_json_logging:
60
+ formatter = JsonFormatter()
61
+ else:
62
+ formatter = ContextFormatter()
63
+
64
+ # Apply formatter to all handlers
65
+ for handler in self.logger.handlers:
66
+ handler.setFormatter(formatter)
67
+
68
+ def _get_log_data(self, message: str, level: str,
69
+ context: Optional[LogContext] = None,
70
+ extra: Optional[Dict[str, Any]] = None,
71
+ exception: Optional[Exception] = None) -> Dict[str, Any]:
72
+ """
73
+ Prepare log data structure.
74
+
75
+ Args:
76
+ message: Log message
77
+ level: Log level
78
+ context: Log context
79
+ extra: Extra data
80
+ exception: Exception if any
81
+
82
+ Returns:
83
+ Dict[str, Any]: Structured log data
84
+ """
85
+ # Get correlation ID from context or generate new one
86
+ correlation_id = correlation_id_context.get()
87
+ if not correlation_id and context:
88
+ correlation_id = context.correlation_id
89
+ if not correlation_id:
90
+ correlation_id = str(uuid.uuid4())
91
+
92
+ log_data = {
93
+ 'timestamp': datetime.utcnow().isoformat() + 'Z',
94
+ 'level': level,
95
+ 'message': message,
96
+ 'correlation_id': correlation_id,
97
+ 'logger_name': self.logger.name
98
+ }
99
+
100
+ # Add context information
101
+ if context:
102
+ log_data.update(context.to_dict())
103
+
104
+ # Add extra data
105
+ if extra:
106
+ log_data['extra'] = extra
107
+
108
+ # Add exception information
109
+ if exception:
110
+ log_data['exception'] = {
111
+ 'type': type(exception).__name__,
112
+ 'message': str(exception),
113
+ 'module': getattr(exception, '__module__', None)
114
+ }
115
+
116
+ # Add stack trace for debugging
117
+ import traceback
118
+ log_data['exception']['traceback'] = traceback.format_exc()
119
+
120
+ return log_data
121
+
122
+ def debug(self, message: str, context: Optional[LogContext] = None,
123
+ extra: Optional[Dict[str, Any]] = None) -> None:
124
+ """Log debug message."""
125
+ if self.logger.isEnabledFor(logging.DEBUG):
126
+ log_data = self._get_log_data(message, LogLevel.DEBUG.value, context, extra)
127
+ self.logger.debug(message, extra=log_data)
128
+
129
+ def info(self, message: str, context: Optional[LogContext] = None,
130
+ extra: Optional[Dict[str, Any]] = None) -> None:
131
+ """Log info message."""
132
+ if self.logger.isEnabledFor(logging.INFO):
133
+ log_data = self._get_log_data(message, LogLevel.INFO.value, context, extra)
134
+ self.logger.info(message, extra=log_data)
135
+
136
+ def warning(self, message: str, context: Optional[LogContext] = None,
137
+ extra: Optional[Dict[str, Any]] = None) -> None:
138
+ """Log warning message."""
139
+ if self.logger.isEnabledFor(logging.WARNING):
140
+ log_data = self._get_log_data(message, LogLevel.WARNING.value, context, extra)
141
+ self.logger.warning(message, extra=log_data)
142
+
143
+ def error(self, message: str, context: Optional[LogContext] = None,
144
+ extra: Optional[Dict[str, Any]] = None,
145
+ exception: Optional[Exception] = None) -> None:
146
+ """Log error message."""
147
+ if self.logger.isEnabledFor(logging.ERROR):
148
+ log_data = self._get_log_data(message, LogLevel.ERROR.value, context, extra, exception)
149
+ self.logger.error(message, extra=log_data)
150
+
151
+ def critical(self, message: str, context: Optional[LogContext] = None,
152
+ extra: Optional[Dict[str, Any]] = None,
153
+ exception: Optional[Exception] = None) -> None:
154
+ """Log critical message."""
155
+ if self.logger.isEnabledFor(logging.CRITICAL):
156
+ log_data = self._get_log_data(message, LogLevel.CRITICAL.value, context, extra, exception)
157
+ self.logger.critical(message, extra=log_data)
158
+
159
+ def log_operation_start(self, operation: str, context: Optional[LogContext] = None,
160
+ extra: Optional[Dict[str, Any]] = None) -> str:
161
+ """
162
+ Log operation start and return correlation ID.
163
+
164
+ Args:
165
+ operation: Operation name
166
+ context: Log context
167
+ extra: Extra data
168
+
169
+ Returns:
170
+ str: Correlation ID for the operation
171
+ """
172
+ correlation_id = str(uuid.uuid4())
173
+
174
+ if context:
175
+ context.correlation_id = correlation_id
176
+ context.operation = operation
177
+ else:
178
+ context = LogContext(correlation_id=correlation_id, operation=operation)
179
+
180
+ # Set correlation ID in context
181
+ correlation_id_context.set(correlation_id)
182
+
183
+ self.info(f"Operation started: {operation}", context, extra)
184
+ return correlation_id
185
+
186
+ def log_operation_end(self, operation: str, correlation_id: str,
187
+ success: bool = True, duration: Optional[float] = None,
188
+ context: Optional[LogContext] = None,
189
+ extra: Optional[Dict[str, Any]] = None) -> None:
190
+ """
191
+ Log operation end.
192
+
193
+ Args:
194
+ operation: Operation name
195
+ correlation_id: Correlation ID
196
+ success: Whether operation succeeded
197
+ duration: Operation duration in seconds
198
+ context: Log context
199
+ extra: Extra data
200
+ """
201
+ if context:
202
+ context.correlation_id = correlation_id
203
+ context.operation = operation
204
+ else:
205
+ context = LogContext(correlation_id=correlation_id, operation=operation)
206
+
207
+ # Add performance data
208
+ if extra is None:
209
+ extra = {}
210
+ extra['success'] = success
211
+ if duration is not None:
212
+ extra['duration_seconds'] = duration
213
+
214
+ status = "completed successfully" if success else "failed"
215
+ message = f"Operation {status}: {operation}"
216
+
217
+ if success:
218
+ self.info(message, context, extra)
219
+ else:
220
+ self.error(message, context, extra)
221
+
222
+ def log_performance_metric(self, metric_name: str, value: Union[int, float],
223
+ unit: str = None, context: Optional[LogContext] = None,
224
+ extra: Optional[Dict[str, Any]] = None) -> None:
225
+ """
226
+ Log performance metric.
227
+
228
+ Args:
229
+ metric_name: Name of the metric
230
+ value: Metric value
231
+ unit: Unit of measurement
232
+ context: Log context
233
+ extra: Extra data
234
+ """
235
+ if extra is None:
236
+ extra = {}
237
+
238
+ extra['metric'] = {
239
+ 'name': metric_name,
240
+ 'value': value,
241
+ 'unit': unit,
242
+ 'timestamp': time.time()
243
+ }
244
+
245
+ message = f"Performance metric: {metric_name}={value}"
246
+ if unit:
247
+ message += f" {unit}"
248
+
249
+ self.info(message, context, extra)
250
+
251
+
252
+ class JsonFormatter(logging.Formatter):
253
+ """JSON log formatter."""
254
+
255
+ def format(self, record: logging.LogRecord) -> str:
256
+ """Format log record as JSON."""
257
+ try:
258
+ # Get structured data from extra
259
+ log_data = getattr(record, 'extra', {})
260
+
261
+ # Ensure basic fields are present
262
+ if 'timestamp' not in log_data:
263
+ log_data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
264
+ if 'level' not in log_data:
265
+ log_data['level'] = record.levelname
266
+ if 'message' not in log_data:
267
+ log_data['message'] = record.getMessage()
268
+ if 'logger_name' not in log_data:
269
+ log_data['logger_name'] = record.name
270
+
271
+ # Add standard log record fields
272
+ log_data.update({
273
+ 'module': record.module,
274
+ 'function': record.funcName,
275
+ 'line': record.lineno,
276
+ 'thread': record.thread,
277
+ 'process': record.process
278
+ })
279
+
280
+ return json.dumps(log_data, default=str, ensure_ascii=False)
281
+
282
+ except Exception as e:
283
+ # Fallback to standard formatting
284
+ return f"JSON formatting error: {e} | Original: {record.getMessage()}"
285
+
286
+
287
+ class ContextFormatter(logging.Formatter):
288
+ """Context-aware log formatter."""
289
+
290
+ def __init__(self):
291
+ """Initialize formatter."""
292
+ super().__init__(
293
+ fmt='%(asctime)s - %(name)s - %(levelname)s - [%(correlation_id)s] - %(message)s',
294
+ datefmt='%Y-%m-%d %H:%M:%S'
295
+ )
296
+
297
+ def format(self, record: logging.LogRecord) -> str:
298
+ """Format log record with context."""
299
+ try:
300
+ # Get correlation ID from context or record
301
+ correlation_id = correlation_id_context.get()
302
+ if not correlation_id:
303
+ extra_data = getattr(record, 'extra', {})
304
+ correlation_id = extra_data.get('correlation_id', 'unknown')
305
+
306
+ # Add correlation ID to record
307
+ record.correlation_id = correlation_id
308
+
309
+ # Add context information if available
310
+ extra_data = getattr(record, 'extra', {})
311
+ if 'operation' in extra_data:
312
+ record.message = f"[{extra_data['operation']}] {record.getMessage()}"
313
+
314
+ return super().format(record)
315
+
316
+ except Exception as e:
317
+ # Fallback formatting
318
+ return f"Formatting error: {e} | Original: {record.getMessage()}"
319
+
320
+
321
+ def get_structured_logger(name: str, enable_json_logging: bool = True) -> StructuredLogger:
322
+ """
323
+ Get a structured logger instance.
324
+
325
+ Args:
326
+ name: Logger name
327
+ enable_json_logging: Whether to use JSON format
328
+
329
+ Returns:
330
+ StructuredLogger: Configured structured logger
331
+ """
332
+ return StructuredLogger(name, enable_json_logging)
333
+
334
+
335
+ def set_correlation_id(correlation_id: str) -> None:
336
+ """
337
+ Set correlation ID in context.
338
+
339
+ Args:
340
+ correlation_id: Correlation ID to set
341
+ """
342
+ correlation_id_context.set(correlation_id)
343
+
344
+
345
+ def get_correlation_id() -> Optional[str]:
346
+ """
347
+ Get current correlation ID from context.
348
+
349
+ Returns:
350
+ Optional[str]: Current correlation ID
351
+ """
352
+ return correlation_id_context.get()
353
+
354
+
355
+ def generate_correlation_id() -> str:
356
+ """
357
+ Generate a new correlation ID.
358
+
359
+ Returns:
360
+ str: New correlation ID
361
+ """
362
+ return str(uuid.uuid4())
src/application/services/audio_processing_service.py CHANGED
@@ -12,6 +12,9 @@ from contextlib import contextmanager
12
  from ..dtos.audio_upload_dto import AudioUploadDto
13
  from ..dtos.processing_request_dto import ProcessingRequestDto
14
  from ..dtos.processing_result_dto import ProcessingResultDto
 
 
 
15
  from ...domain.interfaces.speech_recognition import ISpeechRecognitionService
16
  from ...domain.interfaces.translation import ITranslationService
17
  from ...domain.interfaces.speech_synthesis import ISpeechSynthesisService
@@ -30,7 +33,7 @@ from ...domain.exceptions import (
30
  from ...infrastructure.config.app_config import AppConfig
31
  from ...infrastructure.config.dependency_container import DependencyContainer
32
 
33
- logger = logging.getLogger(__name__)
34
 
35
 
36
  class AudioProcessingApplicationService:
@@ -51,30 +54,34 @@ class AudioProcessingApplicationService:
51
  self._container = container
52
  self._config = config or container.resolve(AppConfig)
53
  self._temp_files: Dict[str, str] = {} # Track temporary files for cleanup
54
-
 
 
 
 
55
  # Setup logging
56
  self._setup_logging()
57
-
58
  logger.info("AudioProcessingApplicationService initialized")
59
 
60
  def _setup_logging(self) -> None:
61
  """Setup logging configuration."""
62
  try:
63
  log_config = self._config.get_logging_config()
64
-
65
  # Configure logger level
66
  logger.setLevel(getattr(logging, log_config['level'].upper(), logging.INFO))
67
-
68
  # Add file handler if enabled
69
  if log_config.get('enable_file_logging', False):
70
  file_handler = logging.FileHandler(log_config['log_file_path'])
71
  file_handler.setLevel(logger.level)
72
-
73
  formatter = logging.Formatter(log_config['format'])
74
  file_handler.setFormatter(formatter)
75
-
76
  logger.addHandler(file_handler)
77
-
78
  except Exception as e:
79
  logger.warning(f"Failed to setup logging configuration: {e}")
80
 
@@ -88,37 +95,53 @@ class AudioProcessingApplicationService:
88
  Returns:
89
  ProcessingResultDto: Result of the complete processing pipeline
90
  """
91
- correlation_id = str(uuid.uuid4())
 
 
 
 
 
 
 
 
 
 
 
92
  start_time = time.time()
93
-
94
- logger.info(f"Starting audio processing pipeline [correlation_id={correlation_id}]")
95
-
 
 
 
96
  try:
97
  # Validate request
98
  self._validate_request(request)
99
-
100
  # Create temporary working directory
101
  with self._create_temp_directory(correlation_id) as temp_dir:
102
  # Step 1: Convert uploaded audio to domain model
103
  audio_content = self._convert_upload_to_audio_content(request.audio, temp_dir)
104
-
105
- # Step 2: Speech-to-Text
106
- original_text = self._perform_speech_recognition(
107
- audio_content,
108
  request.asr_model,
109
  correlation_id
110
  )
111
-
112
- # Step 3: Translation (if needed)
113
- translated_text = self._perform_translation(
114
- original_text,
115
- request.source_language,
116
- request.target_language,
117
- correlation_id
118
- ) if request.requires_translation else original_text
119
-
120
- # Step 4: Text-to-Speech
121
- output_audio_path = self._perform_speech_synthesis(
 
 
122
  translated_text,
123
  request.voice,
124
  request.speed,
@@ -126,10 +149,10 @@ class AudioProcessingApplicationService:
126
  temp_dir,
127
  correlation_id
128
  )
129
-
130
  # Calculate processing time
131
  processing_time = time.time() - start_time
132
-
133
  # Create successful result
134
  result = ProcessingResultDto.success_result(
135
  original_text=original_text.text,
@@ -145,46 +168,114 @@ class AudioProcessingApplicationService:
145
  'translation_required': request.requires_translation
146
  }
147
  )
148
-
149
- logger.info(
150
- f"Audio processing pipeline completed successfully "
151
- f"[correlation_id={correlation_id}, processing_time={processing_time:.2f}s]"
 
 
 
 
 
 
 
 
 
152
  )
153
-
154
  return result
155
-
156
  except DomainException as e:
157
  processing_time = time.time() - start_time
158
- error_code = self._get_error_code_from_exception(e)
159
-
 
 
 
 
 
 
 
 
 
160
  logger.error(
161
- f"Domain error in audio processing pipeline: {e} "
162
- f"[correlation_id={correlation_id}, processing_time={processing_time:.2f}s]"
 
 
 
 
 
 
 
163
  )
164
-
 
 
 
 
 
 
 
 
 
165
  return ProcessingResultDto.error_result(
166
- error_message=str(e),
167
- error_code=error_code,
168
  processing_time=processing_time,
169
- metadata={'correlation_id': correlation_id}
 
 
 
 
 
 
170
  )
171
-
172
  except Exception as e:
173
  processing_time = time.time() - start_time
174
-
175
- logger.error(
176
- f"Unexpected error in audio processing pipeline: {e} "
177
- f"[correlation_id={correlation_id}, processing_time={processing_time:.2f}s]",
178
- exc_info=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  )
180
-
181
  return ProcessingResultDto.error_result(
182
- error_message=f"System error: {str(e)}",
183
- error_code='SYSTEM_ERROR',
184
  processing_time=processing_time,
185
- metadata={'correlation_id': correlation_id}
 
 
 
 
 
186
  )
187
-
188
  finally:
189
  # Cleanup temporary files
190
  self._cleanup_temp_files()
@@ -201,10 +292,10 @@ class AudioProcessingApplicationService:
201
  """
202
  if not isinstance(request, ProcessingRequestDto):
203
  raise ValueError("Request must be a ProcessingRequestDto instance")
204
-
205
  # Additional validation beyond DTO validation
206
  processing_config = self._config.get_processing_config()
207
-
208
  # Check file size limits
209
  max_size_bytes = processing_config['max_file_size_mb'] * 1024 * 1024
210
  if request.audio.size > max_size_bytes:
@@ -212,7 +303,7 @@ class AudioProcessingApplicationService:
212
  f"Audio file too large: {request.audio.size} bytes. "
213
  f"Maximum allowed: {max_size_bytes} bytes"
214
  )
215
-
216
  # Check supported audio formats
217
  supported_formats = processing_config['supported_audio_formats']
218
  file_ext = request.audio.file_extension.lstrip('.')
@@ -235,15 +326,15 @@ class AudioProcessingApplicationService:
235
  """
236
  processing_config = self._config.get_processing_config()
237
  base_temp_dir = processing_config['temp_dir']
238
-
239
  # Create unique temp directory
240
  temp_dir = os.path.join(base_temp_dir, f"processing_{correlation_id}")
241
-
242
  try:
243
  os.makedirs(temp_dir, exist_ok=True)
244
  logger.debug(f"Created temporary directory: {temp_dir}")
245
  yield temp_dir
246
-
247
  finally:
248
  # Cleanup temp directory if configured
249
  if processing_config.get('cleanup_temp_files', True):
@@ -255,8 +346,8 @@ class AudioProcessingApplicationService:
255
  logger.warning(f"Failed to cleanup temp directory {temp_dir}: {e}")
256
 
257
  def _convert_upload_to_audio_content(
258
- self,
259
- upload: AudioUploadDto,
260
  temp_dir: str
261
  ) -> AudioContent:
262
  """
@@ -275,16 +366,16 @@ class AudioProcessingApplicationService:
275
  try:
276
  # Save uploaded content to temporary file
277
  temp_file_path = os.path.join(temp_dir, f"input_{upload.filename}")
278
-
279
  with open(temp_file_path, 'wb') as f:
280
  f.write(upload.content)
281
-
282
  # Track temp file for cleanup
283
  self._temp_files[temp_file_path] = temp_file_path
284
-
285
  # Determine audio format from file extension
286
  audio_format = upload.file_extension.lstrip('.').lower()
287
-
288
  # Create AudioContent (simplified - in real implementation would extract metadata)
289
  audio_content = AudioContent(
290
  data=upload.content,
@@ -292,17 +383,17 @@ class AudioProcessingApplicationService:
292
  sample_rate=16000, # Default, would be extracted from actual file
293
  duration=0.0 # Would be calculated from actual file
294
  )
295
-
296
  logger.debug(f"Converted upload to AudioContent: {upload.filename}")
297
  return audio_content
298
-
299
  except Exception as e:
300
  logger.error(f"Failed to convert upload to AudioContent: {e}")
301
  raise AudioProcessingException(f"Failed to process uploaded audio: {str(e)}")
302
 
303
  def _perform_speech_recognition(
304
- self,
305
- audio: AudioContent,
306
  model: str,
307
  correlation_id: str
308
  ) -> TextContent:
@@ -322,20 +413,20 @@ class AudioProcessingApplicationService:
322
  """
323
  try:
324
  logger.debug(f"Starting STT with model: {model} [correlation_id={correlation_id}]")
325
-
326
  # Get STT provider from container
327
  stt_provider = self._container.get_stt_provider(model)
328
-
329
  # Perform transcription
330
  text_content = stt_provider.transcribe(audio, model)
331
-
332
  logger.info(
333
  f"STT completed successfully [correlation_id={correlation_id}, "
334
  f"text_length={len(text_content.text)}]"
335
  )
336
-
337
  return text_content
338
-
339
  except Exception as e:
340
  logger.error(f"STT failed: {e} [correlation_id={correlation_id}]")
341
  raise SpeechRecognitionException(f"Speech recognition failed: {str(e)}")
@@ -367,27 +458,27 @@ class AudioProcessingApplicationService:
367
  f"Starting translation: {source_language or 'auto'} -> {target_language} "
368
  f"[correlation_id={correlation_id}]"
369
  )
370
-
371
  # Get translation provider from container
372
  translation_provider = self._container.get_translation_provider()
373
-
374
  # Create translation request
375
  translation_request = TranslationRequest(
376
  text=text.text,
377
  source_language=source_language or 'auto',
378
  target_language=target_language
379
  )
380
-
381
  # Perform translation
382
  translated_text = translation_provider.translate(translation_request)
383
-
384
  logger.info(
385
  f"Translation completed successfully [correlation_id={correlation_id}, "
386
  f"source_length={len(text.text)}, target_length={len(translated_text.text)}]"
387
  )
388
-
389
  return translated_text
390
-
391
  except Exception as e:
392
  logger.error(f"Translation failed: {e} [correlation_id={correlation_id}]")
393
  raise TranslationFailedException(f"Translation failed: {str(e)}")
@@ -423,43 +514,43 @@ class AudioProcessingApplicationService:
423
  f"Starting TTS with voice: {voice}, speed: {speed} "
424
  f"[correlation_id={correlation_id}]"
425
  )
426
-
427
  # Get TTS provider from container
428
  tts_provider = self._container.get_tts_provider(voice)
429
-
430
  # Create voice settings
431
  voice_settings = VoiceSettings(
432
  voice_id=voice,
433
  speed=speed,
434
  language=language
435
  )
436
-
437
  # Create synthesis request
438
  synthesis_request = SpeechSynthesisRequest(
439
  text=text.text,
440
  voice_settings=voice_settings
441
  )
442
-
443
  # Perform synthesis
444
  audio_content = tts_provider.synthesize(synthesis_request)
445
-
446
  # Save output to file
447
  output_filename = f"output_{correlation_id}.{audio_content.format}"
448
  output_path = os.path.join(temp_dir, output_filename)
449
-
450
  with open(output_path, 'wb') as f:
451
  f.write(audio_content.data)
452
-
453
  # Track temp file for cleanup
454
  self._temp_files[output_path] = output_path
455
-
456
  logger.info(
457
  f"TTS completed successfully [correlation_id={correlation_id}, "
458
  f"output_file={output_path}]"
459
  )
460
-
461
  return output_path
462
-
463
  except Exception as e:
464
  logger.error(f"TTS failed: {e} [correlation_id={correlation_id}]")
465
  raise SpeechSynthesisException(f"Speech synthesis failed: {str(e)}")
@@ -538,10 +629,10 @@ class AudioProcessingApplicationService:
538
  def cleanup(self) -> None:
539
  """Cleanup application service resources."""
540
  logger.info("Cleaning up AudioProcessingApplicationService")
541
-
542
  # Cleanup temporary files
543
  self._cleanup_temp_files()
544
-
545
  logger.info("AudioProcessingApplicationService cleanup completed")
546
 
547
  def __enter__(self):
@@ -550,4 +641,183 @@ class AudioProcessingApplicationService:
550
 
551
  def __exit__(self, exc_type, exc_val, exc_tb):
552
  """Context manager exit with cleanup."""
553
- self.cleanup()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  from ..dtos.audio_upload_dto import AudioUploadDto
13
  from ..dtos.processing_request_dto import ProcessingRequestDto
14
  from ..dtos.processing_result_dto import ProcessingResultDto
15
+ from ..error_handling.error_mapper import ErrorMapper
16
+ from ..error_handling.structured_logger import StructuredLogger, LogContext, get_structured_logger
17
+ from ..error_handling.recovery_manager import RecoveryManager, RetryConfig, CircuitBreakerConfig
18
  from ...domain.interfaces.speech_recognition import ISpeechRecognitionService
19
  from ...domain.interfaces.translation import ITranslationService
20
  from ...domain.interfaces.speech_synthesis import ISpeechSynthesisService
 
33
  from ...infrastructure.config.app_config import AppConfig
34
  from ...infrastructure.config.dependency_container import DependencyContainer
35
 
36
+ logger = get_structured_logger(__name__)
37
 
38
 
39
  class AudioProcessingApplicationService:
 
54
  self._container = container
55
  self._config = config or container.resolve(AppConfig)
56
  self._temp_files: Dict[str, str] = {} # Track temporary files for cleanup
57
+
58
+ # Initialize error handling components
59
+ self._error_mapper = ErrorMapper()
60
+ self._recovery_manager = RecoveryManager()
61
+
62
  # Setup logging
63
  self._setup_logging()
64
+
65
  logger.info("AudioProcessingApplicationService initialized")
66
 
67
  def _setup_logging(self) -> None:
68
  """Setup logging configuration."""
69
  try:
70
  log_config = self._config.get_logging_config()
71
+
72
  # Configure logger level
73
  logger.setLevel(getattr(logging, log_config['level'].upper(), logging.INFO))
74
+
75
  # Add file handler if enabled
76
  if log_config.get('enable_file_logging', False):
77
  file_handler = logging.FileHandler(log_config['log_file_path'])
78
  file_handler.setLevel(logger.level)
79
+
80
  formatter = logging.Formatter(log_config['format'])
81
  file_handler.setFormatter(formatter)
82
+
83
  logger.addHandler(file_handler)
84
+
85
  except Exception as e:
86
  logger.warning(f"Failed to setup logging configuration: {e}")
87
 
 
95
  Returns:
96
  ProcessingResultDto: Result of the complete processing pipeline
97
  """
98
+ # Generate correlation ID and start operation logging
99
+ correlation_id = logger.log_operation_start(
100
+ "audio_processing_pipeline",
101
+ extra={
102
+ 'asr_model': request.asr_model,
103
+ 'target_language': request.target_language,
104
+ 'voice': request.voice,
105
+ 'file_name': request.audio.filename,
106
+ 'file_size': request.audio.size
107
+ }
108
+ )
109
+
110
  start_time = time.time()
111
+ context = LogContext(
112
+ correlation_id=correlation_id,
113
+ operation="audio_processing_pipeline",
114
+ component="AudioProcessingApplicationService"
115
+ )
116
+
117
  try:
118
  # Validate request
119
  self._validate_request(request)
120
+
121
  # Create temporary working directory
122
  with self._create_temp_directory(correlation_id) as temp_dir:
123
  # Step 1: Convert uploaded audio to domain model
124
  audio_content = self._convert_upload_to_audio_content(request.audio, temp_dir)
125
+
126
+ # Step 2: Speech-to-Text with retry and fallback
127
+ original_text = self._perform_speech_recognition_with_recovery(
128
+ audio_content,
129
  request.asr_model,
130
  correlation_id
131
  )
132
+
133
+ # Step 3: Translation (if needed) with retry
134
+ translated_text = original_text
135
+ if request.requires_translation:
136
+ translated_text = self._perform_translation_with_recovery(
137
+ original_text,
138
+ request.source_language,
139
+ request.target_language,
140
+ correlation_id
141
+ )
142
+
143
+ # Step 4: Text-to-Speech with fallback providers
144
+ output_audio_path = self._perform_speech_synthesis_with_recovery(
145
  translated_text,
146
  request.voice,
147
  request.speed,
 
149
  temp_dir,
150
  correlation_id
151
  )
152
+
153
  # Calculate processing time
154
  processing_time = time.time() - start_time
155
+
156
  # Create successful result
157
  result = ProcessingResultDto.success_result(
158
  original_text=original_text.text,
 
168
  'translation_required': request.requires_translation
169
  }
170
  )
171
+
172
+ # Log successful completion
173
+ logger.log_operation_end(
174
+ "audio_processing_pipeline",
175
+ correlation_id,
176
+ success=True,
177
+ duration=processing_time,
178
+ context=context,
179
+ extra={
180
+ 'original_text_length': len(original_text.text),
181
+ 'translated_text_length': len(translated_text.text) if translated_text != original_text else 0,
182
+ 'output_file': output_audio_path
183
+ }
184
  )
185
+
186
  return result
187
+
188
  except DomainException as e:
189
  processing_time = time.time() - start_time
190
+
191
+ # Map exception to user-friendly error
192
+ error_context = {
193
+ 'file_name': request.audio.filename,
194
+ 'file_size': request.audio.size,
195
+ 'operation': 'audio_processing_pipeline',
196
+ 'correlation_id': correlation_id
197
+ }
198
+
199
+ error_mapping = self._error_mapper.map_exception(e, error_context)
200
+
201
  logger.error(
202
+ f"Domain error in audio processing pipeline: {error_mapping.user_message}",
203
+ context=context,
204
+ exception=e,
205
+ extra={
206
+ 'error_code': error_mapping.error_code,
207
+ 'error_category': error_mapping.category.value,
208
+ 'error_severity': error_mapping.severity.value,
209
+ 'recovery_suggestions': error_mapping.recovery_suggestions
210
+ }
211
  )
212
+
213
+ # Log operation failure
214
+ logger.log_operation_end(
215
+ "audio_processing_pipeline",
216
+ correlation_id,
217
+ success=False,
218
+ duration=processing_time,
219
+ context=context
220
+ )
221
+
222
  return ProcessingResultDto.error_result(
223
+ error_message=error_mapping.user_message,
224
+ error_code=error_mapping.error_code,
225
  processing_time=processing_time,
226
+ metadata={
227
+ 'correlation_id': correlation_id,
228
+ 'error_category': error_mapping.category.value,
229
+ 'error_severity': error_mapping.severity.value,
230
+ 'recovery_suggestions': error_mapping.recovery_suggestions,
231
+ 'technical_details': error_mapping.technical_details
232
+ }
233
  )
234
+
235
  except Exception as e:
236
  processing_time = time.time() - start_time
237
+
238
+ # Map unexpected exception
239
+ error_context = {
240
+ 'file_name': request.audio.filename,
241
+ 'operation': 'audio_processing_pipeline',
242
+ 'correlation_id': correlation_id
243
+ }
244
+
245
+ error_mapping = self._error_mapper.map_exception(e, error_context)
246
+
247
+ logger.critical(
248
+ f"Unexpected error in audio processing pipeline: {error_mapping.user_message}",
249
+ context=context,
250
+ exception=e,
251
+ extra={
252
+ 'error_code': error_mapping.error_code,
253
+ 'error_category': error_mapping.category.value,
254
+ 'error_severity': error_mapping.severity.value
255
+ }
256
+ )
257
+
258
+ # Log operation failure
259
+ logger.log_operation_end(
260
+ "audio_processing_pipeline",
261
+ correlation_id,
262
+ success=False,
263
+ duration=processing_time,
264
+ context=context
265
  )
266
+
267
  return ProcessingResultDto.error_result(
268
+ error_message=error_mapping.user_message,
269
+ error_code=error_mapping.error_code,
270
  processing_time=processing_time,
271
+ metadata={
272
+ 'correlation_id': correlation_id,
273
+ 'error_category': error_mapping.category.value,
274
+ 'error_severity': error_mapping.severity.value,
275
+ 'technical_details': error_mapping.technical_details
276
+ }
277
  )
278
+
279
  finally:
280
  # Cleanup temporary files
281
  self._cleanup_temp_files()
 
292
  """
293
  if not isinstance(request, ProcessingRequestDto):
294
  raise ValueError("Request must be a ProcessingRequestDto instance")
295
+
296
  # Additional validation beyond DTO validation
297
  processing_config = self._config.get_processing_config()
298
+
299
  # Check file size limits
300
  max_size_bytes = processing_config['max_file_size_mb'] * 1024 * 1024
301
  if request.audio.size > max_size_bytes:
 
303
  f"Audio file too large: {request.audio.size} bytes. "
304
  f"Maximum allowed: {max_size_bytes} bytes"
305
  )
306
+
307
  # Check supported audio formats
308
  supported_formats = processing_config['supported_audio_formats']
309
  file_ext = request.audio.file_extension.lstrip('.')
 
326
  """
327
  processing_config = self._config.get_processing_config()
328
  base_temp_dir = processing_config['temp_dir']
329
+
330
  # Create unique temp directory
331
  temp_dir = os.path.join(base_temp_dir, f"processing_{correlation_id}")
332
+
333
  try:
334
  os.makedirs(temp_dir, exist_ok=True)
335
  logger.debug(f"Created temporary directory: {temp_dir}")
336
  yield temp_dir
337
+
338
  finally:
339
  # Cleanup temp directory if configured
340
  if processing_config.get('cleanup_temp_files', True):
 
346
  logger.warning(f"Failed to cleanup temp directory {temp_dir}: {e}")
347
 
348
  def _convert_upload_to_audio_content(
349
+ self,
350
+ upload: AudioUploadDto,
351
  temp_dir: str
352
  ) -> AudioContent:
353
  """
 
366
  try:
367
  # Save uploaded content to temporary file
368
  temp_file_path = os.path.join(temp_dir, f"input_{upload.filename}")
369
+
370
  with open(temp_file_path, 'wb') as f:
371
  f.write(upload.content)
372
+
373
  # Track temp file for cleanup
374
  self._temp_files[temp_file_path] = temp_file_path
375
+
376
  # Determine audio format from file extension
377
  audio_format = upload.file_extension.lstrip('.').lower()
378
+
379
  # Create AudioContent (simplified - in real implementation would extract metadata)
380
  audio_content = AudioContent(
381
  data=upload.content,
 
383
  sample_rate=16000, # Default, would be extracted from actual file
384
  duration=0.0 # Would be calculated from actual file
385
  )
386
+
387
  logger.debug(f"Converted upload to AudioContent: {upload.filename}")
388
  return audio_content
389
+
390
  except Exception as e:
391
  logger.error(f"Failed to convert upload to AudioContent: {e}")
392
  raise AudioProcessingException(f"Failed to process uploaded audio: {str(e)}")
393
 
394
  def _perform_speech_recognition(
395
+ self,
396
+ audio: AudioContent,
397
  model: str,
398
  correlation_id: str
399
  ) -> TextContent:
 
413
  """
414
  try:
415
  logger.debug(f"Starting STT with model: {model} [correlation_id={correlation_id}]")
416
+
417
  # Get STT provider from container
418
  stt_provider = self._container.get_stt_provider(model)
419
+
420
  # Perform transcription
421
  text_content = stt_provider.transcribe(audio, model)
422
+
423
  logger.info(
424
  f"STT completed successfully [correlation_id={correlation_id}, "
425
  f"text_length={len(text_content.text)}]"
426
  )
427
+
428
  return text_content
429
+
430
  except Exception as e:
431
  logger.error(f"STT failed: {e} [correlation_id={correlation_id}]")
432
  raise SpeechRecognitionException(f"Speech recognition failed: {str(e)}")
 
458
  f"Starting translation: {source_language or 'auto'} -> {target_language} "
459
  f"[correlation_id={correlation_id}]"
460
  )
461
+
462
  # Get translation provider from container
463
  translation_provider = self._container.get_translation_provider()
464
+
465
  # Create translation request
466
  translation_request = TranslationRequest(
467
  text=text.text,
468
  source_language=source_language or 'auto',
469
  target_language=target_language
470
  )
471
+
472
  # Perform translation
473
  translated_text = translation_provider.translate(translation_request)
474
+
475
  logger.info(
476
  f"Translation completed successfully [correlation_id={correlation_id}, "
477
  f"source_length={len(text.text)}, target_length={len(translated_text.text)}]"
478
  )
479
+
480
  return translated_text
481
+
482
  except Exception as e:
483
  logger.error(f"Translation failed: {e} [correlation_id={correlation_id}]")
484
  raise TranslationFailedException(f"Translation failed: {str(e)}")
 
514
  f"Starting TTS with voice: {voice}, speed: {speed} "
515
  f"[correlation_id={correlation_id}]"
516
  )
517
+
518
  # Get TTS provider from container
519
  tts_provider = self._container.get_tts_provider(voice)
520
+
521
  # Create voice settings
522
  voice_settings = VoiceSettings(
523
  voice_id=voice,
524
  speed=speed,
525
  language=language
526
  )
527
+
528
  # Create synthesis request
529
  synthesis_request = SpeechSynthesisRequest(
530
  text=text.text,
531
  voice_settings=voice_settings
532
  )
533
+
534
  # Perform synthesis
535
  audio_content = tts_provider.synthesize(synthesis_request)
536
+
537
  # Save output to file
538
  output_filename = f"output_{correlation_id}.{audio_content.format}"
539
  output_path = os.path.join(temp_dir, output_filename)
540
+
541
  with open(output_path, 'wb') as f:
542
  f.write(audio_content.data)
543
+
544
  # Track temp file for cleanup
545
  self._temp_files[output_path] = output_path
546
+
547
  logger.info(
548
  f"TTS completed successfully [correlation_id={correlation_id}, "
549
  f"output_file={output_path}]"
550
  )
551
+
552
  return output_path
553
+
554
  except Exception as e:
555
  logger.error(f"TTS failed: {e} [correlation_id={correlation_id}]")
556
  raise SpeechSynthesisException(f"Speech synthesis failed: {str(e)}")
 
629
  def cleanup(self) -> None:
630
  """Cleanup application service resources."""
631
  logger.info("Cleaning up AudioProcessingApplicationService")
632
+
633
  # Cleanup temporary files
634
  self._cleanup_temp_files()
635
+
636
  logger.info("AudioProcessingApplicationService cleanup completed")
637
 
638
  def __enter__(self):
 
641
 
642
  def __exit__(self, exc_type, exc_val, exc_tb):
643
  """Context manager exit with cleanup."""
644
+ self.cleanup()
645
+
646
+ def _perform_speech_recognition_with_recovery(
647
+ self,
648
+ audio: AudioContent,
649
+ model: str,
650
+ correlation_id: str
651
+ ) -> TextContent:
652
+ """
653
+ Perform speech-to-text recognition with retry and fallback.
654
+
655
+ Args:
656
+ audio: Audio content to transcribe
657
+ model: STT model to use
658
+ correlation_id: Correlation ID for tracking
659
+
660
+ Returns:
661
+ TextContent: Transcribed text
662
+
663
+ Raises:
664
+ SpeechRecognitionException: If all attempts fail
665
+ """
666
+ context = LogContext(
667
+ correlation_id=correlation_id,
668
+ operation="speech_recognition",
669
+ component="AudioProcessingApplicationService"
670
+ )
671
+
672
+ # Configure retry for STT
673
+ retry_config = RetryConfig(
674
+ max_attempts=2,
675
+ base_delay=1.0,
676
+ retryable_exceptions=[SpeechRecognitionException, ConnectionError, TimeoutError]
677
+ )
678
+
679
+ def stt_operation():
680
+ return self._perform_speech_recognition(audio, model, correlation_id)
681
+
682
+ try:
683
+ # Try with retry
684
+ return self._recovery_manager.retry_with_backoff(
685
+ stt_operation,
686
+ retry_config,
687
+ correlation_id
688
+ )
689
+
690
+ except Exception as e:
691
+ # Try fallback models if primary fails
692
+ stt_config = self._config.get_stt_config()
693
+ fallback_models = [m for m in stt_config['preferred_providers'] if m != model]
694
+
695
+ if fallback_models:
696
+ logger.warning(
697
+ f"STT model {model} failed, trying fallbacks: {fallback_models}",
698
+ context=context,
699
+ exception=e
700
+ )
701
+
702
+ fallback_funcs = [
703
+ lambda m=fallback_model: self._perform_speech_recognition(audio, m, correlation_id)
704
+ for fallback_model in fallback_models
705
+ ]
706
+
707
+ return self._recovery_manager.execute_with_fallback(
708
+ stt_operation,
709
+ fallback_funcs,
710
+ correlation_id
711
+ )
712
+ else:
713
+ raise
714
+
715
+ def _perform_translation_with_recovery(
716
+ self,
717
+ text: TextContent,
718
+ source_language: Optional[str],
719
+ target_language: str,
720
+ correlation_id: str
721
+ ) -> TextContent:
722
+ """
723
+ Perform text translation with retry.
724
+
725
+ Args:
726
+ text: Text to translate
727
+ source_language: Source language (optional, auto-detect if None)
728
+ target_language: Target language
729
+ correlation_id: Correlation ID for tracking
730
+
731
+ Returns:
732
+ TextContent: Translated text
733
+
734
+ Raises:
735
+ TranslationFailedException: If all attempts fail
736
+ """
737
+ # Configure retry for translation
738
+ retry_config = RetryConfig(
739
+ max_attempts=3,
740
+ base_delay=1.0,
741
+ exponential_backoff=True,
742
+ retryable_exceptions=[TranslationFailedException, ConnectionError, TimeoutError]
743
+ )
744
+
745
+ def translation_operation():
746
+ return self._perform_translation(text, source_language, target_language, correlation_id)
747
+
748
+ return self._recovery_manager.retry_with_backoff(
749
+ translation_operation,
750
+ retry_config,
751
+ correlation_id
752
+ )
753
+
754
+ def _perform_speech_synthesis_with_recovery(
755
+ self,
756
+ text: TextContent,
757
+ voice: str,
758
+ speed: float,
759
+ language: str,
760
+ temp_dir: str,
761
+ correlation_id: str
762
+ ) -> str:
763
+ """
764
+ Perform text-to-speech synthesis with fallback providers.
765
+
766
+ Args:
767
+ text: Text to synthesize
768
+ voice: Voice to use
769
+ speed: Speech speed
770
+ language: Target language
771
+ temp_dir: Temporary directory for output
772
+ correlation_id: Correlation ID for tracking
773
+
774
+ Returns:
775
+ str: Path to generated audio file
776
+
777
+ Raises:
778
+ SpeechSynthesisException: If all providers fail
779
+ """
780
+ context = LogContext(
781
+ correlation_id=correlation_id,
782
+ operation="speech_synthesis",
783
+ component="AudioProcessingApplicationService"
784
+ )
785
+
786
+ def tts_operation():
787
+ return self._perform_speech_synthesis(text, voice, speed, language, temp_dir, correlation_id)
788
+
789
+ try:
790
+ # Try with circuit breaker protection
791
+ return self._recovery_manager.execute_with_circuit_breaker(
792
+ tts_operation,
793
+ f"tts_{voice}",
794
+ CircuitBreakerConfig(failure_threshold=3, recovery_timeout=30.0),
795
+ correlation_id
796
+ )
797
+
798
+ except Exception as e:
799
+ # Try fallback TTS providers
800
+ tts_config = self._config.get_tts_config()
801
+ fallback_voices = [v for v in tts_config['preferred_providers'] if v != voice]
802
+
803
+ if fallback_voices:
804
+ logger.warning(
805
+ f"TTS voice {voice} failed, trying fallbacks: {fallback_voices}",
806
+ context=context,
807
+ exception=e
808
+ )
809
+
810
+ fallback_funcs = [
811
+ lambda v=fallback_voice: self._perform_speech_synthesis(
812
+ text, v, speed, language, temp_dir, correlation_id
813
+ )
814
+ for fallback_voice in fallback_voices
815
+ ]
816
+
817
+ return self._recovery_manager.execute_with_fallback(
818
+ tts_operation,
819
+ fallback_funcs,
820
+ correlation_id
821
+ )
822
+ else:
823
+ raise
src/application/services/configuration_service.py CHANGED
@@ -7,11 +7,13 @@ from typing import Dict, List, Any, Optional, Union
7
  from pathlib import Path
8
  from dataclasses import asdict
9
 
 
 
10
  from ...infrastructure.config.app_config import AppConfig, TTSConfig, STTConfig, TranslationConfig, ProcessingConfig, LoggingConfig
11
  from ...infrastructure.config.dependency_container import DependencyContainer
12
  from ...domain.exceptions import DomainException
13
 
14
- logger = logging.getLogger(__name__)
15
 
16
 
17
  class ConfigurationException(DomainException):
@@ -36,7 +38,10 @@ class ConfigurationApplicationService:
36
  """
37
  self._container = container
38
  self._config = config or container.resolve(AppConfig)
39
-
 
 
 
40
  logger.info("ConfigurationApplicationService initialized")
41
 
42
  def get_current_configuration(self) -> Dict[str, Any]:
@@ -139,10 +144,10 @@ class ConfigurationApplicationService:
139
  try:
140
  # Validate updates
141
  self._validate_tts_updates(updates)
142
-
143
  # Apply updates to current config
144
  current_config = self._config.get_tts_config()
145
-
146
  for key, value in updates.items():
147
  if key in current_config:
148
  # Update the actual config object
@@ -151,13 +156,13 @@ class ConfigurationApplicationService:
151
  logger.debug(f"Updated TTS config: {key} = {value}")
152
  else:
153
  logger.warning(f"Unknown TTS configuration key: {key}")
154
-
155
  # Return updated configuration
156
  updated_config = self._config.get_tts_config()
157
  logger.info(f"TTS configuration updated: {list(updates.keys())}")
158
-
159
  return updated_config
160
-
161
  except Exception as e:
162
  logger.error(f"Failed to update TTS configuration: {e}")
163
  raise ConfigurationException(f"Failed to update TTS configuration: {str(e)}")
@@ -178,10 +183,10 @@ class ConfigurationApplicationService:
178
  try:
179
  # Validate updates
180
  self._validate_stt_updates(updates)
181
-
182
  # Apply updates to current config
183
  current_config = self._config.get_stt_config()
184
-
185
  for key, value in updates.items():
186
  if key in current_config:
187
  # Update the actual config object
@@ -190,13 +195,13 @@ class ConfigurationApplicationService:
190
  logger.debug(f"Updated STT config: {key} = {value}")
191
  else:
192
  logger.warning(f"Unknown STT configuration key: {key}")
193
-
194
  # Return updated configuration
195
  updated_config = self._config.get_stt_config()
196
  logger.info(f"STT configuration updated: {list(updates.keys())}")
197
-
198
  return updated_config
199
-
200
  except Exception as e:
201
  logger.error(f"Failed to update STT configuration: {e}")
202
  raise ConfigurationException(f"Failed to update STT configuration: {str(e)}")
@@ -217,10 +222,10 @@ class ConfigurationApplicationService:
217
  try:
218
  # Validate updates
219
  self._validate_translation_updates(updates)
220
-
221
  # Apply updates to current config
222
  current_config = self._config.get_translation_config()
223
-
224
  for key, value in updates.items():
225
  if key in current_config:
226
  # Update the actual config object
@@ -229,13 +234,13 @@ class ConfigurationApplicationService:
229
  logger.debug(f"Updated translation config: {key} = {value}")
230
  else:
231
  logger.warning(f"Unknown translation configuration key: {key}")
232
-
233
  # Return updated configuration
234
  updated_config = self._config.get_translation_config()
235
  logger.info(f"Translation configuration updated: {list(updates.keys())}")
236
-
237
  return updated_config
238
-
239
  except Exception as e:
240
  logger.error(f"Failed to update translation configuration: {e}")
241
  raise ConfigurationException(f"Failed to update translation configuration: {str(e)}")
@@ -256,10 +261,10 @@ class ConfigurationApplicationService:
256
  try:
257
  # Validate updates
258
  self._validate_processing_updates(updates)
259
-
260
  # Apply updates to current config
261
  current_config = self._config.get_processing_config()
262
-
263
  for key, value in updates.items():
264
  if key in current_config:
265
  # Update the actual config object
@@ -268,13 +273,13 @@ class ConfigurationApplicationService:
268
  logger.debug(f"Updated processing config: {key} = {value}")
269
  else:
270
  logger.warning(f"Unknown processing configuration key: {key}")
271
-
272
  # Return updated configuration
273
  updated_config = self._config.get_processing_config()
274
  logger.info(f"Processing configuration updated: {list(updates.keys())}")
275
-
276
  return updated_config
277
-
278
  except Exception as e:
279
  logger.error(f"Failed to update processing configuration: {e}")
280
  raise ConfigurationException(f"Failed to update processing configuration: {str(e)}")
@@ -291,7 +296,7 @@ class ConfigurationApplicationService:
291
  """
292
  valid_providers = ['kokoro', 'dia', 'cosyvoice2', 'dummy']
293
  valid_languages = ['en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'ko', 'zh']
294
-
295
  for key, value in updates.items():
296
  if key == 'preferred_providers':
297
  if not isinstance(value, list):
@@ -299,19 +304,19 @@ class ConfigurationApplicationService:
299
  for provider in value:
300
  if provider not in valid_providers:
301
  raise ConfigurationException(f"Invalid TTS provider: {provider}")
302
-
303
  elif key == 'default_speed':
304
  if not isinstance(value, (int, float)) or not (0.1 <= value <= 3.0):
305
  raise ConfigurationException("default_speed must be between 0.1 and 3.0")
306
-
307
  elif key == 'default_language':
308
  if value not in valid_languages:
309
  raise ConfigurationException(f"Invalid language: {value}")
310
-
311
  elif key == 'enable_streaming':
312
  if not isinstance(value, bool):
313
  raise ConfigurationException("enable_streaming must be a boolean")
314
-
315
  elif key == 'max_text_length':
316
  if not isinstance(value, int) or value <= 0:
317
  raise ConfigurationException("max_text_length must be a positive integer")
@@ -327,7 +332,7 @@ class ConfigurationApplicationService:
327
  ConfigurationException: If validation fails
328
  """
329
  valid_providers = ['whisper', 'parakeet']
330
-
331
  for key, value in updates.items():
332
  if key == 'preferred_providers':
333
  if not isinstance(value, list):
@@ -335,19 +340,19 @@ class ConfigurationApplicationService:
335
  for provider in value:
336
  if provider not in valid_providers:
337
  raise ConfigurationException(f"Invalid STT provider: {provider}")
338
-
339
  elif key == 'default_model':
340
  if value not in valid_providers:
341
  raise ConfigurationException(f"Invalid STT model: {value}")
342
-
343
  elif key == 'chunk_length_s':
344
  if not isinstance(value, int) or value <= 0:
345
  raise ConfigurationException("chunk_length_s must be a positive integer")
346
-
347
  elif key == 'batch_size':
348
  if not isinstance(value, int) or value <= 0:
349
  raise ConfigurationException("batch_size must be a positive integer")
350
-
351
  elif key == 'enable_vad':
352
  if not isinstance(value, bool):
353
  raise ConfigurationException("enable_vad must be a boolean")
@@ -366,19 +371,19 @@ class ConfigurationApplicationService:
366
  if key == 'default_provider':
367
  if not isinstance(value, str) or not value:
368
  raise ConfigurationException("default_provider must be a non-empty string")
369
-
370
  elif key == 'model_name':
371
  if not isinstance(value, str) or not value:
372
  raise ConfigurationException("model_name must be a non-empty string")
373
-
374
  elif key == 'max_chunk_length':
375
  if not isinstance(value, int) or value <= 0:
376
  raise ConfigurationException("max_chunk_length must be a positive integer")
377
-
378
  elif key == 'batch_size':
379
  if not isinstance(value, int) or value <= 0:
380
  raise ConfigurationException("batch_size must be a positive integer")
381
-
382
  elif key == 'cache_translations':
383
  if not isinstance(value, bool):
384
  raise ConfigurationException("cache_translations must be a boolean")
@@ -402,15 +407,15 @@ class ConfigurationApplicationService:
402
  Path(value).mkdir(parents=True, exist_ok=True)
403
  except Exception as e:
404
  raise ConfigurationException(f"Invalid temp_dir path: {e}")
405
-
406
  elif key == 'cleanup_temp_files':
407
  if not isinstance(value, bool):
408
  raise ConfigurationException("cleanup_temp_files must be a boolean")
409
-
410
  elif key == 'max_file_size_mb':
411
  if not isinstance(value, int) or value <= 0:
412
  raise ConfigurationException("max_file_size_mb must be a positive integer")
413
-
414
  elif key == 'supported_audio_formats':
415
  if not isinstance(value, list):
416
  raise ConfigurationException("supported_audio_formats must be a list")
@@ -418,7 +423,7 @@ class ConfigurationApplicationService:
418
  for fmt in value:
419
  if fmt not in valid_formats:
420
  raise ConfigurationException(f"Invalid audio format: {fmt}")
421
-
422
  elif key == 'processing_timeout_seconds':
423
  if not isinstance(value, int) or value <= 0:
424
  raise ConfigurationException("processing_timeout_seconds must be a positive integer")
@@ -456,20 +461,20 @@ class ConfigurationApplicationService:
456
  try:
457
  if not os.path.exists(file_path):
458
  raise ConfigurationException(f"Configuration file not found: {file_path}")
459
-
460
  # Create new config instance with the file
461
  new_config = AppConfig(config_file=file_path)
462
-
463
  # Update current config
464
  self._config = new_config
465
-
466
  # Update container with new config
467
  self._container.register_singleton(AppConfig, new_config)
468
-
469
  logger.info(f"Configuration loaded from {file_path}")
470
-
471
  return self.get_current_configuration()
472
-
473
  except Exception as e:
474
  logger.error(f"Failed to load configuration from {file_path}: {e}")
475
  raise ConfigurationException(f"Failed to load configuration: {str(e)}")
@@ -487,9 +492,9 @@ class ConfigurationApplicationService:
487
  try:
488
  self._config.reload_configuration()
489
  logger.info("Configuration reloaded successfully")
490
-
491
  return self.get_current_configuration()
492
-
493
  except Exception as e:
494
  logger.error(f"Failed to reload configuration: {e}")
495
  raise ConfigurationException(f"Failed to reload configuration: {str(e)}")
@@ -507,7 +512,7 @@ class ConfigurationApplicationService:
507
  'stt': {},
508
  'translation': {}
509
  }
510
-
511
  # Check TTS providers
512
  tts_factory = self._container.resolve(type(self._container._get_tts_factory()))
513
  for provider in ['kokoro', 'dia', 'cosyvoice2', 'dummy']:
@@ -516,7 +521,7 @@ class ConfigurationApplicationService:
516
  availability['tts'][provider] = True
517
  except Exception:
518
  availability['tts'][provider] = False
519
-
520
  # Check STT providers
521
  stt_factory = self._container.resolve(type(self._container._get_stt_factory()))
522
  for provider in ['whisper', 'parakeet']:
@@ -525,7 +530,7 @@ class ConfigurationApplicationService:
525
  availability['stt'][provider] = True
526
  except Exception:
527
  availability['stt'][provider] = False
528
-
529
  # Check translation providers
530
  translation_factory = self._container.resolve(type(self._container._get_translation_factory()))
531
  try:
@@ -533,9 +538,9 @@ class ConfigurationApplicationService:
533
  availability['translation']['nllb'] = True
534
  except Exception:
535
  availability['translation']['nllb'] = False
536
-
537
  return availability
538
-
539
  except Exception as e:
540
  logger.error(f"Failed to check provider availability: {e}")
541
  raise ConfigurationException(f"Failed to check provider availability: {str(e)}")
@@ -579,41 +584,41 @@ class ConfigurationApplicationService:
579
  'processing': [],
580
  'logging': []
581
  }
582
-
583
  try:
584
  # Validate TTS configuration
585
  tts_config = self._config.get_tts_config()
586
  if not (0.1 <= tts_config['default_speed'] <= 3.0):
587
  issues['tts'].append(f"Invalid default_speed: {tts_config['default_speed']}")
588
-
589
  if tts_config['max_text_length'] <= 0:
590
  issues['tts'].append(f"Invalid max_text_length: {tts_config['max_text_length']}")
591
-
592
  # Validate STT configuration
593
  stt_config = self._config.get_stt_config()
594
  if stt_config['chunk_length_s'] <= 0:
595
  issues['stt'].append(f"Invalid chunk_length_s: {stt_config['chunk_length_s']}")
596
-
597
  if stt_config['batch_size'] <= 0:
598
  issues['stt'].append(f"Invalid batch_size: {stt_config['batch_size']}")
599
-
600
  # Validate processing configuration
601
  processing_config = self._config.get_processing_config()
602
  if not os.path.exists(processing_config['temp_dir']):
603
  issues['processing'].append(f"Temp directory does not exist: {processing_config['temp_dir']}")
604
-
605
  if processing_config['max_file_size_mb'] <= 0:
606
  issues['processing'].append(f"Invalid max_file_size_mb: {processing_config['max_file_size_mb']}")
607
-
608
  # Validate logging configuration
609
  logging_config = self._config.get_logging_config()
610
  valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
611
  if logging_config['level'].upper() not in valid_levels:
612
  issues['logging'].append(f"Invalid log level: {logging_config['level']}")
613
-
614
  except Exception as e:
615
  issues['general'] = [f"Configuration validation error: {str(e)}"]
616
-
617
  return issues
618
 
619
  def reset_to_defaults(self) -> Dict[str, Any]:
@@ -629,17 +634,17 @@ class ConfigurationApplicationService:
629
  try:
630
  # Create new config with defaults
631
  default_config = AppConfig()
632
-
633
  # Update current config
634
  self._config = default_config
635
-
636
  # Update container with new config
637
  self._container.register_singleton(AppConfig, default_config)
638
-
639
  logger.info("Configuration reset to defaults")
640
-
641
  return self.get_current_configuration()
642
-
643
  except Exception as e:
644
  logger.error(f"Failed to reset configuration: {e}")
645
  raise ConfigurationException(f"Failed to reset configuration: {str(e)}")
 
7
  from pathlib import Path
8
  from dataclasses import asdict
9
 
10
+ from ..error_handling.error_mapper import ErrorMapper
11
+ from ..error_handling.structured_logger import StructuredLogger, LogContext, get_structured_logger
12
  from ...infrastructure.config.app_config import AppConfig, TTSConfig, STTConfig, TranslationConfig, ProcessingConfig, LoggingConfig
13
  from ...infrastructure.config.dependency_container import DependencyContainer
14
  from ...domain.exceptions import DomainException
15
 
16
+ logger = get_structured_logger(__name__)
17
 
18
 
19
  class ConfigurationException(DomainException):
 
38
  """
39
  self._container = container
40
  self._config = config or container.resolve(AppConfig)
41
+
42
+ # Initialize error handling
43
+ self._error_mapper = ErrorMapper()
44
+
45
  logger.info("ConfigurationApplicationService initialized")
46
 
47
  def get_current_configuration(self) -> Dict[str, Any]:
 
144
  try:
145
  # Validate updates
146
  self._validate_tts_updates(updates)
147
+
148
  # Apply updates to current config
149
  current_config = self._config.get_tts_config()
150
+
151
  for key, value in updates.items():
152
  if key in current_config:
153
  # Update the actual config object
 
156
  logger.debug(f"Updated TTS config: {key} = {value}")
157
  else:
158
  logger.warning(f"Unknown TTS configuration key: {key}")
159
+
160
  # Return updated configuration
161
  updated_config = self._config.get_tts_config()
162
  logger.info(f"TTS configuration updated: {list(updates.keys())}")
163
+
164
  return updated_config
165
+
166
  except Exception as e:
167
  logger.error(f"Failed to update TTS configuration: {e}")
168
  raise ConfigurationException(f"Failed to update TTS configuration: {str(e)}")
 
183
  try:
184
  # Validate updates
185
  self._validate_stt_updates(updates)
186
+
187
  # Apply updates to current config
188
  current_config = self._config.get_stt_config()
189
+
190
  for key, value in updates.items():
191
  if key in current_config:
192
  # Update the actual config object
 
195
  logger.debug(f"Updated STT config: {key} = {value}")
196
  else:
197
  logger.warning(f"Unknown STT configuration key: {key}")
198
+
199
  # Return updated configuration
200
  updated_config = self._config.get_stt_config()
201
  logger.info(f"STT configuration updated: {list(updates.keys())}")
202
+
203
  return updated_config
204
+
205
  except Exception as e:
206
  logger.error(f"Failed to update STT configuration: {e}")
207
  raise ConfigurationException(f"Failed to update STT configuration: {str(e)}")
 
222
  try:
223
  # Validate updates
224
  self._validate_translation_updates(updates)
225
+
226
  # Apply updates to current config
227
  current_config = self._config.get_translation_config()
228
+
229
  for key, value in updates.items():
230
  if key in current_config:
231
  # Update the actual config object
 
234
  logger.debug(f"Updated translation config: {key} = {value}")
235
  else:
236
  logger.warning(f"Unknown translation configuration key: {key}")
237
+
238
  # Return updated configuration
239
  updated_config = self._config.get_translation_config()
240
  logger.info(f"Translation configuration updated: {list(updates.keys())}")
241
+
242
  return updated_config
243
+
244
  except Exception as e:
245
  logger.error(f"Failed to update translation configuration: {e}")
246
  raise ConfigurationException(f"Failed to update translation configuration: {str(e)}")
 
261
  try:
262
  # Validate updates
263
  self._validate_processing_updates(updates)
264
+
265
  # Apply updates to current config
266
  current_config = self._config.get_processing_config()
267
+
268
  for key, value in updates.items():
269
  if key in current_config:
270
  # Update the actual config object
 
273
  logger.debug(f"Updated processing config: {key} = {value}")
274
  else:
275
  logger.warning(f"Unknown processing configuration key: {key}")
276
+
277
  # Return updated configuration
278
  updated_config = self._config.get_processing_config()
279
  logger.info(f"Processing configuration updated: {list(updates.keys())}")
280
+
281
  return updated_config
282
+
283
  except Exception as e:
284
  logger.error(f"Failed to update processing configuration: {e}")
285
  raise ConfigurationException(f"Failed to update processing configuration: {str(e)}")
 
296
  """
297
  valid_providers = ['kokoro', 'dia', 'cosyvoice2', 'dummy']
298
  valid_languages = ['en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'ko', 'zh']
299
+
300
  for key, value in updates.items():
301
  if key == 'preferred_providers':
302
  if not isinstance(value, list):
 
304
  for provider in value:
305
  if provider not in valid_providers:
306
  raise ConfigurationException(f"Invalid TTS provider: {provider}")
307
+
308
  elif key == 'default_speed':
309
  if not isinstance(value, (int, float)) or not (0.1 <= value <= 3.0):
310
  raise ConfigurationException("default_speed must be between 0.1 and 3.0")
311
+
312
  elif key == 'default_language':
313
  if value not in valid_languages:
314
  raise ConfigurationException(f"Invalid language: {value}")
315
+
316
  elif key == 'enable_streaming':
317
  if not isinstance(value, bool):
318
  raise ConfigurationException("enable_streaming must be a boolean")
319
+
320
  elif key == 'max_text_length':
321
  if not isinstance(value, int) or value <= 0:
322
  raise ConfigurationException("max_text_length must be a positive integer")
 
332
  ConfigurationException: If validation fails
333
  """
334
  valid_providers = ['whisper', 'parakeet']
335
+
336
  for key, value in updates.items():
337
  if key == 'preferred_providers':
338
  if not isinstance(value, list):
 
340
  for provider in value:
341
  if provider not in valid_providers:
342
  raise ConfigurationException(f"Invalid STT provider: {provider}")
343
+
344
  elif key == 'default_model':
345
  if value not in valid_providers:
346
  raise ConfigurationException(f"Invalid STT model: {value}")
347
+
348
  elif key == 'chunk_length_s':
349
  if not isinstance(value, int) or value <= 0:
350
  raise ConfigurationException("chunk_length_s must be a positive integer")
351
+
352
  elif key == 'batch_size':
353
  if not isinstance(value, int) or value <= 0:
354
  raise ConfigurationException("batch_size must be a positive integer")
355
+
356
  elif key == 'enable_vad':
357
  if not isinstance(value, bool):
358
  raise ConfigurationException("enable_vad must be a boolean")
 
371
  if key == 'default_provider':
372
  if not isinstance(value, str) or not value:
373
  raise ConfigurationException("default_provider must be a non-empty string")
374
+
375
  elif key == 'model_name':
376
  if not isinstance(value, str) or not value:
377
  raise ConfigurationException("model_name must be a non-empty string")
378
+
379
  elif key == 'max_chunk_length':
380
  if not isinstance(value, int) or value <= 0:
381
  raise ConfigurationException("max_chunk_length must be a positive integer")
382
+
383
  elif key == 'batch_size':
384
  if not isinstance(value, int) or value <= 0:
385
  raise ConfigurationException("batch_size must be a positive integer")
386
+
387
  elif key == 'cache_translations':
388
  if not isinstance(value, bool):
389
  raise ConfigurationException("cache_translations must be a boolean")
 
407
  Path(value).mkdir(parents=True, exist_ok=True)
408
  except Exception as e:
409
  raise ConfigurationException(f"Invalid temp_dir path: {e}")
410
+
411
  elif key == 'cleanup_temp_files':
412
  if not isinstance(value, bool):
413
  raise ConfigurationException("cleanup_temp_files must be a boolean")
414
+
415
  elif key == 'max_file_size_mb':
416
  if not isinstance(value, int) or value <= 0:
417
  raise ConfigurationException("max_file_size_mb must be a positive integer")
418
+
419
  elif key == 'supported_audio_formats':
420
  if not isinstance(value, list):
421
  raise ConfigurationException("supported_audio_formats must be a list")
 
423
  for fmt in value:
424
  if fmt not in valid_formats:
425
  raise ConfigurationException(f"Invalid audio format: {fmt}")
426
+
427
  elif key == 'processing_timeout_seconds':
428
  if not isinstance(value, int) or value <= 0:
429
  raise ConfigurationException("processing_timeout_seconds must be a positive integer")
 
461
  try:
462
  if not os.path.exists(file_path):
463
  raise ConfigurationException(f"Configuration file not found: {file_path}")
464
+
465
  # Create new config instance with the file
466
  new_config = AppConfig(config_file=file_path)
467
+
468
  # Update current config
469
  self._config = new_config
470
+
471
  # Update container with new config
472
  self._container.register_singleton(AppConfig, new_config)
473
+
474
  logger.info(f"Configuration loaded from {file_path}")
475
+
476
  return self.get_current_configuration()
477
+
478
  except Exception as e:
479
  logger.error(f"Failed to load configuration from {file_path}: {e}")
480
  raise ConfigurationException(f"Failed to load configuration: {str(e)}")
 
492
  try:
493
  self._config.reload_configuration()
494
  logger.info("Configuration reloaded successfully")
495
+
496
  return self.get_current_configuration()
497
+
498
  except Exception as e:
499
  logger.error(f"Failed to reload configuration: {e}")
500
  raise ConfigurationException(f"Failed to reload configuration: {str(e)}")
 
512
  'stt': {},
513
  'translation': {}
514
  }
515
+
516
  # Check TTS providers
517
  tts_factory = self._container.resolve(type(self._container._get_tts_factory()))
518
  for provider in ['kokoro', 'dia', 'cosyvoice2', 'dummy']:
 
521
  availability['tts'][provider] = True
522
  except Exception:
523
  availability['tts'][provider] = False
524
+
525
  # Check STT providers
526
  stt_factory = self._container.resolve(type(self._container._get_stt_factory()))
527
  for provider in ['whisper', 'parakeet']:
 
530
  availability['stt'][provider] = True
531
  except Exception:
532
  availability['stt'][provider] = False
533
+
534
  # Check translation providers
535
  translation_factory = self._container.resolve(type(self._container._get_translation_factory()))
536
  try:
 
538
  availability['translation']['nllb'] = True
539
  except Exception:
540
  availability['translation']['nllb'] = False
541
+
542
  return availability
543
+
544
  except Exception as e:
545
  logger.error(f"Failed to check provider availability: {e}")
546
  raise ConfigurationException(f"Failed to check provider availability: {str(e)}")
 
584
  'processing': [],
585
  'logging': []
586
  }
587
+
588
  try:
589
  # Validate TTS configuration
590
  tts_config = self._config.get_tts_config()
591
  if not (0.1 <= tts_config['default_speed'] <= 3.0):
592
  issues['tts'].append(f"Invalid default_speed: {tts_config['default_speed']}")
593
+
594
  if tts_config['max_text_length'] <= 0:
595
  issues['tts'].append(f"Invalid max_text_length: {tts_config['max_text_length']}")
596
+
597
  # Validate STT configuration
598
  stt_config = self._config.get_stt_config()
599
  if stt_config['chunk_length_s'] <= 0:
600
  issues['stt'].append(f"Invalid chunk_length_s: {stt_config['chunk_length_s']}")
601
+
602
  if stt_config['batch_size'] <= 0:
603
  issues['stt'].append(f"Invalid batch_size: {stt_config['batch_size']}")
604
+
605
  # Validate processing configuration
606
  processing_config = self._config.get_processing_config()
607
  if not os.path.exists(processing_config['temp_dir']):
608
  issues['processing'].append(f"Temp directory does not exist: {processing_config['temp_dir']}")
609
+
610
  if processing_config['max_file_size_mb'] <= 0:
611
  issues['processing'].append(f"Invalid max_file_size_mb: {processing_config['max_file_size_mb']}")
612
+
613
  # Validate logging configuration
614
  logging_config = self._config.get_logging_config()
615
  valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
616
  if logging_config['level'].upper() not in valid_levels:
617
  issues['logging'].append(f"Invalid log level: {logging_config['level']}")
618
+
619
  except Exception as e:
620
  issues['general'] = [f"Configuration validation error: {str(e)}"]
621
+
622
  return issues
623
 
624
  def reset_to_defaults(self) -> Dict[str, Any]:
 
634
  try:
635
  # Create new config with defaults
636
  default_config = AppConfig()
637
+
638
  # Update current config
639
  self._config = default_config
640
+
641
  # Update container with new config
642
  self._container.register_singleton(AppConfig, default_config)
643
+
644
  logger.info("Configuration reset to defaults")
645
+
646
  return self.get_current_configuration()
647
+
648
  except Exception as e:
649
  logger.error(f"Failed to reset configuration: {e}")
650
  raise ConfigurationException(f"Failed to reset configuration: {str(e)}")
tests/unit/application/error_handling/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for application error handling."""
tests/unit/application/error_handling/test_error_mapper.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for error mapper functionality."""
2
+
3
+ import pytest
4
+ from unittest.mock import Mock
5
+
6
+ from src.application.error_handling.error_mapper import (
7
+ ErrorMapper, ErrorMapping, ErrorSeverity, ErrorCategory
8
+ )
9
+ from src.domain.exceptions import (
10
+ InvalidAudioFormatException,
11
+ TranslationFailedException,
12
+ SpeechRecognitionException,
13
+ SpeechSynthesisException
14
+ )
15
+
16
+
17
+ class TestErrorMapper:
18
+ """Test cases for ErrorMapper."""
19
+
20
+ def setup_method(self):
21
+ """Set up test fixtures."""
22
+ self.error_mapper = ErrorMapper()
23
+
24
+ def test_map_domain_exception(self):
25
+ """Test mapping of domain exceptions."""
26
+ exception = InvalidAudioFormatException("Unsupported format: xyz")
27
+ context = {
28
+ 'file_name': 'test.xyz',
29
+ 'correlation_id': 'test-123'
30
+ }
31
+
32
+ mapping = self.error_mapper.map_exception(exception, context)
33
+
34
+ assert mapping.error_code == "INVALID_AUDIO_FORMAT"
35
+ assert mapping.severity == ErrorSeverity.MEDIUM
36
+ assert mapping.category == ErrorCategory.VALIDATION
37
+ assert "supported format" in mapping.user_message.lower()
38
+ assert len(mapping.recovery_suggestions) > 0
39
+
40
+ def test_map_translation_exception(self):
41
+ """Test mapping of translation exceptions."""
42
+ exception = TranslationFailedException("Translation service unavailable")
43
+
44
+ mapping = self.error_mapper.map_exception(exception)
45
+
46
+ assert mapping.error_code == "TRANSLATION_FAILED"
47
+ assert mapping.severity == ErrorSeverity.HIGH
48
+ assert mapping.category == ErrorCategory.PROCESSING
49
+ assert "translation failed" in mapping.user_message.lower()
50
+
51
+ def test_map_speech_recognition_exception(self):
52
+ """Test mapping of speech recognition exceptions."""
53
+ exception = SpeechRecognitionException("Audio quality too poor")
54
+ context = {'provider': 'whisper'}
55
+
56
+ mapping = self.error_mapper.map_exception(exception, context)
57
+
58
+ assert mapping.error_code == "SPEECH_RECOGNITION_FAILED"
59
+ assert mapping.severity == ErrorSeverity.HIGH
60
+ assert mapping.category == ErrorCategory.PROCESSING
61
+ assert any("whisper" in suggestion for suggestion in mapping.recovery_suggestions)
62
+
63
+ def test_map_speech_synthesis_exception(self):
64
+ """Test mapping of speech synthesis exceptions."""
65
+ exception = SpeechSynthesisException("Voice not available")
66
+
67
+ mapping = self.error_mapper.map_exception(exception)
68
+
69
+ assert mapping.error_code == "SPEECH_SYNTHESIS_FAILED"
70
+ assert mapping.severity == ErrorSeverity.HIGH
71
+ assert mapping.category == ErrorCategory.PROCESSING
72
+
73
+ def test_map_unknown_exception(self):
74
+ """Test mapping of unknown exceptions."""
75
+ exception = RuntimeError("Unknown error")
76
+
77
+ mapping = self.error_mapper.map_exception(exception)
78
+
79
+ assert mapping.error_code == "UNKNOWN_ERROR"
80
+ assert mapping.severity == ErrorSeverity.CRITICAL
81
+ assert mapping.category == ErrorCategory.SYSTEM
82
+
83
+ def test_context_enhancement(self):
84
+ """Test context enhancement of error mappings."""
85
+ exception = ValueError("Invalid parameter")
86
+ context = {
87
+ 'file_name': 'large_file.wav',
88
+ 'file_size': 100 * 1024 * 1024, # 100MB
89
+ 'correlation_id': 'test-456',
90
+ 'operation': 'audio_processing'
91
+ }
92
+
93
+ mapping = self.error_mapper.map_exception(exception, context)
94
+
95
+ assert 'large_file.wav' in mapping.user_message
96
+ assert 'test-456' in mapping.technical_details
97
+ assert any("smaller file" in suggestion for suggestion in mapping.recovery_suggestions)
98
+
99
+ def test_get_error_code_from_exception(self):
100
+ """Test getting error code from exception."""
101
+ exception = TranslationFailedException("Test error")
102
+
103
+ error_code = self.error_mapper.get_error_code_from_exception(exception)
104
+
105
+ assert error_code == "TRANSLATION_FAILED"
106
+
107
+ def test_get_user_message_from_exception(self):
108
+ """Test getting user message from exception."""
109
+ exception = InvalidAudioFormatException("Test error")
110
+
111
+ message = self.error_mapper.get_user_message_from_exception(exception)
112
+
113
+ assert "supported" in message.lower()
114
+ assert "format" in message.lower()
115
+
116
+ def test_get_recovery_suggestions(self):
117
+ """Test getting recovery suggestions."""
118
+ exception = SpeechRecognitionException("Test error")
119
+
120
+ suggestions = self.error_mapper.get_recovery_suggestions(exception)
121
+
122
+ assert len(suggestions) > 0
123
+ assert any("audio quality" in suggestion.lower() for suggestion in suggestions)
124
+
125
+ def test_add_custom_mapping(self):
126
+ """Test adding custom error mapping."""
127
+ custom_mapping = ErrorMapping(
128
+ user_message="Custom error message",
129
+ error_code="CUSTOM_ERROR",
130
+ severity=ErrorSeverity.LOW,
131
+ category=ErrorCategory.VALIDATION
132
+ )
133
+
134
+ self.error_mapper.add_custom_mapping(CustomException, custom_mapping)
135
+
136
+ exception = CustomException("Test")
137
+ mapping = self.error_mapper.map_exception(exception)
138
+
139
+ assert mapping.error_code == "CUSTOM_ERROR"
140
+ assert mapping.user_message == "Custom error message"
141
+
142
+ def test_get_all_error_codes(self):
143
+ """Test getting all error codes."""
144
+ error_codes = self.error_mapper.get_all_error_codes()
145
+
146
+ assert "INVALID_AUDIO_FORMAT" in error_codes
147
+ assert "TRANSLATION_FAILED" in error_codes
148
+ assert "SPEECH_RECOGNITION_FAILED" in error_codes
149
+ assert "SPEECH_SYNTHESIS_FAILED" in error_codes
150
+ assert "UNKNOWN_ERROR" in error_codes
151
+
152
+
153
+ class CustomException(Exception):
154
+ """Custom exception for testing."""
155
+ pass
tests/unit/application/error_handling/test_structured_logger.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for structured logger functionality."""
2
+
3
+ import pytest
4
+ import json
5
+ import logging
6
+ from unittest.mock import Mock, patch
7
+
8
+ from src.application.error_handling.structured_logger import (
9
+ StructuredLogger, LogContext, JsonFormatter, ContextFormatter,
10
+ get_structured_logger, set_correlation_id, get_correlation_id,
11
+ generate_correlation_id
12
+ )
13
+
14
+
15
+ class TestLogContext:
16
+ """Test cases for LogContext."""
17
+
18
+ def test_log_context_creation(self):
19
+ """Test creating log context."""
20
+ context = LogContext(
21
+ correlation_id="test-123",
22
+ operation="test_operation",
23
+ component="test_component"
24
+ )
25
+
26
+ assert context.correlation_id == "test-123"
27
+ assert context.operation == "test_operation"
28
+ assert context.component == "test_component"
29
+
30
+ def test_log_context_to_dict(self):
31
+ """Test converting log context to dictionary."""
32
+ context = LogContext(
33
+ correlation_id="test-123",
34
+ operation="test_operation",
35
+ metadata={"key": "value"}
36
+ )
37
+
38
+ context_dict = context.to_dict()
39
+
40
+ assert context_dict["correlation_id"] == "test-123"
41
+ assert context_dict["operation"] == "test_operation"
42
+ assert context_dict["metadata"] == {"key": "value"}
43
+ assert "user_id" not in context_dict # None values should be excluded
44
+
45
+
46
+ class TestStructuredLogger:
47
+ """Test cases for StructuredLogger."""
48
+
49
+ def setup_method(self):
50
+ """Set up test fixtures."""
51
+ self.logger = StructuredLogger("test_logger", enable_json_logging=False)
52
+
53
+ def test_logger_creation(self):
54
+ """Test creating structured logger."""
55
+ assert self.logger.logger.name == "test_logger"
56
+ assert not self.logger.enable_json_logging
57
+
58
+ def test_debug_logging(self):
59
+ """Test debug logging."""
60
+ context = LogContext(correlation_id="test-123", operation="test_op")
61
+
62
+ with patch.object(self.logger.logger, 'debug') as mock_debug:
63
+ self.logger.debug("Test debug message", context=context)
64
+
65
+ mock_debug.assert_called_once()
66
+ args, kwargs = mock_debug.call_args
67
+ assert "Test debug message" in args[0]
68
+ assert "extra" in kwargs
69
+
70
+ def test_info_logging(self):
71
+ """Test info logging."""
72
+ context = LogContext(correlation_id="test-123")
73
+ extra = {"key": "value"}
74
+
75
+ with patch.object(self.logger.logger, 'info') as mock_info:
76
+ self.logger.info("Test info message", context=context, extra=extra)
77
+
78
+ mock_info.assert_called_once()
79
+ args, kwargs = mock_info.call_args
80
+ assert "Test info message" in args[0]
81
+ assert kwargs["extra"]["extra"] == extra
82
+
83
+ def test_error_logging_with_exception(self):
84
+ """Test error logging with exception."""
85
+ context = LogContext(correlation_id="test-123")
86
+ exception = ValueError("Test error")
87
+
88
+ with patch.object(self.logger.logger, 'error') as mock_error:
89
+ self.logger.error("Test error message", context=context, exception=exception)
90
+
91
+ mock_error.assert_called_once()
92
+ args, kwargs = mock_error.call_args
93
+ assert "Test error message" in args[0]
94
+ assert kwargs["extra"]["exception"]["type"] == "ValueError"
95
+ assert kwargs["extra"]["exception"]["message"] == "Test error"
96
+
97
+ def test_log_operation_start(self):
98
+ """Test logging operation start."""
99
+ extra = {"param": "value"}
100
+
101
+ with patch.object(self.logger.logger, 'info') as mock_info:
102
+ correlation_id = self.logger.log_operation_start("test_operation", extra=extra)
103
+
104
+ assert correlation_id is not None
105
+ mock_info.assert_called_once()
106
+ args, kwargs = mock_info.call_args
107
+ assert "Operation started: test_operation" in args[0]
108
+
109
+ def test_log_operation_end_success(self):
110
+ """Test logging successful operation end."""
111
+ correlation_id = "test-123"
112
+
113
+ with patch.object(self.logger.logger, 'info') as mock_info:
114
+ self.logger.log_operation_end(
115
+ "test_operation",
116
+ correlation_id,
117
+ success=True,
118
+ duration=1.5
119
+ )
120
+
121
+ mock_info.assert_called_once()
122
+ args, kwargs = mock_info.call_args
123
+ assert "completed successfully" in args[0]
124
+ assert kwargs["extra"]["extra"]["success"] is True
125
+ assert kwargs["extra"]["extra"]["duration_seconds"] == 1.5
126
+
127
+ def test_log_operation_end_failure(self):
128
+ """Test logging failed operation end."""
129
+ correlation_id = "test-123"
130
+
131
+ with patch.object(self.logger.logger, 'error') as mock_error:
132
+ self.logger.log_operation_end(
133
+ "test_operation",
134
+ correlation_id,
135
+ success=False
136
+ )
137
+
138
+ mock_error.assert_called_once()
139
+ args, kwargs = mock_error.call_args
140
+ assert "failed" in args[0]
141
+ assert kwargs["extra"]["extra"]["success"] is False
142
+
143
+ def test_log_performance_metric(self):
144
+ """Test logging performance metric."""
145
+ context = LogContext(correlation_id="test-123")
146
+
147
+ with patch.object(self.logger.logger, 'info') as mock_info:
148
+ self.logger.log_performance_metric(
149
+ "response_time",
150
+ 150.5,
151
+ "ms",
152
+ context=context
153
+ )
154
+
155
+ mock_info.assert_called_once()
156
+ args, kwargs = mock_info.call_args
157
+ assert "Performance metric: response_time=150.5 ms" in args[0]
158
+ assert kwargs["extra"]["extra"]["metric"]["name"] == "response_time"
159
+ assert kwargs["extra"]["extra"]["metric"]["value"] == 150.5
160
+ assert kwargs["extra"]["extra"]["metric"]["unit"] == "ms"
161
+
162
+
163
+ class TestJsonFormatter:
164
+ """Test cases for JsonFormatter."""
165
+
166
+ def setup_method(self):
167
+ """Set up test fixtures."""
168
+ self.formatter = JsonFormatter()
169
+
170
+ def test_format_log_record(self):
171
+ """Test formatting log record as JSON."""
172
+ record = logging.LogRecord(
173
+ name="test_logger",
174
+ level=logging.INFO,
175
+ pathname="test.py",
176
+ lineno=10,
177
+ msg="Test message",
178
+ args=(),
179
+ exc_info=None
180
+ )
181
+
182
+ # Add extra data
183
+ record.extra = {
184
+ "correlation_id": "test-123",
185
+ "operation": "test_op"
186
+ }
187
+
188
+ formatted = self.formatter.format(record)
189
+
190
+ # Should be valid JSON
191
+ log_data = json.loads(formatted)
192
+ assert log_data["message"] == "Test message"
193
+ assert log_data["level"] == "INFO"
194
+ assert log_data["correlation_id"] == "test-123"
195
+ assert log_data["operation"] == "test_op"
196
+
197
+ def test_format_error_handling(self):
198
+ """Test formatter error handling."""
199
+ record = logging.LogRecord(
200
+ name="test_logger",
201
+ level=logging.INFO,
202
+ pathname="test.py",
203
+ lineno=10,
204
+ msg="Test message",
205
+ args=(),
206
+ exc_info=None
207
+ )
208
+
209
+ # Add problematic extra data that can't be JSON serialized
210
+ record.extra = {
211
+ "correlation_id": "test-123",
212
+ "problematic_data": object() # Can't be JSON serialized
213
+ }
214
+
215
+ formatted = self.formatter.format(record)
216
+
217
+ # Should still work and include error message
218
+ assert "Test message" in formatted
219
+
220
+
221
+ class TestContextFormatter:
222
+ """Test cases for ContextFormatter."""
223
+
224
+ def setup_method(self):
225
+ """Set up test fixtures."""
226
+ self.formatter = ContextFormatter()
227
+
228
+ def test_format_with_correlation_id(self):
229
+ """Test formatting with correlation ID."""
230
+ record = logging.LogRecord(
231
+ name="test_logger",
232
+ level=logging.INFO,
233
+ pathname="test.py",
234
+ lineno=10,
235
+ msg="Test message",
236
+ args=(),
237
+ exc_info=None
238
+ )
239
+
240
+ record.extra = {"correlation_id": "test-123"}
241
+
242
+ formatted = self.formatter.format(record)
243
+
244
+ assert "[test-123]" in formatted
245
+ assert "Test message" in formatted
246
+
247
+ def test_format_with_operation(self):
248
+ """Test formatting with operation context."""
249
+ record = logging.LogRecord(
250
+ name="test_logger",
251
+ level=logging.INFO,
252
+ pathname="test.py",
253
+ lineno=10,
254
+ msg="Test message",
255
+ args=(),
256
+ exc_info=None
257
+ )
258
+
259
+ record.extra = {
260
+ "correlation_id": "test-123",
261
+ "operation": "test_operation"
262
+ }
263
+
264
+ formatted = self.formatter.format(record)
265
+
266
+ assert "[test_operation]" in formatted
267
+ assert "Test message" in formatted
268
+
269
+
270
+ class TestUtilityFunctions:
271
+ """Test cases for utility functions."""
272
+
273
+ def test_get_structured_logger(self):
274
+ """Test getting structured logger."""
275
+ logger = get_structured_logger("test_logger")
276
+
277
+ assert isinstance(logger, StructuredLogger)
278
+ assert logger.logger.name == "test_logger"
279
+
280
+ def test_correlation_id_context(self):
281
+ """Test correlation ID context management."""
282
+ # Initially should be None
283
+ assert get_correlation_id() is None
284
+
285
+ # Set correlation ID
286
+ set_correlation_id("test-123")
287
+ assert get_correlation_id() == "test-123"
288
+
289
+ def test_generate_correlation_id(self):
290
+ """Test generating correlation ID."""
291
+ correlation_id = generate_correlation_id()
292
+
293
+ assert correlation_id is not None
294
+ assert len(correlation_id) > 0
295
+
296
+ # Should generate different IDs
297
+ another_id = generate_correlation_id()
298
+ assert correlation_id != another_id