Ever stared at a 500 Internal Server Error and thought, “This tells me everything I need to know”? Of course not. Unfriendly errors are the worst. The API consumer gets stuck wondering what went wrong, and you (the developer) end up sifting through logs trying to figure it out.
If you’re like me, you’ve seen all sorts of attempts to make errors and warnings in APIs more consumer-friendly. Maybe you’ve even implemented some yourself. A lot of the strategies out there seem solid on paper but fall apart in practice. Let’s start by looking at why that happens and why error handling so often feels like reinventing the wheel.
The Problem with Most Implementations
When something goes wrong in an API, you want to do better than just throwing an empty “500 Internal Server Error” at your user. Everyone wants rich error and warning responses that are:
- Helpful to consumers: Something like, “Error 123: User quota exceeded.”
- Informative for engineers: A way to log structured errors and warnings downstream for easier debugging.
Sounds simple, right? Well, there are so many ways it often goes wrong…
- Custom Exception Types with Exception Handlers: You define fancy exceptions for all your error cases and write a FastAPI
exception_handler
to convert them into responses. It works great for catching expected errors but often leaves your logs (and consumers) blind to the unexpected ones. - Hooking into the Logging Module: This is great for the backend, but it doesn’t always translate into something useful for consumers. Sure, you have logs with error details, but how often do those make it back into the API response?
- Conventions and Documentation: “Let’s document a standard for error responses!” It sounds like a dream until reality sets in. Engineers are human—they forget to follow conventions, and PR reviews don’t always catch the omissions.
- Manual Context Passing: Sometimes teams use a context object to accumulate errors and warnings. But developers are busy building features, and passing the context around is tedious. The deeper the call stack, the more likely someone forgets to include it.
So why do these always seem to be suboptimal, especially as a project grows? I would argue that it’s because they rely on developers to remember to do something manually—whether that’s throwing the right exception, passing the context object, or adding structured data to responses. And when the implementation fails, users get those dreaded black-box errors.
A Different Approach
I started debating this exact topic with a good friend who is also a software engineer. He recently started working on a new team developing a pretty new product
and we both sort of mulled over the fact that we always see these error-handling patterns in web services start with high hopes and eventually lose steam as the project grows.
Instead of relying on manual conventions, I wanted to experiment with something automatic: a system where errors and warnings are captured in the background and included in
API responses without the developer having to remember much. I also wanted this approach to be something that can be used anywhere in the codebase, even deep within the call stack,
without having to propagate any sort of “accumulator” instance.
Here’s the plan that started to form:
- Use Python’s
ContextVar
to store request-specific data (errors and warnings). - Add middleware to manage this context and attach it to responses.
- Provide helper functions (
add_error
and add_warning
) to make adding errors and warnings easy.
This way, developers don’t have to pass a context object around or worry about adding the accumulated context to the response because it’s all handled automatically. Let’s build it step by step.
Step 1: Defining Error and Warning Codes
We’ll start with a catalog of error and warning codes. These codes are the backbone of structured error handling because they make issues easy to identify.
from enum import IntEnum
class ErrorCode(IntEnum):
NOT_ENOUGH_BAGELS = 1000
class WarningCode(IntEnum):
IM_WARNING_YOU = 2000
This approach gives each error and warning a unique numeric code. For example, NOT_ENOUGH_BAGELS
is 1000
.
You’ll thank yourself later when you start playing with various filtered log aggregations to generate cool time series charts.
Step 2: Structuring Errors and Warnings
Next, let’s use Python’s dataclass
to define the structure of an error or warning. This ensures consistency and makes it easy to serialize these objects into responses.
from dataclasses import dataclass
@dataclass
class ErrorMessage:
code: ErrorCode
message: str
@dataclass
class WarningMessage:
code: WarningCode
message: str
Now we can create structured error messages like this:
ErrorMessage(code=ErrorCode.NOT_ENOUGH_BAGELS, message="Ran out of bagels!")
Step 3: Using ContextVar
to Store Errors and Warnings
Instead of passing a context object through every function, we’ll use Python’s ContextVar
. This allows us to store request-specific state without the hassle of manual context passing.
from contextvars import ContextVar
request_context: ContextVar = ContextVar(
"request_context", default={"errors": [], "warnings": []}
)
Each request gets its own isolated request_context
, which holds two lists: one for errors and one for warnings.
Step 4: Adding Middleware to Manage Context
Middleware will ensure that errors and warnings are included in responses automatically. No more having to rely
on each endpoint’s logic to accumulate the errors and warnings into the response!
from fastapi import Request, Response
def inject_context(response: Response):
context = request_context.get()
response.headers["Errors"] = json.dumps(context["errors"])
response.headers["Warnings"] = json.dumps(context["warnings"])
@app.middleware("http")
async def add_context_var(request: Request, call_next):
request_context.set({"errors": [], "warnings": []})
response = await call_next(request)
inject_context(response)
return response
Also, since FastAPI makes sure each request is handled within its specific coroutine, and each coroutine has its own isolated ContextVar
, you can rest assured
that the context is always tied to a single FastAPI request.
Step 5: Adding Helper Functions
To make adding errors and warnings easy, let’s define two helper functions:
from dataclasses import asdict
def add_error(code: ErrorCode, message: str):
context = request_context.get()
context["errors"].append(asdict(ErrorMessage(code=code, message=message)))
request_context.set(context)
def add_warning(code: WarningCode, message: str):
context = request_context.get()
context["warnings"].append(asdict(WarningMessage(code=code, message=message)))
request_context.set(context)
Now, instead of worrying about the ContextVar
, developers can call add_error
or add_warning
.
Step 6: Using It in an Endpoint
Finally, let’s use everything we’ve built in a FastAPI endpoint:
@app.get("/items/")
async def breakfast():
add_error(ErrorCode.NOT_ENOUGH_BAGELS, "Ran out of bagels!")
add_warning(WarningCode.IM_WARNING_YOU, "You better watch it...")
return {"items": ["Coffee", "Eggs", "Turkey Sausage"]}
This endpoint adds an error and a warning to the context. When you call it, those details will appear in the response headers.
Full Code
That’s it! Here’s the complete implementation:
from fastapi import FastAPI, Request, Response
from enum import IntEnum
from dataclasses import dataclass, asdict
from contextvars import ContextVar
import json
app = FastAPI()
# Step 1: Defining Error and Warning Codes
class ErrorCode(IntEnum):
NOT_ENOUGH_BAGELS = 1000
class WarningCode(IntEnum):
IM_WARNING_YOU = 2000
# Step 2: Structuring Errors and Warnings
@dataclass
class ErrorMessage:
code: ErrorCode
message: str
@dataclass
class WarningMessage:
code: WarningCode
message: str
# Step 3: Using ContextVar to Store Errors and Warnings
request_context: ContextVar = ContextVar(
"request_context", default={"errors": [], "warnings": []}
)
# Step 4: Adding Middleware to Manage Context
def inject_context(response: Response):
context = request_context.get()
response.headers["Errors"] = json.dumps(context["errors"])
response.headers["Warnings"] = json.dumps(context["warnings"])
@app.middleware("http")
async def add_context_var(request: Request, call_next):
request_context.set({"errors": [], "warnings": []})
response = await call_next(request)
inject_context(response)
return response
# Step 5: Adding Helper Functions
def add_error(code: ErrorCode, message: str):
context = request_context.get()
context["errors"].append(asdict(ErrorMessage(code=code, message=message)))
request_context.set(context)
def add_warning(code: WarningCode, message: str):
context = request_context.get()
context["warnings"].append(asdict(WarningMessage(code=code, message=message)))
request_context.set(context)
# Step 6: Using It in an Endpoint
@app.get("/items/")
async def breakfast():
add_error(ErrorCode.NOT_ENOUGH_BAGELS, "Ran out of bagels!")
add_warning(WarningCode.IM_WARNING_YOU, "You better watch it...")
return {"items": ["Coffee", "Eggs", "Turkey Sausage"]}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)
Start up the server and hit it with a request using curl verbosely.
curl -iv http://127.0.0.1:8000/items/
Check the response headers—you’ll see the errors and warnings neatly structured!
* Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> GET /items/ HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< date: Sun, 24 Nov 2024 03:09:47 GMT
date: Sun, 24 Nov 2024 03:09:47 GMT
< server: uvicorn
server: uvicorn
< content-length: 52
content-length: 52
< content-type: application/json
content-type: application/json
< errors: [{"code": 1000, "message": "Ran out of bagels!"}]
errors: [{"code": 1000, "message": "Ran out of bagels!"}]
< warnings: [{"code": 2000, "message": "You better watch it..."}]
warnings: [{"code": 2000, "message": "You better watch it..."}]
<
* Connection #0 to host 127.0.0.1 left intact
{"items":["Coffee","Eggs","Turkey Sausage"]}
You may also have noticed that the response is still a 200
, even though errors were included in the header. I wanted to keep
this blog post focused on application level errors/warnings but it of course logically follows that application level errors may,
and usually do, lead to http error responses. The mechanism to do this has largely been standardized in FastAPI applications using
the HTTPException class.
Using the http exception that’s handled by FastAPI’s standard exception handler in conjunction with the setup shown in this blog post,
you can do all sorts of cool things like have inject_context
automatically raise an HTTPException
whenever errors are found in the
context. This helps in situations where multiple errors have occurred, and you need to consolidate them into a single
response somehow. The response will contain a relatively generic error message, but the headers will include much richer information by providing
an array of errors and warnings.
Final Thoughts
This approach won’t solve all your API error-handling woes, but it makes a good case for automating the boring parts. No more
passing around context objects or forgetting to include error details in responses. Just clean, consistent error and warning
handling, all handled for you. Although I’ve had fun playing around with this over the past few days, I haven’t tried
this out in a project yet. If anyone beats me to it, please reach out and share how it worked out!
And hey, if nothing else, you’ll never forget to log when the bagels run out again.
-Sam