add api exception handler (#74)

* try to add exception handler

* improve server error test

* fix lint

* add build_uri util

* fix header file path

---------

Co-authored-by: Dmitry Afanasyev <afanasiev@litres.ru>
This commit is contained in:
Dmitry Afanasyev 2023-12-30 23:50:59 +03:00 committed by GitHub
parent d1ae7f2281
commit bf7a5520dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 171 additions and 45 deletions

View File

@ -0,0 +1,27 @@
from typing import Any
from fastapi import status as status_code
from pydantic import BaseModel, Field
class BaseError(BaseModel):
"""Error response as defined in
https://datatracker.ietf.org/doc/html/rfc7807.
One difference is that the member "type" is not a URI
"""
type: str | None = Field(title="The name of the class of the error")
title: str | None = Field(
title="A short, human-readable summary of the problem that does not change from occurence to occurence",
)
detail: str | None = Field(
default=None, title="А human-readable explanation specific to this occurrence of the problem"
)
instance: Any | None = Field(default=None, title="Identifier for this specific occurrence of the problem")
class BaseResponse(BaseModel):
status: int = Field(..., title="Status code of request.", example=status_code.HTTP_200_OK) # type: ignore[call-arg]
error: dict[Any, Any] | BaseError | None = Field(default=None, title="Errors")
payload: Any | None = Field(default_factory=dict, title="Payload data.")

View File

@ -1,2 +1,34 @@
from fastapi.responses import ORJSONResponse
from starlette.requests import Request
from api.base_schemas import BaseError, BaseResponse
class BaseAPIException(Exception): class BaseAPIException(Exception):
pass pass
class InternalServerError(BaseError):
pass
class InternalServerErrorResponse(BaseResponse):
error: InternalServerError
class Config:
json_schema_extra = {
"example": {
"status": 500,
"error": {
"type": "InternalServerError",
"title": "Server encountered an unexpected error that prevented it from fulfilling the request",
"detail": "error when adding send message",
},
},
}
async def internal_server_error_handler(_request: Request, _exception: Exception) -> ORJSONResponse:
error = InternalServerError(title="Something went wrong!", type="InternalServerError")
response = InternalServerErrorResponse(status=500, error=error).model_dump(exclude_unset=True)
return ORJSONResponse(status_code=500, content=response)

View File

@ -1,3 +1,4 @@
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import cache, wraps from functools import cache, wraps
from inspect import cleandoc from inspect import cleandoc
@ -39,3 +40,9 @@ def clean_doc(cls: Any) -> str | None:
if cls.__doc__ is None: if cls.__doc__ is None:
return None return None
return cleandoc(cls.__doc__) return cleandoc(cls.__doc__)
def build_uri(uri_parts: list[str]) -> str:
parts = [part.strip("/") for part in uri_parts]
uri = str(os.path.join(*parts)).strip("/")
return f"/{uri}"

View File

@ -1,9 +1,9 @@
import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqladmin import Admin, ModelView from sqladmin import Admin, ModelView
from core.bot.models.chatgpt import ChatGptModels from core.bot.models.chatgpt import ChatGptModels
from core.utils import build_uri
from settings.config import settings from settings.config import settings
if TYPE_CHECKING: if TYPE_CHECKING:
@ -21,12 +21,11 @@ class ChatGptAdmin(ModelView, model=ChatGptModels):
def create_admin(application: "Application") -> Admin: def create_admin(application: "Application") -> Admin:
base_url = os.path.join(settings.URL_PREFIX, "admin")
admin = Admin( admin = Admin(
title="Chat GPT admin", title="Chat GPT admin",
app=application.fastapi_app, app=application.fastapi_app,
engine=application.db.async_engine, engine=application.db.async_engine,
base_url=base_url if base_url.startswith("/") else "/" + base_url, base_url=build_uri([settings.URL_PREFIX, "admin"]),
authentication_backend=None, authentication_backend=None,
) )
admin.add_view(ChatGptAdmin) admin.add_view(ChatGptAdmin)

View File

@ -5,9 +5,11 @@ import sentry_sdk
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import UJSONResponse from fastapi.responses import UJSONResponse
from api.exceptions import internal_server_error_handler
from core.bot.app import BotApplication, BotQueue from core.bot.app import BotApplication, BotQueue
from core.bot.handlers import bot_event_handlers from core.bot.handlers import bot_event_handlers
from core.lifetime import shutdown, startup from core.lifetime import shutdown, startup
from core.utils import build_uri
from infra.admin import create_admin from infra.admin import create_admin
from infra.database.db_adapter import Database from infra.database.db_adapter import Database
from infra.logging_conf import configure_logging from infra.logging_conf import configure_logging
@ -21,11 +23,14 @@ class Application:
title="Chat gpt bot", title="Chat gpt bot",
description="Bot for proxy to chat gpt in telegram", description="Bot for proxy to chat gpt in telegram",
version="0.0.3", version="0.0.3",
docs_url="/" + "/".join([settings.api_prefix.strip("/"), "docs"]), docs_url=build_uri([settings.api_prefix, "docs"]),
redoc_url="/" + "/".join([settings.api_prefix.strip("/"), "redocs"]), redoc_url=build_uri([settings.api_prefix, "redocs"]),
openapi_url="/" + "/".join([settings.api_prefix.strip("/"), "openapi.json"]), openapi_url=build_uri([settings.api_prefix, "openapi.json"]),
debug=settings.DEBUG, debug=settings.DEBUG,
default_response_class=UJSONResponse, default_response_class=UJSONResponse,
exception_handlers={
Exception: internal_server_error_handler,
},
) )
self.bot_app = bot_app self.bot_app = bot_app
self.db = Database(settings) self.db = Database(settings)

