Skip to content

Commit

Permalink
Make CoreExceptionHandler instance
Browse files Browse the repository at this point in the history
  • Loading branch information
drew2a committed Nov 11, 2021
1 parent 607b0d0 commit d3d976c
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from tribler_common.sentry_reporter.sentry_reporter import SentryReporter

from tribler_core.components.base import ComponentStartupException
from tribler_core.utilities.utilities import froze_it

# There are some errors that we are ignoring.
IGNORED_ERRORS_BY_CODE = {
Expand All @@ -32,31 +31,25 @@
}


@froze_it
class CoreExceptionHandler:
"""
This singleton handles Python errors arising in the Core by catching them, adding necessary context,
This class handles Python errors arising in the Core by catching them, adding necessary context,
and sending them to the GUI through the events endpoint. It must be connected to the Asyncio loop.
"""

_logger = logging.getLogger("CoreExceptionHandler")
report_callback: Optional[Callable[[ReportedError], None]] = None
requires_user_consent: bool = True
def __init__(self):
self.logger = logging.getLogger("CoreExceptionHandler")
self.report_callback: Optional[Callable[[ReportedError], None]] = None
self.requires_user_consent: bool = True

@staticmethod
def _get_long_text_from(exception: Exception):
with StringIO() as buffer:
print_exception(type(exception), exception, exception.__traceback__, file=buffer)
return buffer.getvalue()

@classmethod
def _create_exception_from(cls, message: str):
text = f'Received error without exception: {message}'
cls._logger.warning(text)
return Exception(text)

@classmethod
def _is_ignored(cls, exception: Exception):
@staticmethod
def _is_ignored(exception: Exception):
exception_class = exception.__class__
error_number = exception.errno if hasattr(exception, 'errno') else None

Expand All @@ -69,44 +62,51 @@ def _is_ignored(cls, exception: Exception):
pattern = IGNORED_ERRORS_BY_REGEX[exception_class]
return re.search(pattern, str(exception)) is not None

@classmethod
def unhandled_error_observer(cls, loop, context): # pylint: disable=unused-argument
def _create_exception_from(self, message: str):
text = f'Received error without exception: {message}'
self.logger.warning(text)
return Exception(text)

def unhandled_error_observer(self, _, context):
"""
This method is called when an unhandled error in Tribler is observed.
It broadcasts the tribler_exception event.
"""
try:
SentryReporter.ignore_logger(cls._logger.name)
SentryReporter.ignore_logger(self.logger.name)

should_stop = True
context = context.copy()
message = context.pop('message', 'no message')
exception = context.pop('exception', None) or cls._create_exception_from(message)
exception = context.pop('exception', None) or self._create_exception_from(message)
# Exception
text = str(exception)
if isinstance(exception, ComponentStartupException):
should_stop = exception.component.tribler_should_stop_on_component_error
exception = exception.__cause__

if cls._is_ignored(exception):
cls._logger.warning(exception)
if self._is_ignored(exception):
self.logger.warning(exception)
return

long_text = cls._get_long_text_from(exception)
cls._logger.error(f"Unhandled exception occurred! {exception}\n{long_text}")
long_text = self._get_long_text_from(exception)
self.logger.error(f"Unhandled exception occurred! {exception}\n{long_text}")

reported_error = ReportedError(
type=exception.__class__.__name__,
text=text,
long_text=long_text,
context=str(context),
event=SentryReporter.event_from_exception(exception) or {},
requires_user_consent=cls.requires_user_consent,
requires_user_consent=self.requires_user_consent,
should_stop=should_stop
)
if cls.report_callback:
cls.report_callback(reported_error) # pylint: disable=not-callable
if self.report_callback:
self.report_callback(reported_error) # pylint: disable=not-callable

except Exception as ex:
SentryReporter.capture_exception(ex)
raise ex


default_core_exception_handler = CoreExceptionHandler()
Original file line number Diff line number Diff line change
Expand Up @@ -5,67 +5,71 @@

from tribler_common.sentry_reporter import sentry_reporter
from tribler_common.sentry_reporter.sentry_reporter import SentryReporter

from tribler_core.components.reporter.exception_handler import CoreExceptionHandler

pytestmark = pytest.mark.asyncio


# pylint: disable=protected-access
# pylint: disable=protected-access, redefined-outer-name
# fmt: off

@pytest.fixture
def exception_handler():
return CoreExceptionHandler()


def raise_error(error): # pylint: disable=inconsistent-return-statements
try:
raise error
except error.__class__ as e:
return e


async def test_is_ignored():
async def test_is_ignored(exception_handler):
# test that CoreExceptionHandler ignores specific exceptions

# by type
assert CoreExceptionHandler._is_ignored(OSError(113, 'Any'))
assert CoreExceptionHandler._is_ignored(ConnectionResetError(10054, 'Any'))
assert exception_handler._is_ignored(OSError(113, 'Any'))
assert exception_handler._is_ignored(ConnectionResetError(10054, 'Any'))

# by class
assert CoreExceptionHandler._is_ignored(gaierror('Any'))
assert exception_handler._is_ignored(gaierror('Any'))

# by class and substring
assert CoreExceptionHandler._is_ignored(RuntimeError('Message that contains invalid info-hash'))
assert exception_handler._is_ignored(RuntimeError('Message that contains invalid info-hash'))


