post image

Context-Aware FastAPI Responses: Adding Errors and Warnings with ContextVar

Nov 23, 2024 Sam Redai

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:

  1. Helpful to consumers: Something like, “Error 123: User quota exceeded.”
  2. 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:

  1. Use Python’s ContextVar to store request-specific data (errors and warnings).
  2. Add middleware to manage this context and attach it to responses.
  3. 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

Other Posts

post image
The Git-Backed UI: A Design Catastrophe Wrapped in Complexity

In software, simplicity is the key to good design. Users don’t need to struggle with complexity; they want tools that help them work, not get in the way. But tools like dbt Cloud, which should make data transformation easier, do the opposite. Instead of a simple process, they wrap users in layers of Git integration, adding confusion where there should be none.

post image
Introducing Sludge - A terminal UI for Slurm clusters

Whether it’s way back when I used to fumble through htop at work to figure out what’s happening with some on-prem server, or if it’s today when lazydocker and k9s have grown into critical tools that maximize my productivity, I’ve always been a huge fan of terminal UIs–especially those that are highly interactive.