View File

@ -9,6 +9,7 @@ from pydantic_settings import BaseSettings
from yarl import URL from yarl import URL
from constants import API_PREFIX, CHATGPT_BASE_URI, LogLevelEnum from constants import API_PREFIX, CHATGPT_BASE_URI, LogLevelEnum
from core.utils import build_uri
BASE_DIR = Path(__file__).parent.parent BASE_DIR = Path(__file__).parent.parent
SHARED_DIR = BASE_DIR.resolve().joinpath("shared") SHARED_DIR = BASE_DIR.resolve().joinpath("shared")
@ -109,7 +110,7 @@ class AppSettings(SentrySettings, LoggingSettings, BaseSettings):
@cached_property @cached_property
def api_prefix(self) -> str: def api_prefix(self) -> str:
if self.URL_PREFIX: if self.URL_PREFIX:
return "/" + "/".join([self.URL_PREFIX.strip("/"), API_PREFIX.strip("/")]) return build_uri([self.URL_PREFIX, API_PREFIX])
return API_PREFIX return API_PREFIX
@cached_property @cached_property
@ -126,7 +127,7 @@ class AppSettings(SentrySettings, LoggingSettings, BaseSettings):
@cached_property @cached_property
def bot_webhook_url(self) -> str: def bot_webhook_url(self) -> str:
return "/".join([self.api_prefix, self.token_part]) return build_uri([self.api_prefix, self.token_part])
@cached_property @cached_property
def db_file(self) -> Path: def db_file(self) -> Path:

View File

@ -24,6 +24,20 @@ def test_settings() -> AppSettings:
return get_settings() return get_settings()
# Redefine the event_loop fixture to have a session scope. Otherwise `bot` fixture can't be
# session. See https://github.com/pytest-dev/pytest-asyncio/issues/68 for more details.
@pytest.fixture(scope="session", autouse=True)
def event_loop() -> AbstractEventLoop:
"""
Пересоздаем луп для изоляции тестов. В основном нужно для запуска юнит тестов
в связке с интеграционными, т.к. без этого pytest зависает.
Для интеграционных тестов фикстура определяется дополнительная фикстура на всю сессию.
"""
loop = asyncio.get_event_loop_policy().new_event_loop()
asyncio.set_event_loop(loop)
return loop
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def engine(test_settings: AppSettings) -> Generator[Engine, None, None]: def engine(test_settings: AppSettings) -> Generator[Engine, None, None]:
""" """
@ -99,21 +113,6 @@ class PytestApplication(Application): # type: ignore
pass pass
def make_bot(bot_info: dict[str, Any] | None = None, **kwargs: Any) -> PytestExtBot:
"""
Tests are executed on tg.ext.ExtBot, as that class only extends the functionality of tg.bot
"""
token = kwargs.pop("token", (bot_info or {}).get("token"))
kwargs.pop("token", None)
return PytestExtBot(
token=token,
private_key=None,
request=NonchalantHttpxRequest(connection_pool_size=8),
get_updates_request=NonchalantHttpxRequest(connection_pool_size=1),
**kwargs,
)
async def _mocked_get_me(bot: Bot) -> User: async def _mocked_get_me(bot: Bot) -> User:
if bot._bot_user is None: if bot._bot_user is None:
bot._bot_user = _get_bot_user(bot.token) bot._bot_user = _get_bot_user(bot.token)
@ -145,20 +144,6 @@ def _get_bot_user(token: str) -> User:
) )
# Redefine the event_loop fixture to have a session scope. Otherwise `bot` fixture can't be
# session. See https://github.com/pytest-dev/pytest-asyncio/issues/68 for more details.
@pytest.fixture(scope="session", autouse=True)
def event_loop() -> AbstractEventLoop:
"""
Пересоздаем луп для изоляции тестов. В основном нужно для запуска юнит тестов
в связке с интеграционными, т.к. без этого pytest зависает.
Для интеграционных тестов фикстура определяется дополнительная фикстура на всю сессию.
"""
loop = asyncio.get_event_loop_policy().new_event_loop()
asyncio.set_event_loop(loop)
return loop
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def bot_info() -> dict[str, Any]: def bot_info() -> dict[str, Any]:
return BotInfoFactory() return BotInfoFactory()
@ -183,14 +168,16 @@ async def bot(bot_info: dict[str, Any], bot_application: Any) -> AsyncGenerator[
yield _bot yield _bot
@pytest.fixture(scope="session")
async def bot_app(test_settings: AppSettings) -> BotApplication:
return BotApplication(settings=test_settings, handlers=bot_event_handlers.handlers)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def main_application( async def main_application(
bot_application: PytestApplication, test_settings: AppSettings test_settings: AppSettings,
bot_app: BotApplication,
) -> AsyncGenerator[AppApplication, None]: ) -> AsyncGenerator[AppApplication, None]:
bot_app = BotApplication(
settings=test_settings,
handlers=bot_event_handlers.handlers,
)
bot_app.application._initialized = True bot_app.application._initialized = True
bot_app.application.bot = make_bot(BotInfoFactory()) bot_app.application.bot = make_bot(BotInfoFactory())
bot_app.application.bot._bot_user = BotUserFactory() bot_app.application.bot._bot_user = BotUserFactory()
@ -213,3 +200,18 @@ async def rest_client(main_application: AppApplication) -> AsyncGenerator[AsyncC
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) as client: ) as client:
yield client yield client
def make_bot(bot_info: dict[str, Any] | None = None, **kwargs: Any) -> PytestExtBot:
"""
Tests are executed on tg.ext.ExtBot, as that class only extends the functionality of tg.bot
"""
token = kwargs.pop("token", (bot_info or {}).get("token"))
kwargs.pop("token", None)
return PytestExtBot(
token=token,
private_key=None,
request=NonchalantHttpxRequest(connection_pool_size=8),
get_updates_request=NonchalantHttpxRequest(connection_pool_size=1),
**kwargs,
)

