I run a mini‑web server at home. It shows a plain web page at /
and serves JSON under /gnodes/api/v1.0/*
. At first glance the code looked fine until every missing page (even my HTML ones) started spitting out JSON. Below is the exact code, the mistake that caused it, the fix, and a few small extras you can try to stretch your skills.
Error Code
from flask import Flask, jsonify, abort, make_response, render_template, request
app = Flask(__name__)
tasks = [
{"id": 1, "title": "Buy milk"},
{"id": 2, "title": "Read GPT blog"},
]
# ---------- Web UI ----------
@app.route("/")
def index():
return render_template("index.html")
# ---------- REST API ----------
@app.route("/gnodes/api/v1.0/tasks", methods=["GET"])
def get_tasks():
return jsonify({"tasks": tasks})
@app.route("/gnodes/api/v1.0/tasks/<int:task_id>", methods=["GET"])
def get_task(task_id):
task = [t for t in tasks if t["id"] == task_id]
if not task:
abort(404) # ← Problem shows up here
return jsonify({"task": task[0]})
# ---------- Global error handler ----------
@app.errorhandler(404)
def not_found(error):
return make_response(jsonify({"error": "Not found"}), 404)
What Went Wrong
- Symptom – A bad URL on the HTML side (
/does‑not‑exist
) still returns JSON:{"error": "Not found"}
- Cause – The
@app.errorhandler(404)
decorator is global. It grabs every 404 in the app, not just the API ones. - Why It’s Confusing – Inside
get_task()
we do want JSON, but outside the API we expect the browser‑friendly template (or Flask’s default “Not Found” page).
The Clean Fix Conditional Error Handler
You don’t need a second Flask app. One neat check in the handler sorts it out:
@app.errorhandler(404)
def not_found(error):
# If the request path starts with the API prefix, return JSON
if request.path.startswith("/gnodes/api/"):
return make_response(jsonify({"error": "Not found"}), 404)
# Otherwise serve an HTML page (or let Flask’s default show)
return render_template("404.html"), 404
Key point – request.path
is available inside any error handler, so you can branch on it. No Blueprints required (though Blueprints work too if your project gets bigger).
Extra Practice Ideas
- Add POST /tasks – Let the API accept a new task (validate that
"title"
is present, return400
JSON on bad input). - Use Blueprints – Split
/gnodes/api
into its own Blueprint and attach a local error handler, isolating it completely. - Log 404s – Record bad paths to a file so you can see what people (or scripts) keep hitting.
- Version Header Check – If the client sends
Accept: application/json
, return JSON regardless of path; otherwise serve HTML. - Unit Tests – Write pytest functions to assert that
/foo
gives HTML while/gnodes/api/v1.0/42
(missing) returns JSON.
Correct Code
flask import (
Flask, jsonify, abort, make_response,
render_template, request
)
app = Flask(__name__)
API_PREFIX = "/gnodes/api/v1.0"
tasks = [
{"id": 1, "title": "Buy milk"},
{"id": 2, "title": "Read GPT blog"},
]
# ---------- Web UI ----------
@app.route("/")
def index():
return render_template("index.html")
# ---------- REST API ----------
@app.route(f"{API_PREFIX}/tasks", methods=["GET"])
def get_tasks():
return jsonify({"tasks": tasks})
@app.route(f"{API_PREFIX}/tasks/<int:task_id>", methods=["GET"])
def get_task(task_id):
task = [t for t in tasks if t["id"] == task_id]
if not task:
abort(404)
return jsonify({"task": task[0]})
# ---------- 404 handler (HTML vs JSON) ----------
@app.errorhandler(404)
def not_found(error):
wants_json = (
request.path.startswith(API_PREFIX)
or request.headers.get("Accept") == "application/json"
)
if wants_json:
return make_response(jsonify({"error": "Not found"}), 404)
return render_template("404.html"), 404
# ---------- Run ----------
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
Stretch Goals
Challenge | Why It’s Useful |
---|---|
POST /gnodes/api/v1.0/tasks | Learn request validation and error 400 handling. |
Move API into a Blueprint | Keeps bigger projects tidy and lets you attach per‑Blueprint error handlers. |
Log 404s to a file | Spot bots or broken links hammering your server. |
Respect the Accept header | Makes your app kinder to AJAX calls that prefer JSON anywhere. |
Write pytest tests | Automate checks so the bug never sneaks back. |
Final Thought
A single @errorhandler
can feel like a blunt instrument, but a quick request.path
(or header) check turns it into a scalpel. Try the practice tweaks above each one teaches a tiny new Flask trick without blowing up the whole project.