Skip to content

Commit

Permalink
Add error handler to unexpected exception
Browse files Browse the repository at this point in the history
Example:

```
app = Flask('minimal')
jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True)

class MyException(Exception):
    pass

@jsonrpc.errorhandler(MyException)
def handle_my_exception(ex: MyException) -> t.Dict[str, t.Any]:
    return {'message': 'It is a custom exception', 'code': '0001'}

@jsonrpc.method('App.failsWithCustomException')
def fails_with_custom_exception(_string: t.Optional[str] = None) -> t.NoReturn:
    raise MyException('example of fail with custom exception that will be handled')
````

Note: The implementation does not support a global error handler, only
a local one. In other words, the registration of an error handler will
only be available for the JSON-RPC that has been registered.

Resolve #375
  • Loading branch information
nycholas committed Sep 27, 2024
1 parent 62cbfe7 commit ce46b16
Show file tree
Hide file tree
Showing 16 changed files with 914 additions and 159 deletions.
14 changes: 14 additions & 0 deletions examples/minimal/minimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@
jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True)


class MyException(Exception):
pass


@jsonrpc.errorhandler(MyException)
def handle_my_exception(ex: MyException) -> t.Dict[str, t.Any]:
return {'message': 'It is a custom exception', 'code': '0001'}


@jsonrpc.method('App.index')
def index() -> str:
return 'Welcome to Flask JSON-RPC'
Expand Down Expand Up @@ -82,6 +91,11 @@ def fails(_string: t.Optional[str] = None) -> t.NoReturn:
raise ValueError('example of fail')


@jsonrpc.method('App.failsWithCustomException')
def fails_with_custom_exception(_string: t.Optional[str] = None) -> t.NoReturn:
raise MyException('example of fail with custom exception that will be handled')


@jsonrpc.method('App.sum')
def sum_(a: Real, b: Real) -> Real:
return a + b
Expand Down
32 changes: 32 additions & 0 deletions examples/modular/api/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,43 @@
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import typing as t
from dataclasses import dataclass

from flask_jsonrpc import JSONRPCBlueprint

article = JSONRPCBlueprint('article', __name__)


class ArticleException(Exception):
def __init__(self: t.Self, *args: object) -> None:
super().__init__(*args)


class ArticleNotFoundException(ArticleException):
def __init__(self: t.Self, message: str, article_id: int) -> None:
super().__init__(message)
self.article_id = article_id


@dataclass
class Article:
id: int
name: str


@article.errorhandler(ArticleNotFoundException)
def handle_user_not_found_exception(ex: ArticleNotFoundException) -> t.Dict[str, t.Any]:
return {'message': f'Article {ex.article_id} not found', 'code': '2001'}


@article.method('Article.index')
def index() -> str:
return 'Welcome to Article API'


@article.method('Article.getArticle')
def get_article(id: int) -> Article:
if id > 10:
raise ArticleNotFoundException('Article not found', article_id=id)
return Article(id=id, name='Founded')
40 changes: 40 additions & 0 deletions examples/modular/api/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,51 @@
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import typing as t

from flask_jsonrpc import JSONRPCBlueprint

user = JSONRPCBlueprint('user', __name__)


class UserException(Exception):
def __init__(self: t.Self, *args: object) -> None:
super().__init__(*args)


class UserNotFoundException(UserException):
def __init__(self: t.Self, message: str, user_id: int) -> None:
super().__init__(message)
self.user_id = user_id


class User:
def __init__(self: t.Self, id: int, name: str) -> None:
self.id = id
self.name = name


def handle_user_not_found_exception(ex: UserNotFoundException) -> t.Dict[str, t.Any]:
return {'message': f'User {ex.user_id} not found', 'code': '1001'}


user.register_error_handler(UserNotFoundException, handle_user_not_found_exception)


@user.method('User.index')
def index() -> str:
return 'Welcome to User API'


@user.method('User.getUser')
def get_user(id: int) -> User:
if id > 10:
raise UserNotFoundException('User not found', user_id=id)
return User(id, 'Founded')


@user.method('User.removeUser')
def remove_user(id: int) -> User:
if id > 10:
raise ValueError('User not found')
return User(id, 'Removed')
7 changes: 7 additions & 0 deletions examples/modular/modular.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# isort:skip_file
import typing as t
import os
import sys

Expand All @@ -49,6 +50,12 @@
jsonrpc.register_blueprint(app, user, url_prefix='/user', enable_web_browsable_api=True)
jsonrpc.register_blueprint(app, article, url_prefix='/article', enable_web_browsable_api=True)

jsonrpc.errorhandler(ValueError)


def handle_value_error_exception(ex: ValueError) -> t.Dict[str, t.Any]:
return {'message': 'Generic global error handler does not work, :(', 'code': '0000'}


@jsonrpc.method('App.index')
def index() -> str:
Expand Down
6 changes: 5 additions & 1 deletion src/flask_jsonrpc/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from .globals import default_jsonrpc_site, default_jsonrpc_site_api
from .helpers import urn
from .handlers import JSONRPCErrorHandlerDecoratorMixin
from .wrappers import JSONRPCDecoratorMixin
from .contrib.browse import JSONRPCBrowse

Expand All @@ -46,7 +47,7 @@
from .blueprints import JSONRPCBlueprint


class JSONRPC(JSONRPCDecoratorMixin):
class JSONRPC(JSONRPCDecoratorMixin, JSONRPCErrorHandlerDecoratorMixin):
def __init__(
self: Self,
app: t.Optional[Flask] = None,
Expand Down Expand Up @@ -135,6 +136,9 @@ def register_blueprint(
if app.config['DEBUG'] or enable_web_browsable_api:
self.register_browse(jsonrpc_app)

def register_error_handler(self: Self, exception: t.Type[Exception], fn: t.Callable[[t.Any], t.Any]) -> None:
super().register_error_handler(exception, fn)

def init_browse_app(self: Self, app: Flask, path: t.Optional[str] = None, base_url: t.Optional[str] = None) -> None:
browse_url = self._make_jsonrpc_browse_url(path or self.path)
self.jsonrpc_browse = JSONRPCBrowse(app, url_prefix=browse_url, base_url=base_url or self.base_url)
Expand Down
8 changes: 7 additions & 1 deletion src/flask_jsonrpc/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
# POSSIBILITY OF SUCH DAMAGE.
import typing as t

from flask import typing as ft

from .globals import default_jsonrpc_site, default_jsonrpc_site_api
from .handlers import JSONRPCErrorHandlerDecoratorMixin
from .wrappers import JSONRPCDecoratorMixin

# Python 3.10+
Expand All @@ -40,7 +43,7 @@
from .views import JSONRPCView


class JSONRPCBlueprint(JSONRPCDecoratorMixin):
class JSONRPCBlueprint(JSONRPCDecoratorMixin, JSONRPCErrorHandlerDecoratorMixin):
def __init__(
self: Self,
name: str,
Expand All @@ -58,3 +61,6 @@ def get_jsonrpc_site(self: Self) -> 'JSONRPCSite':

def get_jsonrpc_site_api(self: Self) -> t.Type['JSONRPCView']:
return self.jsonrpc_site_api

def register_error_handler(self: Self, exception: t.Type[Exception], fn: ft.ErrorHandlerCallable) -> None:
super().register_error_handler(exception, fn)
1 change: 0 additions & 1 deletion src/flask_jsonrpc/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ def __init__(
@property
def jsonrpc_format(self: Self) -> t.Dict[str, t.Any]:
"""Return the Exception data in a format for JSON-RPC"""

error = {'name': self.__class__.__name__, 'code': self.code, 'message': self.message, 'data': self.data}

# RuntimeError: Working outside of application context.
Expand Down
53 changes: 53 additions & 0 deletions src/flask_jsonrpc/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright (c) 2020-2024, Cenobit Technologies, Inc. http://cenobit.es/
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of the Cenobit Technologies nor the names of
# its contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import typing as t

# Python 3.10+
try:
from typing import Self
except ImportError: # pragma: no cover
from typing_extensions import Self

if t.TYPE_CHECKING:
from .site import JSONRPCSite


class JSONRPCErrorHandlerDecoratorMixin:
def get_jsonrpc_site(self: Self) -> 'JSONRPCSite':
raise NotImplementedError('.get_jsonrpc_site must be overridden') from None

def register_error_handler(self: Self, exception: t.Type[Exception], fn: t.Callable[[t.Any], t.Any]) -> None:
self.get_jsonrpc_site().register_error_handler(exception, fn)

def errorhandler(
self: Self, exception: t.Type[Exception]
) -> t.Callable[[t.Callable[[t.Any], t.Any]], t.Callable[[t.Any], t.Any]]:
def decorator(fn: t.Callable[[t.Any], t.Any]) -> t.Callable[[t.Any], t.Any]:
self.register_error_handler(exception, fn)
return fn

return decorator
21 changes: 20 additions & 1 deletion src/flask_jsonrpc/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class JSONRPCSite:
def __init__(self: Self, path: t.Optional[str] = None, base_url: t.Optional[str] = None) -> None:
self.path = path
self.base_url = base_url
self.error_handlers: t.Dict[t.Type[Exception], t.Callable[[t.Any], t.Any]] = {}
self.view_funcs: t.OrderedDict[str, t.Callable[..., t.Any]] = OrderedDict()
self.uuid: UUID = uuid4()
self.name: str = 'Flask-JSONRPC'
Expand All @@ -86,6 +87,9 @@ def set_path(self: Self, path: str) -> None:
def set_base_url(self: Self, base_url: t.Optional[str]) -> None:
self.base_url = base_url

def register_error_handler(self: Self, exception: t.Type[Exception], fn: t.Callable[[t.Any], t.Any]) -> None:
self.error_handlers[exception] = fn

def register(self: Self, name: str, view_func: t.Callable[..., t.Any]) -> None:
self.view_funcs[name] = view_func

Expand Down Expand Up @@ -173,6 +177,17 @@ def dispatch(
resp_view = self.handle_view_func(view_func, params)
return self.make_response(req_json, resp_view)

def _find_error_handler(self: Self, exc: Exception) -> t.Optional[t.Callable[[t.Any], t.Any]]:
exc_class = type(exc)
if not self.error_handlers:
return None

for cls in exc_class.__mro__:
handler = self.error_handlers.get(cls)
if handler is not None:
return handler
return None

def handle_dispatch_except(
self: Self, req_json: t.Dict[str, t.Any]
) -> t.Tuple[t.Any, int, t.Union[Headers, t.Dict[str, str], t.Tuple[str], t.List[t.Tuple[str]]]]:
Expand All @@ -190,7 +205,11 @@ def handle_dispatch_except(
return response, e.status_code, JSONRPC_DEFAULT_HTTP_HEADERS
except Exception as e: # pylint: disable=W0703
current_app.logger.exception('unexpected error')
jsonrpc_error = ServerError(data={'message': str(e)})
error_handler = self._find_error_handler(e)
jsonrpc_error_data = (
current_app.ensure_sync(error_handler)(e) if error_handler is not None else {'message': str(e)}
)
jsonrpc_error = ServerError(data=jsonrpc_error_data)
response = {
'id': get(req_json, 'id'),
'jsonrpc': get(req_json, 'jsonrpc', JSONRPC_VERSION_DEFAULT),
Expand Down
Loading

0 comments on commit ce46b16

Please sign in to comment.