Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 19 additions & 23 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,28 @@
# Responsibilities:
# - Create the Flask app instance
# - Register the main Blueprint from routes/
# - Register error handlers
# - Register the global error boundary via errors/handlers.py
# - Start the development server when run directly
#
# Business logic, recommendation scoring, and data loading all live in
# the utils/ and routes/ packages, not here.

from flask import Flask, render_template
from flask import Flask
from routes.main_routes import main
from config import Config
from errors.handlers import register_error_handlers

app = Flask(__name__)

# Register all routes defined in the main Blueprint
app.register_blueprint(main)

# Register the global error boundary (handles 400, 403, 404, 405, 429, 500,
# and any unhandled Exception). Must be called after Blueprint registration
# so Blueprint-level error handlers take precedence where defined.
register_error_handlers(app)


@app.after_request
def add_security_headers(response):
"""Add basic security headers to all responses."""
Expand All @@ -30,31 +37,20 @@ def add_security_headers(response):
)
return response

# ---- Error handlers ----

@app.errorhandler(404)
def page_not_found(error):
"""Render a friendly 404 page instead of the raw Flask error."""
return render_template("404.html", config=Config), 404


@app.errorhandler(500)
# Expose the 500 handler at module level so existing tests can import it
# directly: from app import app, internal_server_error
def internal_server_error(error):
"""Render a friendly 500 page for unexpected server errors."""
return render_template("500.html", config=Config), 500

@app.errorhandler(405)
def method_not_allowed(error):
"""Render a friendly 405 page when the wrong HTTP method is used."""
return render_template("405.html", config=Config), 405

@app.errorhandler(403)
def forbidden(error):
"""Render a friendly 403 page when access is denied."""
return render_template("403.html", config=Config), 403
"""Proxy kept for backward compatibility with test_basic.py."""
from errors.handlers import internal_server_error as _handler
return _handler(error)


if __name__ == "__main__":
import os
debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() in ("true", "1")
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=debug_mode)
app.run(
host="0.0.0.0",
port=int(os.environ.get("PORT", 5000)),
debug=debug_mode,
)
1 change: 1 addition & 0 deletions errors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# errors/__init__.py
115 changes: 115 additions & 0 deletions errors/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# errors/handlers.py
# Global error handling for DevPath.
#
# All Flask error handlers live here so app.py stays lean.
# Each handler:
# 1. Logs the exception via utils/error_logger.py (server-side only).
# 2. Renders the matching HTML template with a safe, user-friendly message.
# 3. Never leaks stack traces, file paths, or internal state to the client.
#
# Register by calling register_error_handlers(app) from app.py.

from flask import Flask, render_template, request, jsonify
from config import Config
from utils.error_logger import log_exception


def _wants_json() -> bool:
"""Return True when the request prefers a JSON response.

API routes (prefixed /api/) always receive JSON error responses.
Browser requests receive the HTML error pages.
"""
if request.path.startswith("/api/"):
return True
best = request.accept_mimetypes.best_match(["application/json", "text/html"])
return best == "application/json"


def _json_error(status_code: int, message: str, correlation_id: str = ""):
"""Build a consistent JSON error envelope."""
body = {"error": message}
if correlation_id:
body["reference"] = correlation_id
return jsonify(body), status_code


# ---------------------------------------------------------------------------
# Standalone handler functions — importable directly for testing
# ---------------------------------------------------------------------------

def bad_request(error):
"""Handle 400 Bad Request."""
correlation_id = log_exception(error, status_code=400, context="bad_request")
if _wants_json():
return _json_error(400, "Bad request.", correlation_id)
return render_template("400.html", config=Config, reference=correlation_id), 400


def forbidden(error):
"""Handle 403 Forbidden."""
correlation_id = log_exception(error, status_code=403, context="forbidden")
if _wants_json():
return _json_error(403, "Access denied.", correlation_id)
return render_template("403.html", config=Config, reference=correlation_id), 403


def page_not_found(error):
"""Handle 404 Not Found."""
correlation_id = log_exception(error, status_code=404, context="page_not_found")
if _wants_json():
return _json_error(404, "The requested resource was not found.", correlation_id)
return render_template("404.html", config=Config, reference=correlation_id), 404


