Flask API Fundamentals · Structure and Error Handling
Error Responses That Help
Return consistent JSON errors from Flask with one error shape, HTTPException and custom handlers, and the right status codes.
Errors are part of your API. A client that gets {"error": "validation failed", "fields": {"email": "already registered"}} can show the user exactly what to fix. A client that gets an HTML page saying "500 Internal Server Error" can only give up. This lesson shows how to make every error in a Flask API come back as consistent JSON.
Pick one error shape
Decide on a single shape and use it everywhere, so clients need one parser:
{
"error": "validation_failed",
"message": "Some fields are invalid.",
"fields": {"email": "already registered"}
}
erroris a stable code that clients can check in anifstatement.messageis for people and can change wording later.fieldsappears only for validation errors.
If you'd rather follow a standard, RFC 9457 "Problem Details" defines a similar shape (type, title, status, detail) with the application/problem+json content type.
Turn Flask's HTTP errors into JSON
By default abort(404) and unknown URLs return an HTML page. One handler for HTTPException converts all of them, including 404, 405 and 415, into your JSON shape:
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
@app.errorhandler(HTTPException)
def http_error(e):
code = e.name.lower().replace(" ", "_")
return jsonify(error=code, message=e.description), e.code
Now GET /nope returns {"error": "not_found", ...} with status 404.
Raise your own errors
For errors that come from your business rules, define one exception class and raise it from anywhere in the code. A single handler turns it into a response:
class APIError(Exception):
def __init__(self, status, error, message, fields=None):
super().__init__(message)
self.status, self.error, self.message, self.fields = status, error, message, fields
@app.errorhandler(APIError)
def api_error(e):
body = {"error": e.error, "message": e.message}
if e.fields:
body["fields"] = e.fields
return jsonify(body), e.status
@app.post("/users")
def create_user():
raise APIError(409, "email_taken", "That email is already registered.", {"email": "already registered"})
Handlers stay short: they raise and move on, and the response format lives in one place. The same idea in plain Python, so you can run it:
class APIError(Exception):
def __init__(self, status, error, message, fields=None):
super().__init__(message)
self.status, self.error, self.message, self.fields = status, error, message, fields
def to_response(exc):
if isinstance(exc, APIError):
body = {"error": exc.error, "message": exc.message}
if exc.fields:
body["fields"] = exc.fields
return exc.status, body
# Anything unexpected: log it on the server, but never send the details to the client.
return 500, {"error": "internal_error", "message": "Something went wrong. Please try again."}
errors = [
APIError(404, "user_not_found", "No user with id 7."),
APIError(422, "validation_failed", "Some fields are invalid.", {"email": "must contain @"}),
KeyError("password_hash"),
]
for exc in errors:
print(to_response(exc))
Output
(404, {'error': 'user_not_found', 'message': 'No user with id 7.'})
(422, {'error': 'validation_failed', 'message': 'Some fields are invalid.', 'fields': {'email': 'must contain @'}})
(500, {'error': 'internal_error', 'message': 'Something went wrong. Please try again.'})Don't leak details on 500
When something unexpected fails, log the full stack trace on the server and send the client a generic message. Stack traces reveal file paths, library versions and sometimes data:
@app.errorhandler(Exception)
def unexpected_error(e):
if isinstance(e, HTTPException):
return http_error(e)
app.logger.exception("Unhandled error")
return jsonify(error="internal_error", message="Something went wrong. Please try again."), 500
Choose the right status code
| Status | Meaning for the client |
|---|---|
400 |
The request can't be read (broken JSON, wrong type of body) |
401 |
Not signed in, or the token is missing or expired |
403 |
Signed in, but not allowed to do this |
404 |
The resource doesn't exist |
409 |
Conflicts with the current state, such as a duplicate email |
422 |
Readable, but the values are invalid |
429 |
Too many requests; add a Retry-After header |
500 |
A bug or failure on the server; the client did nothing wrong |
The difference between 401 and 403 matters: 401 tells the client to sign in again, 403 tells it that signing in again won't help.
Common mistakes
- Returning
200with{"success": false}. Caches, retries and monitoring all read the status code, so they treat this as success. - Different shapes per endpoint. One endpoint returns
{"error": ...}, another{"message": ...}, a third a plain string. Clients end up with special cases everywhere. - Leaking exception text.
str(e)from a database error can include SQL and table names. Log it; don't return it. - Using messages as codes. If clients match on
message, fixing a typo breaks them. Give them a stableerrorcode instead.
Try it
Add the HTTPException handler to an app, then request a URL that doesn't exist and send a DELETE to a route that only allows GET. Check that both return JSON with 404 and 405.
Open in the app to mark lessons complete, take notes and track your progress.