Skip to content
Chris Zhan
Go back

An invalid timezone shouldn't be a 500: FastAPI + ZoneInfo

A single user-supplied query parameter was turning an ordinary bad-input case into a 500 Internal Server Error. The endpoint wasn’t broken — the input was — but the status code said otherwise.

Here’s what happened, why the status code mattered more than the impact, and the small wrapper that fixes it.

What broke

The service had an endpoint that accepted a tz query parameter — a timezone name like Asia/Taipei — and used it to localize some timestamps before returning them. The handler did the obvious thing:

from zoneinfo import ZoneInfo

tz = ZoneInfo(tz_name)  # tz_name comes straight from the query string

That works fine for any real zone name. The problem is what happens when it isn’t one.

GET /api/brief?tz=foo  ->  500 Internal Server Error

ZoneInfo("foo") raises ZoneInfoNotFoundError: 'No time zone found with key foo'. Nothing in the handler caught it, so it propagated all the way up to the ASGI layer, and FastAPI did what it does with any uncaught exception: returned a 500.

(Examples here are from Python 3.14; the exact error strings have shifted slightly across versions, but the behaviour and the fix are the same.)

This one never reached production. It surfaced while I was building a small /api/brief endpoint for a personal daily-brief service, during a security-review pass I run over pull requests. The review flagged that the timezone helper handed the raw tz value straight to ZoneInfo() with nothing catching the failure — so any unknown timezone would 500. Cheap to fix in review; annoying to discover from a 5xx graph later.

Why a 500 is the wrong answer

The timezone string is user-supplied input. When a caller hands you tz=foo, they made the mistake — there is no real timezone by that name. That’s the textbook definition of a 400 Bad Request: the server is fine, the request is malformed, and the caller needs to fix their input and try again.

A 500 says the opposite. It says the server failed — something on my side is broken, the caller did nothing wrong, and there’s nothing they can do but wait for me to fix it. That’s not just cosmetically wrong, it’s operationally wrong:

Even if it had shipped, the route was intranet-only, so the real-world blast radius would have been small. But “low impact” and “wrong” are different axes, and the semantic correctness is what makes the pattern worth fixing — and worth writing down.

The root cause, and a sharp edge worth knowing

The mechanical cause is just an unhandled exception. But there’s a detail about ZoneInfoNotFoundError that’s easy to trip over:

>>> from zoneinfo import ZoneInfoNotFoundError
>>> ZoneInfoNotFoundError.__mro__
(<class 'zoneinfo.ZoneInfoNotFoundError'>, <class 'KeyError'>, <class 'LookupError'>, <class 'Exception'>, ...)

ZoneInfoNotFoundError subclasses KeyError. That cuts both ways:

The fix

Wrap the construction and translate the failure into an explicit 400:

from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException

try:
    tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError as e:
    raise HTTPException(
        status_code=400,
        detail=f"Unknown timezone: {tz_name}",
    ) from e

The only judgment call here is not leaving this inline in one handler. Any service that takes a tz param in one place tends to take it in several. So instead of a bare ZoneInfo() in the route, I put the parsing in a tiny helper:

# tz_utils.py
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException

def parse_tz(name: str) -> ZoneInfo:
    try:
        return ZoneInfo(name)
    except ZoneInfoNotFoundError as e:
        raise HTTPException(
            status_code=400,
            detail=f"Unknown timezone: {name}",
        ) from e

Now any route that needs a timezone calls parse_tz(tz_name), and the correct status code is guaranteed by construction — not by remembering to add a try/except each time. The next endpoint that takes a tz inherits the right behaviour for free.

A nice bonus: ZoneInfo resists path traversal

One reassuring thing came out of the same review. Because tz_name is interpolated into a lookup that ultimately maps to a file under the tz database, an obvious next question is: can someone send tz=../../etc/passwd and read arbitrary files?

No. ZoneInfo validates the key and raises rather than walking the filesystem:

>>> ZoneInfo("../etc/passwd")
ValueError: ZoneInfo keys must refer to subdirectories of TZPATH, got: ../etc/passwd

So the parameter was never a file-read vector. The only real gap was the status code — which is a good reminder that “is this exploitable?” and “is this correct?” are two separate reviews, and a thing can pass the first while failing the second.

The takeaway

The general rule I now apply: any time untrusted input feeds a constructor or parser that can raise, decide the status code on purpose. Don’t let an uncaught exception pick it for you — the default is always 500, and the default is usually wrong for bad input. Catch it at the boundary, translate it to the status the caller needs to see, and if more than one route does the same parse, make the correct behavior a helper so nobody has to remember it.

It’s four lines, but they keep your 5xx rate meaningful — the difference between an alert you trust and one you learn to ignore.


Share this post: