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