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

1""" 

2ErrorReporter - Main error categorization and handling logic 

3""" 

4 

5import re 

6from enum import Enum 

7from typing import Any, Dict, Optional 

8 

9from loguru import logger 

10 

11 

12class ErrorCategory(Enum): 

13 """Categories of errors that can occur during research""" 

14 

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" 

22 

23 

24class ErrorReporter: 

25 """ 

26 Analyzes and categorizes errors to provide better user feedback 

27 """ 

28 

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 } 

111 

112 def categorize_error(self, error_message: str) -> ErrorCategory: 

113 """ 

114 Categorize an error based on its message 

115 

116 Args: 

117 error_message: The error message to categorize 

118 

119 Returns: 

120 ErrorCategory: The categorized error type 

121 """ 

122 error_message = str(error_message).lower() 

123 

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 

131 

132 return ErrorCategory.UNKNOWN_ERROR 

133 

134 def get_user_friendly_title(self, category: ErrorCategory) -> str: 

135 """ 

136 Get a user-friendly title for an error category 

137 

138 Args: 

139 category: The error category 

140 

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") 

154 

155 def get_suggested_actions(self, category: ErrorCategory) -> list: 

156 """ 

157 Get suggested actions for resolving an error 

158 

159 Args: 

160 category: The error category 

161 

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"]) 

213 

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 

219 

220 Args: 

221 error_message: The error message to analyze 

222 context: Optional context information 

223 

224 Returns: 

225 dict: Comprehensive error analysis 

226 """ 

227 category = self.categorize_error(error_message) 

228 

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 } 

237 

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 ) 

246 

247 # Send notifications for specific error types 

248 self._send_error_notifications(category, error_message, context) 

249 

250 return analysis 

251 

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. 

260 

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 

273 

274 # Try to get username from context 

275 username = None 

276 if context: 

277 username = context.get("username") 

278 

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 

285 

286 from ..notifications.manager import NotificationManager 

287 from ..notifications import EventType 

288 from ..database.session_context import get_user_db_session 

289 

290 # Get settings snapshot for notification 

291 with get_user_db_session(username) as session: 

292 from ..settings import SettingsManager 

293 

294 settings_manager = SettingsManager(session) 

295 settings_snapshot = settings_manager.get_settings_snapshot() 

296 

297 notification_manager = NotificationManager( 

298 settings_snapshot=settings_snapshot, user_id=username 

299 ) 

300 

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 

321 

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 } 

330 

331 else: 

332 return 

333 

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) 

358 

359 except Exception as e: 

360 logger.debug(f"Failed to send error notification: {e}") 

361 

362 def _extract_service_name(self, error_message: str) -> str: 

363 """ 

364 Extract service name from error message. 

365 

366 Args: 

367 error_message: Error message 

368 

369 Returns: 

370 Service name or "API Service" 

371 """ 

372 error_lower = error_message.lower() 

373 

374 # Check for common service names 

375 services = [ 

376 "openai", 

377 "anthropic", 

378 "google", 

379 "ollama", 

380 "searxng", 

381 "tavily", 

382 "brave", 

383 ] 

384 

385 for service in services: 

386 if service in error_lower: 

387 return service.title() 

388 

389 return "API Service" 

390 

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") 

403 

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)