async def test_is_not_ignored():
async def test_is_not_ignored(exception_handler):
# test that CoreExceptionHandler do not ignore exceptions out of
# IGNORED_ERRORS_BY_CODE and IGNORED_ERRORS_BY_SUBSTRING
assert not CoreExceptionHandler._is_ignored(OSError(1, 'Any'))
assert not CoreExceptionHandler._is_ignored(RuntimeError('Any'))
assert not CoreExceptionHandler._is_ignored(AttributeError())
assert not exception_handler._is_ignored(OSError(1, 'Any'))
assert not exception_handler._is_ignored(RuntimeError('Any'))
assert not exception_handler._is_ignored(AttributeError())


async def test_create_exception_from():
async def test_create_exception_from(exception_handler):
# test that CoreExceptionHandler can create an Exception from a string
assert isinstance(CoreExceptionHandler._create_exception_from('Any'), Exception)
assert isinstance(exception_handler._create_exception_from('Any'), Exception)


async def test_get_long_text_from():
async def test_get_long_text_from(exception_handler):
# test that CoreExceptionHandler can generate stacktrace from an Exception
error = raise_error(AttributeError('Any'))
actual_string = CoreExceptionHandler._get_long_text_from(error)
actual_string = exception_handler._get_long_text_from(error)
assert 'raise_error' in actual_string


@patch(f'{sentry_reporter.__name__}.{SentryReporter.__name__}.{SentryReporter.event_from_exception.__name__}',
new=MagicMock(return_value={'sentry': 'event'}))
async def test_unhandled_error_observer_exception():
async def test_unhandled_error_observer_exception(exception_handler):
# test that unhandled exception, represented by Exception, reported to the GUI
context = {'exception': raise_error(AttributeError('Any')), 'Any key': 'Any value'}
CoreExceptionHandler.report_callback = MagicMock()
CoreExceptionHandler.unhandled_error_observer(None, context)
CoreExceptionHandler.report_callback.assert_called()
exception_handler.report_callback = MagicMock()
exception_handler.unhandled_error_observer(None, context)
exception_handler.report_callback.assert_called()

# get the argument that has been passed to the report_callback
reported_error = CoreExceptionHandler.report_callback.call_args_list[-1][0][0]
reported_error = exception_handler.report_callback.call_args_list[-1][0][0]
assert reported_error.type == 'AttributeError'
assert reported_error.text == 'Any'
assert 'raise_error' in reported_error.long_text
Expand All @@ -75,15 +79,15 @@ async def test_unhandled_error_observer_exception():
assert reported_error.requires_user_consent


async def test_unhandled_error_observer_only_message():
async def test_unhandled_error_observer_only_message(exception_handler):
# test that unhandled exception, represented by message, reported to the GUI
context = {'message': 'Any'}
CoreExceptionHandler.report_callback = MagicMock()
CoreExceptionHandler.unhandled_error_observer(None, context)
CoreExceptionHandler.report_callback.assert_called()
exception_handler.report_callback = MagicMock()
exception_handler.unhandled_error_observer(None, context)
exception_handler.report_callback.assert_called()

# get the argument that has been passed to the report_callback
reported_error = CoreExceptionHandler.report_callback.call_args_list[-1][0][0]
reported_error = exception_handler.report_callback.call_args_list[-1][0][0]
assert reported_error.type == 'Exception'
assert reported_error.text == 'Received error without exception: Any'
assert reported_error.long_text == 'Exception: Received error without exception: Any\n'
Expand All @@ -93,11 +97,11 @@ async def test_unhandled_error_observer_only_message():
assert reported_error.requires_user_consent


async def test_unhandled_error_observer_ignored():
async def test_unhandled_error_observer_ignored(exception_handler):
# test that exception from list IGNORED_ERRORS_BY_CODE never sends to the GUI
context = {'exception': OSError(113, '')}
CoreExceptionHandler.report_callback = MagicMock()
with patch.object(CoreExceptionHandler._logger, 'warning') as mocked_warning:
CoreExceptionHandler.unhandled_error_observer(None, context)
exception_handler.report_callback = MagicMock()
with patch.object(exception_handler.logger, 'warning') as mocked_warning:
exception_handler.unhandled_error_observer(None, context)
mocked_warning.assert_called_once()
CoreExceptionHandler.report_callback.assert_not_called()
exception_handler.report_callback.assert_not_called()
7 changes: 4 additions & 3 deletions src/tribler-core/tribler_core/start_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from tribler_core.components.metadata_store.metadata_store_component import MetadataStoreComponent
from tribler_core.components.payout.payout_component import PayoutComponent
from tribler_core.components.popularity.popularity_component import PopularityComponent
from tribler_core.components.reporter.exception_handler import CoreExceptionHandler
from tribler_core.components.reporter.exception_handler import CoreExceptionHandler, default_core_exception_handler
from tribler_core.components.reporter.reporter_component import ReporterComponent
from tribler_core.components.resource_monitor.resource_monitor_component import ResourceMonitorComponent
from tribler_core.components.restapi.restapi_component import RESTComponent
Expand Down Expand Up @@ -172,8 +172,9 @@ def start_tribler_core(base_path, api_port, api_key, root_state_dir, gui_test_mo
asyncio.set_event_loop(asyncio.SelectorEventLoop())

loop = asyncio.get_event_loop()
CoreExceptionHandler.requires_user_consent = config.error_handling.core_error_reporting_requires_user_consent
loop.set_exception_handler(CoreExceptionHandler.unhandled_error_observer)
exception_handler = default_core_exception_handler
exception_handler.requires_user_consent = config.error_handling.core_error_reporting_requires_user_consent
loop.set_exception_handler(exception_handler.unhandled_error_observer)

loop.run_until_complete(core_session(config, components=list(components_gen(config))))

Expand Down

0 comments on commit d3d976c

Please sign in to comment.