Skip to content

Application error support with shared schema #284

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,4 @@ cython_debug/

credentials.env
volumes/
.qlty
27 changes: 27 additions & 0 deletions src/common/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
class ApplicationError(Exception):
code: str
internal_message: str
public_message: str
metadata: dict | None

def __init__(self, code: str, internal_message: str, public_message: str, metadata: dict | None = None):
"""
Represents an object encapsulating error information with attributes for code,
internal message, public message, and optional metadata.

Attributes:
code (str): A short, unique, and descriptive code identifying the error.
internal_message (str): A detailed message intended for internal use or logging purposes.
public_message (str): A user-friendly message describing the error, suitable for public display.
metadata (dict | None): Optional additional internal information or context regarding the error.

Parameters:
code: A short string representing the unique error code.
internal_message: A string message containing the internal details of the error.
public_message: A string message intended to be displayed to end users.
metadata: An optional dictionary containing extra information about the error.
"""
self.code = code
self.internal_message = internal_message
self.public_message = public_message
self.metadata = metadata
18 changes: 18 additions & 0 deletions src/http_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from common import AppConfig, application_init
from common.di_container import Container
from common.errors import ApplicationError
from common.telemetry import instrument_third_party
from http_app import context
from http_app.routes import init_routes
Expand Down Expand Up @@ -64,6 +65,23 @@ def init_exception_handlers(app: FastAPI) -> None:
async def add_exception_middleware(request: Request, call_next):
try:
return await call_next(request)
except ApplicationError as e:
logging.exception(
e.internal_message,
extra={
"code": e.code,
"metadata": e.metadata,
},
)
return JSONResponse(
{
"error": {
"message": e.public_message,
"code": e.code,
}
},
status_code=500,
)
except Exception as e:
logging.exception(e)
return JSONResponse({"error": "Internal server error"}, status_code=500)
36 changes: 33 additions & 3 deletions tests/http_app/test_exception_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,51 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient

from common.errors import ApplicationError


@patch("logging.exception")
async def test_exception_is_logged_handler_returns_500(
async def test_unhandled_exception_is_logged_handler_returns_500(
mocked_logging_exception: MagicMock,
testapp: FastAPI,
):
my_exc = Exception("Some random exception")

@testapp.get("/ppp")
@testapp.get("/unhandled_exception")
async def fake_endpoint():
raise my_exc

ac = TestClient(app=testapp, base_url="http://test")
response = ac.get("/ppp")
response = ac.get("/unhandled_exception")

assert response.status_code == 500
assert response.json() == {"error": "Internal server error"}
mocked_logging_exception.assert_called_once_with(my_exc)


@patch("logging.exception")
async def test_application_error_is_logged_handler_returns_500(
mocked_logging_exception: MagicMock,
testapp: FastAPI,
):
my_exc = ApplicationError(
public_message="Some random exception",
internal_message="Some random internal exception",
code="ERR_1234567890",
metadata={
"user_id": "1234567890",
},
)

@testapp.get("/application_error")
async def fake_endpoint():
raise my_exc

ac = TestClient(app=testapp, base_url="http://test")
response = ac.get("/application_error")

assert response.status_code == 500
assert response.json() == {"error": {"code": "ERR_1234567890", "message": "Some random exception"}}
mocked_logging_exception.assert_called_once_with(
my_exc.internal_message, extra={"code": my_exc.code, "metadata": my_exc.metadata}
)