__init__.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  1. """
  2. Unified Error Handling Module
  3. This module provides:
  4. - Custom exception classes for different error types
  5. - Global exception handlers for Flask
  6. - Unified error response format
  7. - Error logging with timestamp, context, and stack trace
  8. Requirements:
  9. - 8.1: Log errors with timestamp, context, and stack trace
  10. - 8.5: Gracefully handle critical errors without crashing
  11. """
  12. import traceback
  13. import logging
  14. from datetime import datetime, timezone
  15. from functools import wraps
  16. from typing import Optional, Dict, Any
  17. from flask import jsonify, request, current_app
  18. # Configure module logger
  19. logger = logging.getLogger(__name__)
  20. # ==================== Error Codes ====================
  21. class ErrorCode:
  22. """Standardized error codes for the application"""
  23. # Authentication errors (401)
  24. AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED'
  25. TOKEN_EXPIRED = 'TOKEN_EXPIRED'
  26. TOKEN_INVALID = 'TOKEN_INVALID'
  27. # Authorization errors (403)
  28. ACCESS_DENIED = 'ACCESS_DENIED'
  29. INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS'
  30. # Validation errors (400)
  31. VALIDATION_ERROR = 'VALIDATION_ERROR'
  32. MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD'
  33. INVALID_FIELD_FORMAT = 'INVALID_FIELD_FORMAT'
  34. # Resource errors (404)
  35. RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND'
  36. USER_NOT_FOUND = 'USER_NOT_FOUND'
  37. CREDENTIAL_NOT_FOUND = 'CREDENTIAL_NOT_FOUND'
  38. TASK_NOT_FOUND = 'TASK_NOT_FOUND'
  39. REPORT_NOT_FOUND = 'REPORT_NOT_FOUND'
  40. # AWS errors
  41. AWS_CREDENTIAL_INVALID = 'AWS_CREDENTIAL_INVALID'
  42. AWS_API_ERROR = 'AWS_API_ERROR'
  43. AWS_RATE_LIMITED = 'AWS_RATE_LIMITED'
  44. AWS_ACCESS_DENIED = 'AWS_ACCESS_DENIED'
  45. # System errors (500)
  46. INTERNAL_ERROR = 'INTERNAL_ERROR'
  47. DATABASE_ERROR = 'DATABASE_ERROR'
  48. FILE_SYSTEM_ERROR = 'FILE_SYSTEM_ERROR'
  49. WORKER_ERROR = 'WORKER_ERROR'
  50. # Task errors
  51. TASK_EXECUTION_ERROR = 'TASK_EXECUTION_ERROR'
  52. TASK_TIMEOUT = 'TASK_TIMEOUT'
  53. SCAN_ERROR = 'SCAN_ERROR'
  54. REPORT_GENERATION_ERROR = 'REPORT_GENERATION_ERROR'
  55. # ==================== Custom Exception Classes ====================
  56. class AppError(Exception):
  57. """
  58. Base application error class.
  59. All custom exceptions should inherit from this class.
  60. Provides consistent error structure with code, message, status_code, and details.
  61. """
  62. def __init__(
  63. self,
  64. message: str,
  65. code: str = ErrorCode.INTERNAL_ERROR,
  66. status_code: int = 400,
  67. details: Optional[Dict[str, Any]] = None,
  68. original_exception: Optional[Exception] = None
  69. ):
  70. super().__init__(message)
  71. self.message = message
  72. self.code = code
  73. self.status_code = status_code
  74. self.details = details or {}
  75. self.original_exception = original_exception
  76. self.timestamp = datetime.now(timezone.utc)
  77. # Capture stack trace if original exception provided
  78. if original_exception:
  79. self.stack_trace = traceback.format_exception(
  80. type(original_exception),
  81. original_exception,
  82. original_exception.__traceback__
  83. )
  84. else:
  85. self.stack_trace = traceback.format_stack()
  86. def to_dict(self) -> Dict[str, Any]:
  87. """Convert error to dictionary for JSON response"""
  88. error_dict = {
  89. 'code': self.code,
  90. 'message': self.message,
  91. 'timestamp': self.timestamp.isoformat()
  92. }
  93. if self.details:
  94. error_dict['details'] = self.details
  95. return error_dict
  96. def to_log_dict(self) -> Dict[str, Any]:
  97. """Convert error to dictionary for logging (includes stack trace)"""
  98. log_dict = self.to_dict()
  99. log_dict['status_code'] = self.status_code
  100. log_dict['stack_trace'] = ''.join(self.stack_trace) if self.stack_trace else None
  101. return log_dict
  102. class AuthenticationError(AppError):
  103. """Authentication related errors (401)"""
  104. def __init__(
  105. self,
  106. message: str = "Authentication failed",
  107. code: str = ErrorCode.AUTHENTICATION_FAILED,
  108. details: Optional[Dict[str, Any]] = None,
  109. original_exception: Optional[Exception] = None
  110. ):
  111. super().__init__(
  112. message=message,
  113. code=code,
  114. status_code=401,
  115. details=details,
  116. original_exception=original_exception
  117. )
  118. class AuthorizationError(AppError):
  119. """Authorization related errors (403)"""
  120. def __init__(
  121. self,
  122. message: str = "Access denied",
  123. code: str = ErrorCode.ACCESS_DENIED,
  124. details: Optional[Dict[str, Any]] = None,
  125. original_exception: Optional[Exception] = None
  126. ):
  127. super().__init__(
  128. message=message,
  129. code=code,
  130. status_code=403,
  131. details=details,
  132. original_exception=original_exception
  133. )
  134. class NotFoundError(AppError):
  135. """Resource not found errors (404)"""
  136. def __init__(
  137. self,
  138. message: str = "Resource not found",
  139. code: str = ErrorCode.RESOURCE_NOT_FOUND,
  140. details: Optional[Dict[str, Any]] = None,
  141. original_exception: Optional[Exception] = None
  142. ):
  143. super().__init__(
  144. message=message,
  145. code=code,
  146. status_code=404,
  147. details=details,
  148. original_exception=original_exception
  149. )
  150. class ValidationError(AppError):
  151. """Validation errors (400)"""
  152. def __init__(
  153. self,
  154. message: str = "Validation failed",
  155. code: str = ErrorCode.VALIDATION_ERROR,
  156. details: Optional[Dict[str, Any]] = None,
  157. original_exception: Optional[Exception] = None
  158. ):
  159. super().__init__(
  160. message=message,
  161. code=code,
  162. status_code=400,
  163. details=details,
  164. original_exception=original_exception
  165. )
  166. class AWSError(AppError):
  167. """AWS related errors"""
  168. def __init__(
  169. self,
  170. message: str = "AWS operation failed",
  171. code: str = ErrorCode.AWS_API_ERROR,
  172. status_code: int = 500,
  173. details: Optional[Dict[str, Any]] = None,
  174. original_exception: Optional[Exception] = None
  175. ):
  176. super().__init__(
  177. message=message,
  178. code=code,
  179. status_code=status_code,
  180. details=details,
  181. original_exception=original_exception
  182. )
  183. class DatabaseError(AppError):
  184. """Database related errors"""
  185. def __init__(
  186. self,
  187. message: str = "Database operation failed",
  188. code: str = ErrorCode.DATABASE_ERROR,
  189. details: Optional[Dict[str, Any]] = None,
  190. original_exception: Optional[Exception] = None
  191. ):
  192. super().__init__(
  193. message=message,
  194. code=code,
  195. status_code=500,
  196. details=details,
  197. original_exception=original_exception
  198. )
  199. class TaskError(AppError):
  200. """Task execution related errors"""
  201. def __init__(
  202. self,
  203. message: str = "Task execution failed",
  204. code: str = ErrorCode.TASK_EXECUTION_ERROR,
  205. status_code: int = 500,
  206. details: Optional[Dict[str, Any]] = None,
  207. original_exception: Optional[Exception] = None,
  208. task_id: Optional[int] = None
  209. ):
  210. if task_id:
  211. details = details or {}
  212. details['task_id'] = task_id
  213. super().__init__(
  214. message=message,
  215. code=code,
  216. status_code=status_code,
  217. details=details,
  218. original_exception=original_exception
  219. )
  220. self.task_id = task_id
  221. class ScanError(TaskError):
  222. """Scan operation related errors"""
  223. def __init__(
  224. self,
  225. message: str = "Scan operation failed",
  226. code: str = ErrorCode.SCAN_ERROR,
  227. details: Optional[Dict[str, Any]] = None,
  228. original_exception: Optional[Exception] = None,
  229. task_id: Optional[int] = None,
  230. service: Optional[str] = None,
  231. region: Optional[str] = None
  232. ):
  233. details = details or {}
  234. if service:
  235. details['service'] = service
  236. if region:
  237. details['region'] = region
  238. super().__init__(
  239. message=message,
  240. code=code,
  241. status_code=500,
  242. details=details,
  243. original_exception=original_exception,
  244. task_id=task_id
  245. )
  246. self.service = service
  247. self.region = region
  248. class ReportGenerationError(TaskError):
  249. """Report generation related errors"""
  250. def __init__(
  251. self,
  252. message: str = "Report generation failed",
  253. code: str = ErrorCode.REPORT_GENERATION_ERROR,
  254. details: Optional[Dict[str, Any]] = None,
  255. original_exception: Optional[Exception] = None,
  256. task_id: Optional[int] = None
  257. ):
  258. super().__init__(
  259. message=message,
  260. code=code,
  261. status_code=500,
  262. details=details,
  263. original_exception=original_exception,
  264. task_id=task_id
  265. )
  266. # ==================== Error Logging Utilities ====================
  267. def log_error(
  268. error: Exception,
  269. context: Optional[Dict[str, Any]] = None,
  270. level: str = 'error'
  271. ) -> Dict[str, Any]:
  272. """
  273. Log an error with full context and stack trace.
  274. Requirements:
  275. - 8.1: Log errors with timestamp, context, and stack trace
  276. Args:
  277. error: The exception to log
  278. context: Additional context information
  279. level: Log level ('error', 'warning', 'critical')
  280. Returns:
  281. Dictionary containing the logged error information
  282. """
  283. timestamp = datetime.now(timezone.utc)
  284. # Build error log entry
  285. log_entry = {
  286. 'timestamp': timestamp.isoformat(),
  287. 'error_type': type(error).__name__,
  288. 'message': str(error),
  289. 'context': context or {}
  290. }
  291. # Add request context if available
  292. try:
  293. if request:
  294. log_entry['request'] = {
  295. 'method': request.method,
  296. 'path': request.path,
  297. 'remote_addr': request.remote_addr,
  298. 'user_agent': str(request.user_agent) if request.user_agent else None
  299. }
  300. except RuntimeError:
  301. # Outside of request context
  302. pass
  303. # Add stack trace
  304. if isinstance(error, AppError):
  305. log_entry['code'] = error.code
  306. log_entry['status_code'] = error.status_code
  307. log_entry['details'] = error.details
  308. log_entry['stack_trace'] = ''.join(error.stack_trace) if error.stack_trace else None
  309. else:
  310. log_entry['stack_trace'] = traceback.format_exc()
  311. # Log at appropriate level
  312. log_message = f"[{log_entry['error_type']}] {log_entry['message']}"
  313. if level == 'critical':
  314. logger.critical(log_message, extra={'error_details': log_entry})
  315. elif level == 'warning':
  316. logger.warning(log_message, extra={'error_details': log_entry})
  317. else:
  318. logger.error(log_message, extra={'error_details': log_entry})
  319. return log_entry
  320. def create_error_response(
  321. code: str,
  322. message: str,
  323. status_code: int = 400,
  324. details: Optional[Dict[str, Any]] = None
  325. ) -> tuple:
  326. """
  327. Create a standardized error response.
  328. Args:
  329. code: Error code
  330. message: Human-readable error message
  331. status_code: HTTP status code
  332. details: Additional error details
  333. Returns:
  334. Tuple of (response_json, status_code)
  335. """
  336. response = {
  337. 'error': {
  338. 'code': code,
  339. 'message': message,
  340. 'timestamp': datetime.now(timezone.utc).isoformat()
  341. }
  342. }
  343. if details:
  344. response['error']['details'] = details
  345. return jsonify(response), status_code
  346. # ==================== Error Handler Decorator ====================
  347. def handle_errors(func):
  348. """
  349. Decorator to handle errors in route handlers.
  350. Catches exceptions and converts them to appropriate error responses.
  351. Logs all errors with full context.
  352. Requirements:
  353. - 8.5: Gracefully handle critical errors without crashing
  354. """
  355. @wraps(func)
  356. def wrapper(*args, **kwargs):
  357. try:
  358. return func(*args, **kwargs)
  359. except AppError as e:
  360. # Log the error
  361. log_error(e, context={'handler': func.__name__})
  362. # Return error response
  363. return jsonify({'error': e.to_dict()}), e.status_code
  364. except Exception as e:
  365. # Log unexpected error
  366. log_error(e, context={'handler': func.__name__}, level='critical')
  367. # Return generic error response
  368. return create_error_response(
  369. code=ErrorCode.INTERNAL_ERROR,
  370. message='An unexpected error occurred',
  371. status_code=500
  372. )
  373. return wrapper
  374. # ==================== Global Error Handlers ====================
  375. def register_error_handlers(app):
  376. """
  377. Register global error handlers for the Flask app.
  378. Requirements:
  379. - 8.5: Gracefully handle critical errors without crashing
  380. """
  381. @app.errorhandler(AppError)
  382. def handle_app_error(error):
  383. """Handle custom application errors"""
  384. log_error(error)
  385. return jsonify({'error': error.to_dict()}), error.status_code
  386. @app.errorhandler(400)
  387. def handle_bad_request(error):
  388. """Handle 400 Bad Request errors"""
  389. log_entry = log_error(error, context={'type': 'bad_request'}, level='warning')
  390. return create_error_response(
  391. code=ErrorCode.VALIDATION_ERROR,
  392. message='Bad request',
  393. status_code=400,
  394. details={'description': str(error.description) if hasattr(error, 'description') else None}
  395. )
  396. @app.errorhandler(401)
  397. def handle_unauthorized(error):
  398. """Handle 401 Unauthorized errors"""
  399. log_entry = log_error(error, context={'type': 'unauthorized'}, level='warning')
  400. return create_error_response(
  401. code=ErrorCode.AUTHENTICATION_FAILED,
  402. message='Authentication required',
  403. status_code=401
  404. )
  405. @app.errorhandler(403)
  406. def handle_forbidden(error):
  407. """Handle 403 Forbidden errors"""
  408. log_entry = log_error(error, context={'type': 'forbidden'}, level='warning')
  409. return create_error_response(
  410. code=ErrorCode.ACCESS_DENIED,
  411. message='Access denied',
  412. status_code=403
  413. )
  414. @app.errorhandler(404)
  415. def handle_not_found(error):
  416. """Handle 404 Not Found errors"""
  417. log_entry = log_error(error, context={'type': 'not_found'}, level='warning')
  418. return create_error_response(
  419. code=ErrorCode.RESOURCE_NOT_FOUND,
  420. message='Resource not found',
  421. status_code=404
  422. )
  423. @app.errorhandler(405)
  424. def handle_method_not_allowed(error):
  425. """Handle 405 Method Not Allowed errors"""
  426. log_entry = log_error(error, context={'type': 'method_not_allowed'}, level='warning')
  427. return create_error_response(
  428. code='METHOD_NOT_ALLOWED',
  429. message='Method not allowed',
  430. status_code=405
  431. )
  432. @app.errorhandler(500)
  433. def handle_internal_error(error):
  434. """Handle 500 Internal Server errors"""
  435. log_entry = log_error(error, context={'type': 'internal_error'}, level='critical')
  436. return create_error_response(
  437. code=ErrorCode.INTERNAL_ERROR,
  438. message='An internal error occurred',
  439. status_code=500
  440. )
  441. @app.errorhandler(Exception)
  442. def handle_unexpected_error(error):
  443. """
  444. Handle any unexpected exceptions.
  445. Requirements:
  446. - 8.5: Gracefully handle critical errors without crashing
  447. """
  448. log_entry = log_error(error, context={'type': 'unexpected'}, level='critical')
  449. return create_error_response(
  450. code=ErrorCode.INTERNAL_ERROR,
  451. message='An unexpected error occurred',
  452. status_code=500
  453. )
  454. # ==================== Task Error Logging ====================
  455. class TaskErrorLogger:
  456. """
  457. Utility class for logging errors associated with tasks.
  458. Requirements:
  459. - 8.2: Record error details in task record
  460. - 8.3: Display error logs associated with task
  461. """
  462. @staticmethod
  463. def log_task_error(
  464. task_id: int,
  465. error: Exception,
  466. service: Optional[str] = None,
  467. region: Optional[str] = None,
  468. context: Optional[Dict[str, Any]] = None
  469. ) -> None:
  470. """
  471. Log an error for a specific task.
  472. Args:
  473. task_id: The task ID to associate the error with
  474. error: The exception that occurred
  475. service: Optional AWS service name
  476. region: Optional AWS region
  477. context: Additional context information
  478. """
  479. from app import db
  480. from app.models import TaskLog
  481. import json
  482. # Build error details
  483. details = {
  484. 'error_type': type(error).__name__,
  485. 'timestamp': datetime.now(timezone.utc).isoformat()
  486. }
  487. if service:
  488. details['service'] = service
  489. if region:
  490. details['region'] = region
  491. if context:
  492. details['context'] = context
  493. # Add stack trace
  494. if isinstance(error, AppError):
  495. details['code'] = error.code
  496. details['stack_trace'] = ''.join(error.stack_trace) if error.stack_trace else None
  497. else:
  498. details['stack_trace'] = traceback.format_exc()
  499. # Create task log entry
  500. log_entry = TaskLog(
  501. task_id=task_id,
  502. level='error',
  503. message=str(error),
  504. details=json.dumps(details)
  505. )
  506. db.session.add(log_entry)
  507. db.session.commit()
  508. # Also log to application logger
  509. log_error(error, context={'task_id': task_id, 'service': service, 'region': region})
  510. @staticmethod
  511. def log_task_warning(
  512. task_id: int,
  513. message: str,
  514. details: Optional[Dict[str, Any]] = None
  515. ) -> None:
  516. """Log a warning for a specific task"""
  517. from app import db
  518. from app.models import TaskLog
  519. import json
  520. log_entry = TaskLog(
  521. task_id=task_id,
  522. level='warning',
  523. message=message,
  524. details=json.dumps(details) if details else None
  525. )
  526. db.session.add(log_entry)
  527. db.session.commit()
  528. @staticmethod
  529. def log_task_info(
  530. task_id: int,
  531. message: str,
  532. details: Optional[Dict[str, Any]] = None
  533. ) -> None:
  534. """Log an info message for a specific task"""
  535. from app import db
  536. from app.models import TaskLog
  537. import json
  538. log_entry = TaskLog(
  539. task_id=task_id,
  540. level='info',
  541. message=message,
  542. details=json.dumps(details) if details else None
  543. )
  544. db.session.add(log_entry)
  545. db.session.commit()
  546. @staticmethod
  547. def get_task_errors(task_id: int) -> list:
  548. """
  549. Get all error logs for a specific task.
  550. Requirements:
  551. - 8.3: Display error logs associated with task
  552. """
  553. from app.models import TaskLog
  554. logs = TaskLog.query.filter_by(
  555. task_id=task_id,
  556. level='error'
  557. ).order_by(TaskLog.created_at.desc()).all()
  558. return [log.to_dict() for log in logs]
  559. @staticmethod
  560. def get_task_logs(
  561. task_id: int,
  562. level: Optional[str] = None,
  563. page: int = 1,
  564. page_size: int = 20
  565. ) -> Dict[str, Any]:
  566. """
  567. Get paginated logs for a specific task.
  568. Args:
  569. task_id: The task ID
  570. level: Optional filter by log level
  571. page: Page number
  572. page_size: Number of items per page
  573. Returns:
  574. Dictionary with logs and pagination info
  575. """
  576. from app.models import TaskLog
  577. query = TaskLog.query.filter_by(task_id=task_id)
  578. if level:
  579. query = query.filter_by(level=level)
  580. query = query.order_by(TaskLog.created_at.desc())
  581. # Paginate
  582. total = query.count()
  583. logs = query.offset((page - 1) * page_size).limit(page_size).all()
  584. return {
  585. 'data': [log.to_dict() for log in logs],
  586. 'pagination': {
  587. 'page': page,
  588. 'page_size': page_size,
  589. 'total': total,
  590. 'total_pages': (total + page_size - 1) // page_size
  591. }
  592. }