Skip to content
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

[Feature] Improve Exception handlers #6430

Merged
merged 17 commits into from
May 20, 2024
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
11 changes: 11 additions & 0 deletions openbb_platform/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
- [Important classes](#important-classes)
- [Import statements](#import-statements)
- [The TET pattern](#the-tet-pattern)
- [Error](#errors)
- [Data processing commands](#data-processing-commands)
- [Python Interface](#python-interface)
- [API Interface](#api-interface)
Expand Down Expand Up @@ -538,6 +539,16 @@ As the OpenBB Platform has its own standardization framework and the data fetche
2. Extract - `extract_data(query: ExampleQueryParams,credentials: Optional[Dict[str, str]],**kwargs: Any,) -> Dict`: makes the request to the API endpoint and returns the raw data. Given the transformed query parameters, the credentials and any other extra arguments, this method should return the raw data as a dictionary.
3. Transform - `transform_data(query: ExampleQueryParams, data: Dict, **kwargs: Any) -> List[ExampleHistoricalData]`: transforms the raw data into the defined data model. Given the transformed query parameters (might be useful for some filtering), the raw data and any other extra arguments, this method should return the transformed data as a list of [`Data`](openbb_platform/platform/provider/openbb_core/provider/abstract/data.py) children.

#### Errors
Copy link
Contributor

Choose a reason for hiding this comment

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

This should go to the docs website.
We should consider deprecate this document and just point potential contributors to the website instead.

cc @IgorWounds @montezdesousa


To ensure a consistent error handling behavior our API relies on the convention below.

| Status code | Exception | Detail | Description |
| -------- | ------- | ------- | ------- |
| 400 | `OpenBBError` or child of `OpenBBError` | Custom message. | Use this to explicitly raise custom exceptions, like `EmptyDataError`. |
| 422 | `ValidationError` | `Pydantic` errors dict message. | Automatically raised to inform the user about query validation errors. ValidationErrors outside of the query are treated with status code 500 by default. |
| 500 | Any exception not covered above, eg `ValueError`, `ZeroDivisionError` | Unexpected error. | Unexpected exceptions, most likely a bug. |

#### Data processing commands

The data processing commands are commands that are used to process the data that may or may not come from the OpenBB Platform.
Expand Down
44 changes: 24 additions & 20 deletions openbb_platform/core/openbb_core/api/app_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,38 @@
from typing import List, Optional

from fastapi import APIRouter, FastAPI
from openbb_core.api.exception_handlers import ExceptionHandlers
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.app.router import RouterLoader
from pydantic import ValidationError


class AppLoader:
"""App loader."""

@staticmethod
def get_openapi_tags() -> List[dict]:
"""Get openapi tags."""
main_router = RouterLoader.from_extensions()
openapi_tags = []
# Add tag data for each router in the main router
for r in main_router.routers:
openapi_tags.append(
{
"name": r,
"description": main_router.get_attr(r, "description"),
}
)
return openapi_tags

@staticmethod
def from_routers(
app: FastAPI, routers: List[Optional[APIRouter]], prefix: str
) -> FastAPI:
"""Load routers to app."""
def add_routers(app: FastAPI, routers: List[Optional[APIRouter]], prefix: str):
"""Add routers."""
for router in routers:
if router:
app.include_router(router=router, prefix=prefix)

return app
@staticmethod
def add_openapi_tags(app: FastAPI):
"""Add openapi tags."""
main_router = RouterLoader.from_extensions()
# Add tag data for each router in the main router
app.openapi_tags = [
{
"name": r,
"description": main_router.get_attr(r, "description"),
}
for r in main_router.routers
]

@staticmethod
def add_exception_handlers(app: FastAPI):
"""Add exception handlers."""
app.exception_handlers[Exception] = ExceptionHandlers.exception
app.exception_handlers[ValidationError] = ExceptionHandlers.validation
app.exception_handlers[OpenBBError] = ExceptionHandlers.openbb
69 changes: 69 additions & 0 deletions openbb_platform/core/openbb_core/api/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Exception handlers module."""

import logging
from typing import Any

from fastapi import Request
from fastapi.responses import JSONResponse
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.env import Env
from pydantic import ValidationError

logger = logging.getLogger("uvicorn.error")


class ExceptionHandlers:
"""Exception handlers."""

@staticmethod
async def _handle(exception: Exception, status_code: int, detail: Any):
"""Exception handler."""
if Env().DEBUG_MODE:
raise exception
logger.error(exception)
return JSONResponse(
status_code=status_code,
content={
"detail": detail,
},
)

@staticmethod
async def exception(_: Request, error: Exception) -> JSONResponse:
"""Exception handler for Base Exception."""
return await ExceptionHandlers._handle(
exception=error,
status_code=500,
detail="Unexpected error.",
)

@staticmethod
async def validation(request: Request, error: ValidationError):
"""Exception handler for ValidationError."""
# Some validation is performed at Fetcher level.
# So we check if the validation error comes from a QueryParams class.
# And that it is in the request query params.
# If yes, we update the error location with query.
# If not, we handle it as a base Exception error.
query_params = dict(request.query_params)
errors = error.errors(include_url=False)
all_in_query = all(
loc in query_params for err in errors for loc in err.get("loc", ())
)
if "QueryParams" in error.title and all_in_query:
detail = [{**err, "loc": ("query",) + err.get("loc", ())} for err in errors]
return await ExceptionHandlers._handle(
exception=error,
status_code=422,
detail=detail,
)
return await ExceptionHandlers.exception(request, error)

@staticmethod
async def openbb(_: Request, error: OpenBBError):
"""Exception handler for OpenBBError."""
return await ExceptionHandlers._handle(
exception=error,
status_code=400,
detail=str(error.original),
)
41 changes: 4 additions & 37 deletions openbb_platform/core/openbb_core/api/rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from openbb_core.api.app_loader import AppLoader
from openbb_core.api.router.commands import router as router_commands
from openbb_core.api.router.coverage import router as router_coverage
from openbb_core.api.router.system import router as router_system
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.app.service.auth_service import AuthService
from openbb_core.app.service.system_service import SystemService
from openbb_core.env import Env
Expand Down Expand Up @@ -73,8 +71,7 @@ async def lifespan(_: FastAPI):
allow_methods=system.api_settings.cors.allow_methods,
allow_headers=system.api_settings.cors.allow_headers,
)
app.openapi_tags = AppLoader.get_openapi_tags()
AppLoader.from_routers(
AppLoader.add_routers(
app=app,
routers=(
[AuthService().router, router_system, router_coverage, router_commands]
Expand All @@ -83,38 +80,8 @@ async def lifespan(_: FastAPI):
),
prefix=system.api_settings.prefix,
)


@app.exception_handler(Exception)
async def api_exception_handler(_: Request, exc: Exception):
"""Exception handler for all other exceptions."""
if Env().DEBUG_MODE:
raise exc
logger.error(exc)
return JSONResponse(
status_code=404,
content={
"detail": str(exc),
"error_kind": exc.__class__.__name__,
},
)


@app.exception_handler(OpenBBError)
async def openbb_exception_handler(_: Request, exc: OpenBBError):
"""Exception handler for OpenBB errors."""
if Env().DEBUG_MODE:
raise exc
logger.error(exc.original)
openbb_error = exc.original
status_code = 400 if "No results" in str(openbb_error) else 500
return JSONResponse(
status_code=status_code,
content={
"detail": str(openbb_error),
"error_kind": openbb_error.__class__.__name__,
},
)
AppLoader.add_openapi_tags(app)
AppLoader.add_exception_handlers(app)


if __name__ == "__main__":
Expand Down
8 changes: 3 additions & 5 deletions openbb_platform/core/openbb_core/app/command_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,8 @@ def validate_kwargs(
}
# We allow extra fields to return with model with 'cc: CommandContext'
config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
ValidationModel = create_model(func.__name__, __config__=config, **fields) # type: ignore # pylint: disable=C0103
# pylint: disable=C0103
ValidationModel = create_model(func.__name__, __config__=config, **fields) # type: ignore
# Validate and coerce
model = ValidationModel(**kwargs)
ParametersBuilder._warn_kwargs(
Expand Down Expand Up @@ -331,7 +332,7 @@ def _chart(

if chart_params:
kwargs.update(chart_params)
obbject.charting.show(render=False, **kwargs)
obbject.charting.show(render=False, **kwargs) # type: ignore[attr-defined]
except Exception as e: # pylint: disable=broad-exception-caught
if Env().DEBUG_MODE:
raise OpenBBError(e) from e
Expand Down Expand Up @@ -386,9 +387,6 @@ async def _execute_func(
obbject._standard_params = kwargs.get("standard_params", None)
if chart and obbject.results:
cls._chart(obbject, **kwargs)

except Exception as e:
raise OpenBBError(e) from e
finally:
ls = LoggingService(system_settings, user_settings)
ls.log(
Expand Down
13 changes: 5 additions & 8 deletions openbb_platform/core/openbb_core/app/logs/logging_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
import logging
from enum import Enum
from types import TracebackType
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union, cast
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union

from openbb_core.app.logs.formatters.formatter_with_exceptions import (
FormatterWithExceptions,
)
from openbb_core.app.logs.handlers_manager import HandlersManager
from openbb_core.app.logs.models.logging_settings import LoggingSettings
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.app.model.abstract.singleton import SingletonMeta
from openbb_core.app.model.system_settings import SystemSettings
from openbb_core.app.model.user_settings import UserSettings
Expand Down Expand Up @@ -224,24 +223,22 @@ def log(
kwargs = {k: str(v)[:100] for k, v in kwargs.items()}

# Get execution info
openbb_error = cast(
Optional[OpenBBError], exec_info[1] if exec_info else None
)
error = str(exec_info[1]) if exec_info and len(exec_info) > 1 else None

# Construct message
message_label = "ERROR" if openbb_error else "CMD"
message_label = "ERROR" if error else "CMD"
log_message = json.dumps(
{
"route": route,
"input": kwargs,
"error": str(openbb_error.original) if openbb_error else None,
"error": error,
"custom_headers": custom_headers,
},
default=to_jsonable_python,
)
log_message = f"{message_label}: {log_message}"

log_level = logger.error if openbb_error else logger.info
log_level = logger.error if error else logger.info
log_level(
log_message,
extra={"func_name_override": func.__name__},
Expand Down
48 changes: 20 additions & 28 deletions openbb_platform/core/openbb_core/app/static/utils/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ def exception_handler(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*f_args, **f_kwargs):
try:
return func(*f_args, **f_kwargs)
except (ValidationError, Exception) as e:
# If the DEBUG_MODE is enabled, raise the exception with complete traceback
except (ValidationError, OpenBBError, Exception) as e:
if Env().DEBUG_MODE:
raise

Expand All @@ -60,34 +59,27 @@ def wrapper(*f_args, **f_kwargs):

if isinstance(e, ValidationError):
error_list = []

validation_error = f"{e.error_count()} validations errors in {e.title}"
for error in e.errors():
arg = ".".join(map(str, error["loc"]))
arg_error = f"Arg {arg} ->\n"
error_details = (
f"{error['msg']} "
f"[validation_error_type={error['type']}, "
f"input_type={type(error['input']).__name__}, "
f"input_value={error['input']}]\n"
)
url = error.get("url")
error_info = (
f" For further information visit {url}\n" if url else ""
validation_error = f"{e.error_count()} validations error(s)"
for err in e.errors(include_url=False):
loc = ".".join(
[
str(i)
for i in err.get("loc", ())
if i not in ("standard_params", "extra_params")
]
)
error_list.append(arg_error + error_details + error_info)

_input = err.get("input", "")
msg = err.get("msg", "")
error_list.append(f"[Arg] {loc} -> input: {_input} -> {msg}")
error_list.insert(0, validation_error)
error_str = "\n".join(error_list)

raise OpenBBError(
f"\nType -> ValidationError \n\nDetails -> {error_str}"
).with_traceback(tb) from None

# If the error is not a ValidationError, then it is a generic exception
error_type = getattr(e, "original", e).__class__.__name__
raise OpenBBError(
f"\nType -> {error_type}\n\nDetail -> {str(e)}"
).with_traceback(tb) from None
raise OpenBBError(f"\n[Error] -> {error_str}").with_traceback(
tb
) from None
if isinstance(e, OpenBBError):
raise OpenBBError(f"\n[Error] -> {str(e)}").with_traceback(tb) from None
raise OpenBBError("\n[Error] -> Unexpected error.").with_traceback(
tb
) from None

return wrapper
4 changes: 3 additions & 1 deletion openbb_platform/core/openbb_core/provider/utils/errors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Custom exceptions for the provider."""

from openbb_core.app.model.abstract.error import OpenBBError

class EmptyDataError(Exception):

class EmptyDataError(OpenBBError):
"""Exception raised for empty data."""

def __init__(
Expand Down
5 changes: 1 addition & 4 deletions website/content/excel/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,11 @@ Data standardization is at the core of OpenBB Add-in for Excel, offering you a c
<button
className="bg-grey-200 hover:bg-grey-400 dark:bg-[#303038] dark:hover:bg-grey-600 text-grey-900 dark:text-grey-200 text-sm font-medium py-2 px-4 rounded"
>
Join Add-In for Excel waitlist
Start Terminal Pro free-trial
</button>
</a>
</div>


Data standardization is at the core of OpenBB for Excel, offering you a consistent and reliable dataset from a diverse range of asset classes, from equity, fixed income, and cryptocurrency to macroeconomics. This seamless fetch of data means you can readily compare across providers and update it instantly, ensuring accuracy and saving you valuable time.

---

- **Features**
Expand Down
Loading