Skip to content

Recommend wrapping app directly for 500 error handling - #114

Open
sebastian-correa wants to merge 2 commits into
snok:mainfrom
sebastian-correa:feature/better-default-500-handling
Open

sebastian-correa wants to merge 2 commits into
snok:mainfrom
sebastian-correa:feature/better-default-500-handling

Conversation

@sebastian-correa

@sebastian-correa sebastian-correa commented Sep 25, 2026 •

Copy link
Copy Markdown

Summary

Type-check CorrelationIdMiddleware as returning the wrapped app's own type via a TYPE_CHECKING-only __new__ override, so wrapping e.g. a FastAPI instance no longer widens its type. Update README to recommend app = CorrelationIdMiddleware(app) over add_middleware, since it also includes the X-Request-ID header on unhandled 500 responses without a custom exception handler.

I added a test to ensure the signatures ofthe TYPE_CHECKING-only declaration and the real runtime declaration stay in sync.

Fixes the issues posed in #109 and #104.

Problem

PR #109 recommends wrapping the app directly instead of using add_middleware, since that's the only way to get X-Request-ID on unhandled 500 responses:

app = FastAPI()
app = CorrelationIdMiddleware(app)  # instead of app.add_middleware(CorrelationIdMiddleware)

However, CorrelationIdMiddleware is a @dataclass whose __init__ takes and returns ASGIApp. Static type checkers therefore widen app's type from FastAPI to the middleware's own type, breaking @app.get(...), dependency injection, and any other tooling that relies on app staying FastAPI.

We need a way to satisfy both:

  1. app's static type must remain unchanged after wrapping.
  2. Request IDs should be attachable to 500 responses without requiring extra user code.

Solution

Expose CorrelationIdMiddleware to type checkers as a TYPE_CHECKING-only class whose __new__ is annotated to return the same type it was given (via a TypeVar bound to ASGIApp), instead of the usual Self.

At runtime, CorrelationIdMiddleware is just an alias for the real dataclass (renamed _CorrelationIdMiddleware). Therefore, its behavior, add_middleware() compatibility, and the public API are all unchanged. Only static analysis sees the shim.

The README now recommends app = CorrelationIdMiddleware(app) as the primary pattern (it also solves the 500-response header problem for free), while add_middleware(...) remains documented as a supported alternative for users who don't need 500 coverage.

Testing performed

All checks below were run against the actual installed package (not just isolated snippets), using ty, pyright, and mypy as three independent type checkers, plus the existing test suite.

Type preservation

app = FastAPI()
app = CorrelationIdMiddleware(app)
reveal_type(app)  # -> FastAPI, under both `ty` and `pyright`

Confirmed no reportAttributeAccessIssue on app.get(...) afterward.

Existing registration path unaffected

Middleware(CorrelationIdMiddleware, header_name="X-Foo") (used by add_middleware() and the test suite) still type-checks.

isinstance / variable annotations

We also want isinstance checks, and inline annotations to work, so I tested that

m: CorrelationIdMiddleware                    # error: Expected class but received "(app, ...) -> ..."
isinstance(app, CorrelationIdMiddleware)      # error: not assignable to "_ClassInfo"

works with pyright, ty, and mypy.

Runtime behavior / full test suite

uv run coverage run -m pytest tests   # 36 passed
uvx ty check                          # All checks passed
uv run ruff check asgi_correlation_id # All checks passed
uv run ruff format --check ...        # 6 files already formatted

Known issues

mypy (only) reports an error on the stub's own definition:

middleware.py:X: error: "__new__" must return a class instance (got "_AppType")  [misc]

I verified this does not leak to downstream consumers: I installed the package into an isolated venv and ran mypy (including --strict) against a script that I wrote to mimic a user's usage (had the line app = CorrelationIdMiddleware(app)) and I got zero errors, and reveal_type correctly showed FastAPI.

mypy doesn't re-check/re-report errors inside already-installed dependencies unless you point it directly at their source, which means users won't get bother with this. Furthermore, since this repo's CI only runs ty check, this doesn't affect the project's own CI either.

In all, contributors who run mypy directly against middleware.py would see one [misc] warning on the stub definition. No consumer of the published package and no CI check in this repo is affected.

Discarded alternatives

  1. from_app classmethod typed -> FastAPI.
    Adds a second, redundant construction path (CorrelationIdMiddleware(app) vs .from_app(app)).

  2. Auto-registering a 500 exception handler in __post_init__.
    Starlette's Starlette.build_middleware_stack() uses add_middleware(...), where self.app is not the FastAPI/Starlette instance; it's the next middleware in the stack or the router.

  3. Plain TYPE_CHECKING function stub (def CorrelationIdMiddleware(app: _AppType, ...) -> _AppType).
    Worked for the main use case, but broke isinstance() and type annotations against the middleware.

  4. Subclassing the dataclass and overriding only __new__(cls, app, *args: Any, **kwargs: Any) -> _AppType, to avoid manually mirroring every dataclass field in the stub signature.
    Verified with ty, pyright, and mypy that *args: Any, **kwargs: Any silently disables type-checking for every other keyword argument.

@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.69%. Comparing base (76b61b6) to head (68440f0).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #114      +/-   ##
==========================================
+ Coverage   94.44%   94.69%   +0.25%     
==========================================
  Files           9        9              
  Lines         270      283      +13     
==========================================
+ Hits          255      268      +13     
  Misses         15       15              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

To avoid breaking static typing of app when using
app = CorrelationIdMiddleware(app), CorrelationIdMiddleware is now
exposed to type checkers as a class whose __new__ returns the same
type it was given, instead of Self. At runtime this class is unused so
behavior and add_middleware() compatibility are unchanged.

Add a regression test that parses the module source with ast and fails
if the TYPE_CHECKING-only __new__ stub's parameters drift out of sync
with the dataclass's fields.
@sebastian-correa
sebastian-correa force-pushed the feature/better-default-500-handling branch from 1ac2477 to 68440f0 Compare September 28, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant