Fix exception handler overlap issue - add DomainError catch-all handler
**Problem:** Broad exception handlers created fragility where adding a new DomainError subclass without explicit registration would silently fall through to the generic exception handler, losing the specific error_code and metadata. **Solution:** 1. Import DomainError in main.py for explicit handler registration 2. Fix type hints in exception handlers from 'Exception' to specific types - NotFoundError handler now typed as 'NotFoundError' - BadRequestError handler now typed as 'BadRequestError' - ConflictError handler now typed as 'ConflictError' - DomainError handler now typed as 'DomainError' - ServiceUnavailableError handler now typed as 'ServiceUnavailableError' 3. Add DomainError as an explicit catch-all handler in the registration chain - Positioned after specific handlers, before HTTPException - Any unregistered DomainError subclass now gets correct error_code + metadata 4. Document the exception handler hierarchy with detailed comments 5. Update Backend-Development.md with handler hierarchy documentation 6. Update Architekture.md section 2.2 with exception handler details 7. Fix test expectations in test_main.py to verify ErrorResponse format **Impact:** Any new DomainError subclass now automatically gets correct HTTP 500 status, error_code, and metadata - even if developer forgets explicit handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -33,6 +33,7 @@ from app.exceptions import (
|
||||
AuthenticationError,
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
DomainError,
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanProtocolError,
|
||||
NotFoundError,
|
||||
@@ -328,7 +329,7 @@ async def _fail2ban_protocol_handler(
|
||||
|
||||
async def _not_found_handler(
|
||||
request: Request,
|
||||
exc: Exception,
|
||||
exc: NotFoundError,
|
||||
) -> JSONResponse:
|
||||
"""Return a ``404 Not Found`` response for missing domain entities.
|
||||
|
||||
@@ -359,7 +360,7 @@ async def _not_found_handler(
|
||||
|
||||
async def _bad_request_handler(
|
||||
request: Request,
|
||||
exc: Exception,
|
||||
exc: BadRequestError,
|
||||
) -> JSONResponse:
|
||||
"""Return a ``400 Bad Request`` response for validation and domain contract errors.
|
||||
|
||||
@@ -390,7 +391,7 @@ async def _bad_request_handler(
|
||||
|
||||
async def _conflict_handler(
|
||||
request: Request,
|
||||
exc: Exception,
|
||||
exc: ConflictError,
|
||||
) -> JSONResponse:
|
||||
"""Return a ``409 Conflict`` response for domain state conflicts."""
|
||||
log.warning(
|
||||
@@ -413,7 +414,7 @@ async def _conflict_handler(
|
||||
|
||||
async def _domain_error_handler(
|
||||
request: Request,
|
||||
exc: Exception,
|
||||
exc: DomainError,
|
||||
) -> JSONResponse:
|
||||
"""Return a ``500 Internal Server Error`` response for domain write failures."""
|
||||
log.error(
|
||||
@@ -468,7 +469,7 @@ async def _value_error_handler(
|
||||
|
||||
async def _service_unavailable_handler(
|
||||
request: Request,
|
||||
exc: Exception,
|
||||
exc: ServiceUnavailableError,
|
||||
) -> JSONResponse:
|
||||
"""Return a ``503 Service Unavailable`` response for infrastructure errors.
|
||||
|
||||
@@ -778,18 +779,35 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
|
||||
# --- Exception handlers ---
|
||||
# Ordered from most specific to least specific. FastAPI evaluates handlers
|
||||
# in the order they were registered, so fail2ban network errors get a 502
|
||||
# rather than falling through to the generic 500 handler.
|
||||
#
|
||||
# Exception handlers are registered from most specific to least specific. FastAPI evaluates
|
||||
# them in registration order, allowing specific handlers to match before fallback handlers.
|
||||
#
|
||||
# The hierarchy (in order) is:
|
||||
# 1. Network-specific errors (Fail2BanConnectionError, Fail2BanProtocolError) → HTTP 502
|
||||
# 2. Auth/rate-limit errors (AuthenticationError, RateLimitError) → HTTP 401/429
|
||||
# 3. Category handlers (NotFoundError, BadRequestError, ConflictError) → HTTP 404/400/409
|
||||
# 4. OperationError handler → HTTP 500
|
||||
# 5. ServiceUnavailableError handler → HTTP 503
|
||||
# 6. Generic DomainError handler (catch-all for any unregistered DomainError subclass) → HTTP 500
|
||||
# 7. HTTPException (FastAPI built-ins, validation errors) → HTTP varies
|
||||
# 8. ValueError (Pydantic validation) → HTTP 400
|
||||
# 9. Exception (absolute catch-all for unexpected errors) → HTTP 500
|
||||
#
|
||||
# This ensures that any new DomainError subclass that inherits from a registered category
|
||||
# is automatically handled with the correct error_code and metadata. If a developer adds
|
||||
# a DomainError subclass without putting it in a category, it falls through to the
|
||||
# generic DomainError handler rather than the unhandled_exception_handler.
|
||||
app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(AuthenticationError, _authentication_error_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(RateLimitError, _rate_limit_error_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(NotFoundError, _not_found_handler)
|
||||
app.add_exception_handler(BadRequestError, _bad_request_handler)
|
||||
app.add_exception_handler(ConflictError, _conflict_handler)
|
||||
app.add_exception_handler(OperationError, _domain_error_handler)
|
||||
app.add_exception_handler(ServiceUnavailableError, _service_unavailable_handler)
|
||||
app.add_exception_handler(NotFoundError, _not_found_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(BadRequestError, _bad_request_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(ConflictError, _conflict_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(OperationError, _domain_error_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(ServiceUnavailableError, _service_unavailable_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(DomainError, _domain_error_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(HTTPException, _http_exception_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(ValueError, _value_error_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(Exception, _unhandled_exception_handler)
|
||||
|
||||
@@ -112,15 +112,25 @@ async def test_create_app_global_domain_exception_handlers() -> None:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/not-found")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Jail not found: 'ssh'"}
|
||||
data = response.json()
|
||||
assert data["code"] == "jail_not_found"
|
||||
assert data["detail"] == "Jail not found: 'ssh'"
|
||||
assert data["metadata"] == {"jail_name": "ssh"}
|
||||
assert "correlation_id" in data
|
||||
|
||||
response = await client.get("/bad-request")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "invalid payload"}
|
||||
data = response.json()
|
||||
assert data["code"] == "config_validation_failed"
|
||||
assert data["detail"] == "invalid payload"
|
||||
assert "correlation_id" in data
|
||||
|
||||
response = await client.get("/server-error")
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "write failed"}
|
||||
data = response.json()
|
||||
assert data["code"] == "config_write_failed"
|
||||
assert data["detail"] == "write failed"
|
||||
assert "correlation_id" in data
|
||||
|
||||
|
||||
def test_create_app_disables_cors_by_default() -> None:
|
||||
|
||||
Reference in New Issue
Block a user