""" Centralized error handling for StarPunk Per ADR-055 and developer Q&A Q4: - Uses Flask's @app.errorhandler decorator - Registered in app factory (centralized) - Micropub endpoints return spec-compliant JSON errors - Other endpoints return HTML error pages - All errors logged with correlation IDs """ from flask import request, render_template, jsonify, g def register_error_handlers(app): """ Register centralized error handlers Checks request path to determine response format: - /micropub/* returns JSON (Micropub spec compliance) - All others return HTML templates All errors are logged with correlation IDs for tracing Args: app: Flask application instance """ @app.errorhandler(400) def bad_request(error): """Handle 400 Bad Request errors""" correlation_id = getattr(g, 'correlation_id', 'no-request') app.logger.warning(f"Bad request: {error}") if request.path.startswith('/micropub'): # Micropub spec-compliant error response return jsonify({ 'error': 'invalid_request', 'error_description': str(error) or 'Bad request' }), 400 return render_template('400.html', error=error), 400 @app.errorhandler(401) def unauthorized(error): """Handle 401 Unauthorized errors""" correlation_id = getattr(g, 'correlation_id', 'no-request') app.logger.warning(f"Unauthorized access attempt") if request.path.startswith('/micropub'): # Micropub spec-compliant error response return jsonify({ 'error': 'unauthorized', 'error_description': 'Authentication required' }), 401 return render_template('401.html'), 401 @app.errorhandler(403) def forbidden(error): """Handle 403 Forbidden errors""" correlation_id = getattr(g, 'correlation_id', 'no-request') app.logger.warning(f"Forbidden access attempt") if request.path.startswith('/micropub'): # Micropub spec-compliant error response return jsonify({ 'error': 'forbidden', 'error_description': 'Insufficient scope or permissions' }), 403 return render_template('403.html'), 403 @app.errorhandler(404) def not_found(error): """Handle 404 Not Found errors""" # Don't log 404s at warning level - they're common and not errors app.logger.debug(f"Resource not found: {request.path}") if request.path.startswith('/api/') or request.path.startswith('/micropub'): return jsonify({'error': 'Not found'}), 404 return render_template('404.html'), 404 @app.errorhandler(405) def method_not_allowed(error): """Handle 405 Method Not Allowed errors""" correlation_id = getattr(g, 'correlation_id', 'no-request') app.logger.warning(f"Method not allowed: {request.method} {request.path}") if request.path.startswith('/micropub'): return jsonify({ 'error': 'invalid_request', 'error_description': f'Method {request.method} not allowed' }), 405 return render_template('405.html'), 405 @app.errorhandler(500) def internal_server_error(error): """Handle 500 Internal Server Error""" correlation_id = getattr(g, 'correlation_id', 'no-request') app.logger.error(f"Internal server error: {error}", exc_info=True) if request.path.startswith('/api/') or request.path.startswith('/micropub'): # Don't expose internal error details in API responses if request.path.startswith('/micropub'): return jsonify({ 'error': 'server_error', 'error_description': 'An internal server error occurred' }), 500 else: return jsonify({'error': 'Internal server error'}), 500 return render_template('500.html'), 500 @app.errorhandler(503) def service_unavailable(error): """Handle 503 Service Unavailable errors""" correlation_id = getattr(g, 'correlation_id', 'no-request') app.logger.error(f"Service unavailable: {error}") if request.path.startswith('/api/') or request.path.startswith('/micropub'): return jsonify({ 'error': 'temporarily_unavailable', 'error_description': 'Service temporarily unavailable' }), 503 return render_template('503.html'), 503 # Register generic exception handler @app.errorhandler(Exception) def handle_exception(error): """ Handle uncaught exceptions Logs the full exception with correlation ID and returns appropriate error response """ correlation_id = getattr(g, 'correlation_id', 'no-request') app.logger.error(f"Uncaught exception: {error}", exc_info=True) # If it's an HTTP exception, let Flask handle it if hasattr(error, 'code'): return error # Otherwise, return 500 if request.path.startswith('/micropub'): return jsonify({ 'error': 'server_error', 'error_description': 'An unexpected error occurred' }), 500 elif request.path.startswith('/api/'): return jsonify({'error': 'Internal server error'}), 500 else: return render_template('500.html'), 500 class MicropubError(Exception): """ Micropub-specific error class Automatically formats errors according to Micropub spec """ def __init__(self, error_code, description, status_code=400): """ Initialize Micropub error Args: error_code: Micropub error code (e.g., 'invalid_request', 'insufficient_scope') description: Human-readable error description status_code: HTTP status code (default 400) """ self.error_code = error_code self.description = description self.status_code = status_code super().__init__(description) def to_response(self): """ Convert to Micropub-compliant JSON response Returns: tuple: (dict, int) Flask response tuple """ return jsonify({ 'error': self.error_code, 'error_description': self.description }), self.status_code