How I Fix Flask Global 404 Handler for API and HTML Routes

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 pointrequest.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

  1. Add POST /tasks – Let the API accept a new task (validate that "title" is present, return 400 JSON on bad input).
  2. Use Blueprints – Split /gnodes/api into its own Blueprint and attach a local error handler, isolating it completely.
  3. Log 404s – Record bad paths to a file so you can see what people (or scripts) keep hitting.
  4. Version Header Check – If the client sends Accept: application/json, return JSON regardless of path; otherwise serve HTML.
  5. 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

ChallengeWhy It’s Useful
POST /gnodes/api/v1.0/tasksLearn request validation and error 400 handling.
Move API into a BlueprintKeeps bigger projects tidy and lets you attach per‑Blueprint error handlers.
Log 404s to a fileSpot bots or broken links hammering your server.
Respect the Accept headerMakes your app kinder to AJAX calls that prefer JSON anywhere.
Write pytest testsAutomate 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.

Related blog posts