Files
StarPunk/starpunk/errors.py
Phil Skentelbery 93d2398c1d feat: Implement v1.1.1 Phase 1 - Core Infrastructure
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>
2025-11-25 13:56:30 -07:00

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