def method_not_allowed(error):
"""Handle 405 Method Not Allowed."""
correlation_id = log_exception(error, status_code=405, context="method_not_allowed")
if _wants_json():
return _json_error(405, "HTTP method not allowed.", correlation_id)
return render_template("405.html", config=Config, reference=correlation_id), 405


def too_many_requests(error):
"""Handle 429 Too Many Requests."""
correlation_id = log_exception(error, status_code=429, context="too_many_requests")
if _wants_json():
return _json_error(429, "Too many requests. Please slow down.", correlation_id)
return render_template("429.html", config=Config, reference=correlation_id), 429


def internal_server_error(error):
"""Handle 500 Internal Server Error."""
correlation_id = log_exception(error, status_code=500, context="internal_server_error")
if _wants_json():
return _json_error(500, "An unexpected error occurred.", correlation_id)
return render_template("500.html", config=Config, reference=correlation_id), 500


def unhandled_exception(error):
"""Catch-all for any Exception not matched by a specific handler."""
correlation_id = log_exception(error, status_code=500, context="unhandled_exception")
if _wants_json():
return _json_error(500, "An unexpected error occurred.", correlation_id)
return render_template("500.html", config=Config, reference=correlation_id), 500


# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------

def register_error_handlers(app: Flask) -> None:
"""Attach all global error handlers to the Flask application.

Call once during application initialisation:

from errors.handlers import register_error_handlers
register_error_handlers(app)
"""
app.register_error_handler(400, bad_request)
app.register_error_handler(403, forbidden)
app.register_error_handler(404, page_not_found)
app.register_error_handler(405, method_not_allowed)
app.register_error_handler(429, too_many_requests)
app.register_error_handler(500, internal_server_error)
app.register_error_handler(Exception, unhandled_exception)
38 changes: 38 additions & 0 deletions templates/400.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bad Request — DevPath</title>
<meta name="robots" content="noindex" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<meta property="og:title" content="Bad Request — DevPath" />
<meta property="og:description" content="The request could not be understood by the server." />
<meta property="og:image" content="{{ config.get_og_image_url() }}" />
<meta property="og:url" content="{{ config.get_base_url() }}/" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="stylesheet" href="/static/style.css" />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=Inter:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<nav class="navbar">
<div class="nav-inner">
<a href="/" class="nav-logo">DevPath</a>
{% include 'partials/theme_toggle.html' %}
</div>
</nav>
<div class="error-page">
<div class="error-page-inner">
<div class="error-code">400</div>
<h1>Bad Request</h1>
<p>The request could not be understood by the server. Please check your input and try again.</p>
{% if reference %}
<p class="error-reference">Reference: <code>{{ reference }}</code></p>
{% endif %}
<a href="/" class="btn-primary" style="display:inline-block;text-decoration:none;padding:14px 32px;">Back to Home</a>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>
38 changes: 38 additions & 0 deletions templates/429.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Too Many Requests — DevPath</title>
<meta name="robots" content="noindex" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
<meta property="og:title" content="Too Many Requests — DevPath" />
<meta property="og:description" content="You have made too many requests. Please wait a moment before trying again." />
<meta property="og:image" content="{{ config.get_og_image_url() }}" />
<meta property="og:url" content="{{ config.get_base_url() }}/" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="stylesheet" href="/static/style.css" />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=Inter:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<nav class="navbar">
<div class="nav-inner">
<a href="/" class="nav-logo">DevPath</a>
{% include 'partials/theme_toggle.html' %}
</div>
</nav>
<div class="error-page">
<div class="error-page-inner">
<div class="error-code">429</div>
<h1>Too Many Requests</h1>
<p>You have sent too many requests in a short period. Please wait a moment before trying again.</p>
{% if reference %}
<p class="error-reference">Reference: <code>{{ reference }}</code></p>
{% endif %}
<a href="/" class="btn-primary" style="display:inline-block;text-decoration:none;padding:14px 32px;">Back to Home</a>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>
Loading
Loading