View File

@ -1,11 +1,15 @@
import httpx import httpx
import pytest import pytest
from faker import Faker from faker import Faker
from httpx import AsyncClient, Response from fastapi.responses import ORJSONResponse
from httpx import ASGITransport, AsyncClient, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette import status
from api.exceptions import BaseAPIException from api.exceptions import BaseAPIException
from core.bot.app import BotApplication
from main import Application as AppApplication
from settings.config import AppSettings from settings.config import AppSettings
from tests.integration.factories.bot import ChatGptModelFactory from tests.integration.factories.bot import ChatGptModelFactory
from tests.integration.utils import mocked_ask_question_api from tests.integration.utils import mocked_ask_question_api
@ -63,3 +67,32 @@ async def test_bot_healthcheck_not_ok(
): ):
response = await rest_client.get("/api/bot-healthcheck") response = await rest_client.get("/api/bot-healthcheck")
assert response.status_code == httpx.codes.INTERNAL_SERVER_ERROR assert response.status_code == httpx.codes.INTERNAL_SERVER_ERROR
async def test_server_error_handler_returns_500_without_traceback_when_debug_disabled(
test_settings: AppSettings,
bot_app: BotApplication,
) -> None:
settings = test_settings.model_copy(update={"DEBUG": False})
fastapi_app = AppApplication(settings=settings, bot_app=bot_app).fastapi_app
route = "/server-error"
@fastapi_app.get(route, response_model=None, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
async def controller() -> ORJSONResponse:
result = 1 / 0
return ORJSONResponse(content=result, status_code=status.HTTP_200_OK)
async with AsyncClient(
transport=ASGITransport(app=fastapi_app, raise_app_exceptions=False), # type: ignore[arg-type]
base_url="http://test",
headers={"Content-Type": "application/json"},
) as client:
response = await client.get(route)
assert response.status_code == 500
data = response.json()
assert data == {"error": {"title": "Something went wrong!", "type": "InternalServerError"}, "status": 500}
replaced_oauth_route = next(
filter(lambda r: r.path == route, fastapi_app.routes) # type: ignore[arg-type, attr-defined]
)
fastapi_app.routes.remove(replaced_oauth_route)

View File

@ -1,7 +1,9 @@
import time import time
from typing import Callable from typing import Callable
from core.utils import timed_lru_cache import pytest
from core.utils import build_uri, timed_lru_cache
class TestTimedLruCache: class TestTimedLruCache:
@ -58,3 +60,21 @@ class TestTimedLruCache:
) -> None: ) -> None:
for _ in range(call_times): for _ in range(call_times):
assert func(first, second) == result assert func(first, second) == result
@pytest.mark.parametrize(
"uri_parts, expected_result",
[
(["", "admin"], "/admin"),
(["/gpt", "admin"], "/gpt/admin"),
(["/gpt", "/chat"], "/gpt/chat"),
(["gpt", ""], "/gpt"),
(["gpt"], "/gpt"),
(["gpt", "chat"], "/gpt/chat"),
(["", ""], "/"),
(["gpt", "/chat", "/admin"], "/gpt/chat/admin"),
(["gpt/", "/chat/", "/admin"], "/gpt/chat/admin"),
],
)
def test_build_uri_with_slash_prefix(uri_parts: list[str], expected_result: str) -> None:
assert build_uri(uri_parts) == expected_result

View File

@ -7,7 +7,7 @@
#include <tuple> #include <tuple>
#include <vector> #include <vector>
#include <concurrentqueue/concurrentqueue.h> #include <concurrentqueue.h>
#include <curl/curl.h> #include <curl/curl.h>
#include <openssl/md5.h> #include <openssl/md5.h>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>