Source code for heros.exceptions
"""Exceptions raised by HEROS, in particular those transported from a remote HERO."""
import builtins
import traceback
from typing import Any
#: Marker key of a structured error payload, so that a receiver can tell a serialized
#: exception apart from a plain string (as sent by protocol version <= 1.0).
ERROR_MARKER = "__heros_error__"
[docs]
class HEROSError(Exception):
"""Base class of all HEROS errors."""
[docs]
class RemoteHEROError(HEROSError):
"""An exception that was raised inside a remote HERO while serving a request.
Args:
message: the ``str`` of the remote exception.
remote_type: class name of the remote exception, if the remote sent one.
remote_traceback: formatted traceback from the remote process.
endpoint: the endpoint that was queried.
"""
def __init__(
self,
message: str,
remote_type: str | None = None,
remote_module: str | None = None,
remote_traceback: str | None = None,
endpoint: str | None = None,
):
super().__init__(message)
self.message = message
self.remote_type = remote_type
self.remote_module = remote_module
self.remote_traceback = remote_traceback
self.endpoint = endpoint
[docs]
@classmethod
def from_exception(cls,
exc: BaseException, endpoint: str | None = None):
return RemoteHEROError(
message=str(exc),
remote_type=type(exc).__name__,
remote_module=type(exc).__module__,
remote_traceback="".join(traceback.format_tb(exc.__traceback__)),
endpoint=endpoint
)
[docs]
@classmethod
def from_payload(cls, payload: Any, endpoint: str | None = None):
"""Rebuild a remote exception from a serialized payload.
All payloads that do not have the correct format (dict with error marker) are
wrapped in a bare :class:`RemoteHEROError` so that old HEROs keep working.
"""
if isinstance(payload, dict) and payload.get(ERROR_MARKER):
new_cls = _error_class(payload.get("type"))
return new_cls(
payload.get("message", ""),
remote_type=payload.get("type"),
remote_traceback=payload.get("traceback"),
endpoint=endpoint
)
return RemoteHEROError(str(payload), endpoint=endpoint)
[docs]
def __str__(self) -> str:
head = f"{self.remote_type}: {self.message}" if self.remote_type else self.message
if self.endpoint:
head = f"{head} (raised in HERO at '{self.endpoint}')"
if self.remote_traceback:
head = f"{head}\n\n--- remote traceback ---\n{self.remote_traceback}"
return head
[docs]
def serialize(self) -> dict[str, Any]:
"""Turn an exception into a payload that :func:`exception_from_payload` can rebuild."""
return {
ERROR_MARKER: True,
"type": self.remote_type,
"module": self.remote_module,
"message": self.message,
"traceback": self.remote_traceback
}
[docs]
class RemoteTransportError(HEROSError):
"""The query to a remote HERO failed at transport level (interrupted stream, ...)."""
_error_classes: dict[str, type] = {}
_mapping_blacklist = (RemoteHEROError, KeyboardInterrupt, SystemExit)
[docs]
def _error_class(remote_type: str | None) -> type:
"""Build (and cache) an exception class that is both a ``RemoteHEROError`` and, where
the remote exception is a builtin, that builtin.
This lets user code keep writing ``except ValueError:`` around a remote call while the
HEROS-specific information stays available on the exception object.
"""
if not remote_type:
return RemoteHEROError
if remote_type not in _error_classes:
base = getattr(builtins, remote_type, None)
if isinstance(base, type) and issubclass(base, BaseException) and not issubclass(base, _mapping_blacklist):
try:
_error_classes[remote_type] = type(f"Remote{remote_type}", (RemoteHEROError, base), {})
except TypeError:
# incompatible layout: fall back to the plain remote error
_error_classes[remote_type] = RemoteHEROError
else:
_error_classes[remote_type] = RemoteHEROError
return _error_classes[remote_type]