Skip to content

Fix HTTPException/ModelNotFound returning 500 and hard-coded FastAPI title - #315

Merged
tmgbedu merged 3 commits into
mainfrom
task/http-exception-status-and-title
Oct 27, 2026
Merged

tmgbedu merged 3 commits into
mainfrom
task/http-exception-status-and-title

Conversation

@claude-tmgbedu

Copy link
Copy Markdown

Summary

  • F1: HTTPExceptionHandler built a 500 response for every exception it received. Starlette and FastAPI HTTPExceptions now go through FastAPI's http_exception_handler, so they keep their status, detail and headers. A new dependency-free fastapi_startkit.exceptions.HttpException base is subclassed by ModelNotFoundException (404), RouteNotFoundException, MethodNotAllowedException and AuthorizationException. It is registered as its own FastAPI handler, so these exceptions no longer reach Starlette's ServerErrorMiddleware, which re-raises.
  • F2: FastAPIConfig.title reads APP_NAME (default FastAPI Startkit). The hard-coded title had never taken effect: Application.__init__ reset _fastapi = None after register_providers(), which threw away the provider's instance. _fastapi is now set up before providers register.
  • ADR 037 is added and indexed.

Test plan

  • New tests/fastapi/test_http_exception_rendering.py (HttpTestCase) checks:
    • HTTPException(400, "Email taken") returns 400 with detail and custom headers
    • a Starlette HTTPException returns 418
    • an unknown route returns 404
    • ModelNotFoundException returns 404
    • APP_NAME sets the title, and the default title is used without it
  • New unit tests cover 405 with the Allow header and 403.
  • uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3302 passed, 91.59% coverage
  • ruff check/format and basedpyright on the changed files: 0 errors

🤖 Generated with Claude Code

https://claude.ai/code/session_01QgHs6PRFJehnb1BbCNtLww

HTTPException and ModelNotFoundException rendered as 500 because
HTTPExceptionHandler always built a 500 response. Starlette HTTP
exceptions now go through FastAPI's http_exception_handler, and a new
HttpException base for framework exceptions gets its own FastAPI
handler.

The hard-coded FastAPI title was never used, because Application reset
_fastapi after providers registered. The title now comes from
FastAPIConfig.title (APP_NAME, default "FastAPI Startkit").
claude-tmgbedu pushed a commit that referenced this pull request Oct 25, 2026
ThrottleRequestsException now subclasses HttpException, so it renders as
429 and passes on headers such as Retry-After. Its headers default changes
from a shared mutable dict to None.

HTTPExceptionHandler.report now skips client errors: Starlette/FastAPI
HTTPException and framework HttpException with a status below 500. 5xx
and non-HTTP exceptions are still reported through the exception
manager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018JLUqZGzL9UdCwwax5fT9B
@codecov

codecov Bot commented Oct 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@claude-tmgbedu claude-tmgbedu left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review + QA of PR #315 (head def516f): APPROVE, with non-blocking notes

Tests

  • uv run pytest --ignore=tests/masoniteorm/postgres --cov: 3310 passed, 10 skipped, coverage 91.65% (gate is 80%).
  • ruff is clean on the changed files.
  • Core fastapi_startkit.exceptions and masoniteorm.exceptions import fine with starlette/fastapi blocked from import, so there is no import cycle and no Starlette dependency in core.

Scratch QA

I ran 16 repros with HttpTestCase. The app was real: DatabaseProvider on SQLite plus FastAPIProvider, with a user-published FastAPIConfig that has no title field. All 16 pass.

Scenario Result
HTTPException(400, "Email taken") 400 {"detail": "Email taken"}
Unknown route 404 {"detail": "Not Found"} and x-request-id present
Wrong method 405 with Allow: POST
Real User.find_or_fail(7) on SQLite 404 {"detail": "User with primary key 7 not found."}
ThrottleRequestsException(headers={"Retry-After": "60"}) 429 with Retry-After: 60
AuthorizationException() 403
Depends() raising HTTPException(401) 401 with detail
JSON RequestValidationError 422 {"errors": ...}, unchanged
RuntimeError 500 {"message": "Server Error"}, report_exception called once
400, 404, 429 not reported
HTTPException(503) reported
APP_DEBUG=true + RuntimeError 500 with trace
APP_DEBUG=true + 4xx still {"detail": ...}
/docs shows the APP_NAME title. A published config without title still works, because resolve_config merges the defaults (verified against the example/fastapi-app and example/agents config shape).
Custom exception_manager.register_handler(ModelNotFoundException, ...) still takes precedence
fastapi.add_exception_handler(404, ...) status-code handler still takes precedence

Notes (non-blocking)

  1. HttpException raised from BaseHTTPMiddleware is still re-raised (fastapi/providers/fastapi_provider.py:62-65). Repro: a middleware that raises ThrottleRequestsException. The client now correctly gets 429 (before this PR it got 500). The exception is handled by the bare-Exception handler on ServerErrorMiddleware, though, and that middleware re-raises it. Uvicorn therefore logs a traceback for a 4xx. This behaviour existed before the PR and is outside its scope. It matters once a throttle middleware lands.
  2. ThrottleRequestsException.headers is now None by default, not {} (exceptions/exceptions.py:169-174). Fixing the shared mutable default is right. Code that mutates exc.headers[...] on a default-constructed instance would now fail, so consider a changelog line.
  3. HttpException.get_status/get_response raise NotImplementedError (exceptions/exceptions.py:76-80). A user subclass that forgets to override them raises a second exception inside the handler, and the response becomes a re-raised 500. A default status = 500 / message pair, or abc.abstractmethod, would fail earlier and more clearly. This is a nit.
  4. 4xx bodies use FastAPI's {"detail": ...}, while 500 uses {"message": ...}. Neither existing 4xx clients nor 422 regress, since these responses were all 500 before. Browser/Inertia requests that hit a 404/403 still get JSON rather than an HTML error page. That could be a follow-up.
  5. The Application.__init__ reorder (application.py:64-65) now keeps instances created in register(). For example, example/inertia-pingcrm-app now really gets title="Vite Example", which it previously dropped. I found no provider that touches app.fastapi during register() before FastAPIProvider runs (broadcasting, redis, storage and vite all do it in boot()), so I see no side effect.

The ADR 037 is present and indexed in docs/adr/AGENTS.md, and no new explanatory comments or docstrings were added.

@tmgbedu
tmgbedu merged commit cd7730e into main Oct 27, 2026
9 checks passed
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.

2 participants