Coverage for src/local_deep_research/error_handling/error_reporter.py: 91%
90 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2ErrorReporter - Main error categorization and handling logic
3"""
5import re
6from enum import Enum
7from typing import Any, Dict, Optional
9from loguru import logger
11from .error_messages import MAX_ERROR_CLASSIFICATION_CHARS
14class ErrorCategory(Enum):
15 """Categories of errors that can occur during research"""
17 CONNECTION_ERROR = "connection_error"
18 MODEL_ERROR = "model_error"
19 SEARCH_ERROR = "search_error"
20 SYNTHESIS_ERROR = "synthesis_error"
21 FILE_ERROR = "file_error"
22 RATE_LIMIT_ERROR = "rate_limit_error"
23 UNKNOWN_ERROR = "unknown_error"
26class ErrorReporter:
27 """
28 Analyzes and categorizes errors to provide better user feedback
29 """
31 def __init__(self):
32 self.error_patterns = {
33 ErrorCategory.CONNECTION_ERROR: [
34 r"POST predict.*EOF",
35 r"Connection refused",
36 r"timeout",
37 r"Connection.*failed",
38 r"HTTP error \d+",
39 r"network.*error",
40 r"\[Errno 111\]",
41 r"host\.docker\.internal",
42 r"host.*localhost.*Docker",
43 r"127\.0\.0\.1.*Docker",
44 r"localhost.*1234.*Docker",
45 r"LM.*Studio.*Docker.*Mac",
46 # OpenAI-compatible endpoint tokens (#3878)
47 r"Error type: openai_connection_refused",
48 r"Error type: openai_timeout",
49 ],
50 ErrorCategory.MODEL_ERROR: [
51 r"Model.*not found",
52 r"Invalid.*model",
53 r"Ollama.*not available",
54 r"API key.*invalid",
55 r"Authentication.*error",
56 r"max_workers must be greater than 0",
57 r"TypeError.*Context.*Size",
58 r"'<' not supported between instances of .* and 'NoneType'",
59 r"No auth credentials found",
60 r"401.*API key",
61 # OpenAI-compatible endpoint tokens (#3878)
62 r"Error type: openai_auth",
63 r"Error type: openai_permission_denied",
64 r"Error type: openai_model_not_found",
65 r"Error type: openai_bad_request",
66 r"Error type: openai_unknown",
67 ],
68 ErrorCategory.RATE_LIMIT_ERROR: [
69 r"429.*resource.*exhausted",
70 r"429.*too many requests",
71 r"rate limit",
72 r"rate_limit",
73 r"ratelimit",
74 r"quota.*exceeded",
75 r"resource.*exhausted.*quota",
76 r"threshold.*requests",
77 r"LLM rate limit",
78 r"API rate limit",
79 r"maximum.*requests.*minute",
80 r"maximum.*requests.*hour",
81 # OpenAI-compatible endpoint token (#3878 follow-up)
82 r"Error type: openai_rate_limit",
83 ],
84 ErrorCategory.SEARCH_ERROR: [
85 r"Search.*failed",
86 r"No search results",
87 r"Search engine.*error",
88 r"The search is longer than 256 characters",
89 r"Failed to create search engine",
90 r"search engine.*could not be found",
91 r"GitHub API error",
92 ],
93 ErrorCategory.SYNTHESIS_ERROR: [
94 r"Error.*synthesis",
95 r"Failed.*generate",
96 r"Synthesis.*timeout",
97 r"detailed.*report.*stuck",
98 r"report.*taking.*long",
99 r"progress.*100.*stuck",
100 ],
101 ErrorCategory.FILE_ERROR: [
102 r"Permission denied",
103 r"File.*not found",
104 r"Cannot write.*file",
105 r"Disk.*full",
106 r"No module named.*local_deep_research",
107 r"HTTP error 404.*research results",
108 r"Attempt to write readonly database",
109 r"database is locked",
110 r"database table is locked",
111 ],
112 }
114 def categorize_error(self, error_message: str) -> ErrorCategory:
115 """
116 Categorize an error based on its message
118 Args:
119 error_message: The error message to categorize
121 Returns:
122 ErrorCategory: The categorized error type
123 """
124 # Keep the regex work bounded even when a provider includes a large
125 # response body in its exception. Slice after lowercasing too, since
126 # Unicode case conversion can expand a character into several.
127 error_message = str(error_message)[:MAX_ERROR_CLASSIFICATION_CHARS]
128 error_message = error_message.lower()[:MAX_ERROR_CLASSIFICATION_CHARS]
130 for category, patterns in self.error_patterns.items():
131 for pattern in patterns:
132 if re.search(pattern.lower(), error_message):
133 logger.debug(
134 f"Categorized error as {category.value}: {pattern}"
135 )
136 return category
138 return ErrorCategory.UNKNOWN_ERROR
140 def get_user_friendly_title(self, category: ErrorCategory) -> str:
141 """
142 Get a user-friendly title for an error category
144 Args:
145 category: The error category
147 Returns:
148 str: User-friendly title
149 """
150 titles = {
151 ErrorCategory.CONNECTION_ERROR: "Connection Issue",
152 ErrorCategory.MODEL_ERROR: "LLM Service Error",
153 ErrorCategory.SEARCH_ERROR: "Search Service Error",
154 ErrorCategory.SYNTHESIS_ERROR: "Report Generation Error",
155 ErrorCategory.FILE_ERROR: "File System Error",
156 ErrorCategory.RATE_LIMIT_ERROR: "API Rate Limit Exceeded",
157 ErrorCategory.UNKNOWN_ERROR: "Unexpected Error",
158 }
159 return titles.get(category, "Error")
161 def get_suggested_actions(self, category: ErrorCategory) -> list:
162 """
163 Get suggested actions for resolving an error
165 Args:
166 category: The error category
168 Returns:
169 list: List of suggested actions
170 """
171 suggestions = {
172 ErrorCategory.CONNECTION_ERROR: [
173 "Check if the LLM service (Ollama/LM Studio) is running",
174 "Verify network connectivity",
175 "Try switching to a different model provider",
176 "Check the service logs for more details",
177 ],
178 ErrorCategory.MODEL_ERROR: [
179 "Verify the model name is correct",
180 "Check if the model is downloaded and available",
181 "Validate API keys if using external services",
182 "Try switching to a different model",
183 ],
184 ErrorCategory.SEARCH_ERROR: [
185 "Check internet connectivity",
186 "Try reducing the number of search results",
187 "Wait a moment and try again",
188 "Check if search service is configured correctly",
189 "For local documents: ensure the path is absolute and folder exists",
190 "Try a different search engine if one is failing",
191 ],
192 ErrorCategory.SYNTHESIS_ERROR: [
193 "The research data was collected successfully",
194 "Try switching to a different model for report generation",
195 "Check the partial results below",
196 "Review the detailed logs for more information",
197 ],
198 ErrorCategory.FILE_ERROR: [
199 "Check disk space availability",
200 "Verify write permissions",
201 "Try changing the output directory",
202 "Restart the application",
203 ],
204 ErrorCategory.RATE_LIMIT_ERROR: [
205 "The API has reached its rate limit",
206 "Enable LLM Rate Limiting in Settings → Rate Limiting → Enable LLM Rate Limiting",
207 "Once enabled, the system will automatically learn and adapt to API limits",
208 "Consider upgrading to a paid API plan for higher limits",
209 "Try using a different model temporarily",
210 ],
211 ErrorCategory.UNKNOWN_ERROR: [
212 "Check the detailed logs below for more information",
213 "Try running the research again",
214 "Report this issue if it persists",
215 "Contact support with the error details",
216 ],
217 }
218 return suggestions.get(category, ["Check the logs for more details"])
220 def analyze_error(
221 self, error_message: str, context: Optional[Dict[str, Any]] = None
222 ) -> Dict[str, Any]:
223 """
224 Perform comprehensive error analysis
226 Args:
227 error_message: The error message to analyze
228 context: Optional context information
230 Returns:
231 dict: Comprehensive error analysis
232 """
233 category = self.categorize_error(error_message)
235 analysis = {
236 "category": category,
237 "title": self.get_user_friendly_title(category),
238 "original_error": error_message,
239 "suggestions": self.get_suggested_actions(category),
240 "severity": self._determine_severity(category),
241 "recoverable": self._is_recoverable(category),
242 }
244 # Add context-specific information
245 if context:
246 analysis["context"] = context
247 analysis["has_partial_results"] = bool(
248 context.get("findings")
249 or context.get("current_knowledge")
250 or context.get("search_results")
251 )
253 # Send notifications for specific error types
254 self._send_error_notifications(category, error_message, context)
256 return analysis
258 def _send_error_notifications(
259 self,
260 category: ErrorCategory,
261 error_message: str,
262 context: Optional[Dict[str, Any]] = None,
263 ) -> None:
264 """
265 Send notifications for specific error categories.
267 Args:
268 category: Error category
269 error_message: Error message
270 context: Optional context information
271 """
272 try:
273 # Only send notifications for AUTH and QUOTA errors
274 if category not in [
275 ErrorCategory.MODEL_ERROR,
276 ErrorCategory.RATE_LIMIT_ERROR,
277 ]:
278 return
280 # Try to get username from context
281 username = None
282 if context:
283 username = context.get("username")
285 # Don't send notifications if we can't determine user
286 if not username:
287 logger.debug(
288 "No username in context, skipping error notification"
289 )
290 return
292 from ..notifications.manager import NotificationManager
293 from ..notifications import EventType
294 from ..database.session_context import get_user_db_session
296 # Get settings snapshot for notification
297 with get_user_db_session(username) as session:
298 from ..settings import SettingsManager
300 settings_manager = SettingsManager(session)
301 settings_snapshot = settings_manager.get_settings_snapshot()
303 notification_manager = NotificationManager(
304 settings_snapshot=settings_snapshot, user_id=username
305 )
307 # Determine event type and build context
308 if category == ErrorCategory.MODEL_ERROR: 308 ↛ 328line 308 didn't jump to line 328 because the condition on line 308 was always true
309 # Check if it's an auth error specifically
310 error_str = error_message.lower()
311 if any( 311 ↛ 326line 311 didn't jump to line 326 because the condition on line 311 was always true
312 pattern in error_str
313 for pattern in [
314 "api key",
315 "authentication",
316 "401",
317 "unauthorized",
318 ]
319 ):
320 event_type = EventType.AUTH_ISSUE
321 notification_context = {
322 "service": self._extract_service_name(error_message),
323 }
324 else:
325 # Not an auth error, don't notify
326 return
328 elif category == ErrorCategory.RATE_LIMIT_ERROR:
329 event_type = EventType.API_QUOTA_WARNING
330 notification_context = {
331 "service": self._extract_service_name(error_message),
332 "current": "Unknown",
333 "limit": "Unknown",
334 "reset_time": "Unknown",
335 }
337 else:
338 return
340 # Send notification
341 result = notification_manager.send_notification(
342 event_type=event_type,
343 context=notification_context,
344 )
345 if not result:
346 # DEBUG so it doesn't pollute the default log, but operators
347 # enabling ``--log-level debug`` can see precisely why the
348 # error-notification didn't fire (e.g. server_disabled vs.
349 # unconfigured). Use ``reason.value`` so the enum renders
350 # as the clean string ("unconfigured") rather than
351 # "NotificationReason.UNCONFIGURED". Include ``detail``
352 # when present so the operator-facing explanation is not
353 # silently dropped. See issue #4877.
354 reason_value = getattr(
355 getattr(result, "reason", None), "value", None
356 )
357 detail = getattr(result, "detail", "") or ""
358 if reason_value is None:
359 # Legacy bool-returning mocks/callers carry no reason.
360 reason_value = "dropped"
361 message = (
362 f"{reason_value}: {detail}" if detail else reason_value
363 )
364 logger.debug("Error notification not sent: {}", message)
366 except Exception as e:
367 logger.debug(f"Failed to send error notification: {e}")
369 def _extract_service_name(self, error_message: str) -> str:
370 """
371 Extract service name from error message.
373 Args:
374 error_message: Error message
376 Returns:
377 Service name or "API Service"
378 """
379 error_lower = error_message.lower()
381 # Check for common service names
382 services = [
383 "openai",
384 "anthropic",
385 "google",
386 "ollama",
387 "searxng",
388 "tavily",
389 "brave",
390 ]
392 for service in services:
393 if service in error_lower:
394 return service.title()
396 return "API Service"
398 def _determine_severity(self, category: ErrorCategory) -> str:
399 """Determine error severity level"""
400 severity_map = {
401 ErrorCategory.CONNECTION_ERROR: "high",
402 ErrorCategory.MODEL_ERROR: "high",
403 ErrorCategory.SEARCH_ERROR: "medium",
404 ErrorCategory.SYNTHESIS_ERROR: "low", # Can often show partial results
405 ErrorCategory.FILE_ERROR: "medium",
406 ErrorCategory.RATE_LIMIT_ERROR: "medium", # Can be resolved with settings
407 ErrorCategory.UNKNOWN_ERROR: "high",
408 }
409 return severity_map.get(category, "medium")
411 def _is_recoverable(self, category: ErrorCategory) -> bool:
412 """Determine if error is recoverable with user action"""
413 recoverable = {
414 ErrorCategory.CONNECTION_ERROR: True,
415 ErrorCategory.MODEL_ERROR: True,
416 ErrorCategory.SEARCH_ERROR: True,
417 ErrorCategory.SYNTHESIS_ERROR: True,
418 ErrorCategory.FILE_ERROR: True,
419 ErrorCategory.RATE_LIMIT_ERROR: True, # Can enable rate limiting
420 ErrorCategory.UNKNOWN_ERROR: False,
421 }
422 return recoverable.get(category, False)