DEV Community

Cover image for FastAPI for AI Engineers - Part 9: Mastering HTTP Exceptions & Status Codes
Ananya S
Ananya S

Posted on Edited on AI-assisted

FastAPI for AI Engineers - Part 9: Mastering HTTP Exceptions & Status Codes

If you have been following this FastAPI series, we have built CRUD operations, connected to a database, and secured our endpoints with JWTs. But what happens when things go wrong?

When a user searches for an item that doesn't exist, or tries to access a route without a token, your API needs to communicate the exact problem. Returning a generic 500 Internal Server Error or a standard 200 OK with an error message in the body is a bad practice.

Today, we are going to look at the most common HTTP status codes and how to raise them cleanly using FastAPI's HTTPException.

The Core Status Codes You Need to Know

HTTP status codes are grouped by their starting number.

  • 2xx (Success): Everything worked perfectly.

  • 4xx (Client Error): The user did something wrong (bad data, missing token, wrong URL).

  • 5xx (Server Error): Your code broke.

Image of 500 Server Error as code didn't work

Here are the four most critical 4xx errors you will use in FastAPI:

1. 404 Not Found

Use this when a requested resource does not exist in your database.

from fastapi import FastAPI, HTTPException

app = FastAPI()

fake_db = {"1": "Apple", "2": "Banana"}

@app.get("/items/{item_id}")
def get_item(item_id: str):
    if item_id not in fake_db:
        raise HTTPException(
            status_code=404, 
            detail=f"Item with ID {item_id} does not exist."
        )
    return {"item": fake_db[item_id]}
Enter fullscreen mode Exit fullscreen mode

Item not found

2. 400 Bad Request

Use this when the user sends a request that is syntactically correct but fails a business logic rule.

Note: If you use Pydantic models (like we covered in Part 4), FastAPI automatically handles basic validation errors (like ensuring an amount is a positive number) and throws a 422 Unprocessable Entity. You should reserve 400 for business rules your schema can't easily express, like checking an account balance.


from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI()

# Mock database of account balances
account_balances = {"account_abc": 100}

class TransferRequest(BaseModel):
    account_id: str
    # Pydantic handles the > 0 check automatically, returning a 422 if it fails
    amount: int = Field(gt=0) 

@app.post("/transfer")
def transfer_funds(request: TransferRequest):
    current_balance = account_balances.get(request.account_id, 0)

    # We use a 400 here for business logic that Pydantic cannot know about
    if request.amount > current_balance:
        raise HTTPException(
            status_code=400, 
            detail="Insufficient funds for this transfer."
        )

    account_balances[request.account_id] -= request.amount
    return {
        "message": "Transfer successful", 
        "remaining_balance": account_balances[request.account_id]
    }
Enter fullscreen mode Exit fullscreen mode

This is the 400 error, when we request an amount greater than what is present in our account balance.

Adding amount more than 100

Image of the error

This is 422 error, when we add integers where strings should be there.

422 error image

3. 401 Unauthorized (Authentication)

This is the error you throw when someone tries to access a protected route without a JWT token, or with an expired/invalid token. Think of this as: "I don't know who you are."

from fastapi import Header

@app.get("/profile")
def get_profile(authorization: str = Header(default=None)):
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(
            status_code=401, 
            detail="Missing or invalid authentication token",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return {"user": "ZeroShotAnu"}
Enter fullscreen mode Exit fullscreen mode

Authentication Error

4. 403 Forbidden (Authorization)

This is often confused with 401. You use 403 Forbidden when you know who the user is (their token is valid), but they do not have the permission to do what they are trying to do. Think of this as: "I know who you are, but you aren't an Admin."


@app.delete("/users/{user_id}")
def delete_user(user_id: str, current_user_role: str = "guest"):
    # The user is logged in, but they are just a guest
    if current_user_role != "admin":
        raise HTTPException(
            status_code=403, 
            detail="You do not have permission to delete users."
        )
    return {"message": "User deleted"}
Enter fullscreen mode Exit fullscreen mode

Another example I tried here is, we already have this user existing and hence it is forbidden to create the same user again.

Creating a new student - that already exists

Error

Error Image

So when I create a real new student,

Actual new student image

it shows a success response:

Success response Image

Showing 403 VS 200

🛡️ Security Pro-Tip: The 404 Fallback
If a user tries to access a specific private resource (like someone else's billing record), returning a 403 confirms that the record actually exists. To prevent attackers from guessing valid IDs in your database, highly secure APIs will often return a 404 Not Found instead, keeping the attacker entirely in the dark.

fake_db = {"invoice_88": {"owner": "Alice", "amount": 100}}

@app.get("/invoices/{invoice_id}")
def get_private_invoice(invoice_id: str, current_user: str = "Bob"):
    invoice = fake_db.get(invoice_id)

    # Hide both "doesn't exist" and "unauthorized" behind the same 404 error
    if not invoice or invoice["owner"] != current_user:
        raise HTTPException(
            status_code=404, 
            detail="Invoice not found."
        )
    return invoice
Enter fullscreen mode Exit fullscreen mode

Image of adding a wrong owner to access db

Image of 404 error

Why This Matters

Proper status codes make your API predictable. When a frontend developer (or an AI Agent) interacts with your API, they don't read the text in your detail message first; their code looks at the status_code to decide what to do next.

If they see a 401, they redirect the user to the login screen.

If they see a 404, they show a "Page Not Found" illustration.

The next post will be the last post in this series, we will learn about APIRouter and Background tasks. Hope you enjoyed this series.
Do ask any questions you have in the comments below.

Top comments (1)

Collapse
 
arhancanli profile image
Arhan Canli •

One thing worth testing in the /transfer example: with def transfer_funds(amount: int) as a bare parameter, FastAPI treats amount as a query parameter, not a JSON field. I ran it with the test client: POST /transfer?amount=5 returns 200, POST /transfer with body {"amount": 5} returns 422 (missing query param), ?amount=abc returns 422, and ?amount=-1 reaches your 400. So a client sending JSON, which is what most people expect from a money endpoint, never gets to the business rule.

The usual fix is a Pydantic body model with amount: int = Field(gt=0), which turns the <= 0 check into a 422 by itself and leaves 400 for rules the schema can't express (insufficient balance, say; many APIs use 409 or 422 there).

When you cover 403 next: for resources a user may not see, many APIs return 404 instead, so the status code doesn't confirm that an ID exists. Do you plan to cover that choice in this series?