diff --git a/.gitignore b/.gitignore index d3b3a6e..24244ca 100644 --- a/.gitignore +++ b/.gitignore @@ -285,3 +285,4 @@ cython_debug/ credentials.env volumes/ +.qlty diff --git a/src/common/errors.py b/src/common/errors.py new file mode 100644 index 0000000..83c2a00 --- /dev/null +++ b/src/common/errors.py @@ -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 diff --git a/src/http_app/__init__.py b/src/http_app/__init__.py index 6782c91..57f14bf 100644 --- a/src/http_app/__init__.py +++ b/src/http_app/__init__.py @@ -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 @@ -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) diff --git a/tests/http_app/test_exception_handlers.py b/tests/http_app/test_exception_handlers.py index fda870b..af41e1a 100644 --- a/tests/http_app/test_exception_handlers.py +++ b/tests/http_app/test_exception_handlers.py @@ -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} + )