Phase 1 of v1.1.1 "Polish" release focusing on production readiness. Implements logging, connection pooling, validation, and error handling. Following specs in docs/design/v1.1.1/developer-qa.md and ADRs 052-055. **Structured Logging** (Q3, ADR-054) - RotatingFileHandler (10MB files, keep 10) - Correlation IDs for request tracing - All print statements replaced with logging - Context-aware correlation IDs (init/request) - Logs written to data/logs/starpunk.log **Database Connection Pooling** (Q2, ADR-053) - Connection pool with configurable size (default: 5) - Request-scoped connections via Flask g object - Pool statistics for monitoring - WAL mode enabled for concurrency - Backward compatible get_db() signature **Configuration Validation** (Q14, ADR-052) - Validates presence and type of all config values - Fail-fast startup with clear error messages - LOG_LEVEL enum validation - Type checking for strings, integers, paths - Non-zero exit status on errors **Centralized Error Handling** (Q4, ADR-055) - Moved handlers to starpunk/errors.py - Micropub spec-compliant JSON errors - HTML templates for browser requests - All errors logged with correlation IDs - MicropubError exception class **Database Module Reorganization** - Moved database.py to database/ package - Separated init.py, pool.py, schema.py - Maintains backward compatibility - Cleaner separation of concerns **Testing** - 580 tests passing - 1 pre-existing flaky test noted - No breaking changes to public API **Documentation** - CHANGELOG.md updated with v1.1.1 entry - Version bumped to 1.1.1 - Implementation report in docs/reports/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
190 lines
6.4 KiB
Python
190 lines
6.4 KiB
Python
"""
|
|
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
|