aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ouroboros/errors.py246
1 files changed, 246 insertions, 0 deletions
diff --git a/ouroboros/errors.py b/ouroboros/errors.py
new file mode 100644
index 0000000..aa1e174
--- /dev/null
+++ b/ouroboros/errors.py
@@ -0,0 +1,246 @@
+#
+# Ouroboros - Copyright (C) 2016 - 2026
+#
+# Python API for Ouroboros - Exceptions and errno translation
+#
+# Dimitri Staessens <dimitri@ouroboros.rocks>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public License
+# version 2.1 as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., http://www.fsf.org/about/contact/.
+#
+
+"""Exception hierarchy and errno translation for pyouroboros.
+
+The hierarchy follows stdlib precedent (``socket``, ``ssl``, ``json``):
+a single root :class:`OuroborosError`, semantically-named subclasses,
+and multiple inheritance from Python builtins where the semantics
+match (e.g. :class:`FlowTimeout` is a :class:`TimeoutError`).
+"""
+
+from __future__ import annotations
+
+import errno
+import os
+from importlib.metadata import PackageNotFoundError, version
+
+# Ouroboros-specific errno values, mirroring include/ouroboros/errno.h.
+# Imported here (rather than via CFFI) so this module stays FFI-free
+ENOTALLOC = 1000 # Flow is not allocated
+EIPCPTYPE = 1001 # Unknown IPCP type
+EIRMD = 1002 # Failed to communicate with IRMD
+EIPCP = 1003 # Failed to communicate with IPCP
+EIPCPSTATE = 1004 # Target in wrong state
+EFLOWDOWN = 1005 # Flow is down
+EFLOWPEER = 1006 # Flow is down (peer timed out)
+ENAME = 1007 # Naming error
+ECRYPT = 1008 # Encryption error
+EAUTH = 1009 # Authentication error
+EREPLAY = 1010 # OAP replay detected
+
+
+# --- Root ---
+
+class OuroborosError(Exception):
+ """Base class for all pyouroboros exceptions."""
+
+
+# --- IRM-side ---
+
+class IrmError(OuroborosError):
+ """Base class for IRM (control plane) errors."""
+
+
+class IpcpCreateError(IrmError):
+ """Raised when IPCP creation fails."""
+
+
+class IpcpBootstrapError(IrmError):
+ """Raised when IPCP bootstrapping fails."""
+
+
+class IpcpEnrollError(IrmError):
+ """Raised when IPCP enrollment fails."""
+
+
+class IpcpConnectError(IrmError):
+ """Raised when IPCP connection or disconnection fails."""
+
+
+class IpcpTypeError(IrmError, ValueError):
+ """Raised when an unknown IPCP type is encountered."""
+
+
+class IpcpStateError(IrmError):
+ """Raised when an IPCP/IRMd is in the wrong state for the request."""
+
+
+class IrmdError(IrmError):
+ """Raised when the IRM daemon is unreachable."""
+
+
+class IpcpdError(IrmError):
+ """Raised when the IPCP daemon is unreachable."""
+
+
+class BindError(IrmError):
+ """Raised when binding a program/process/IPCP to a name fails."""
+
+
+class NameNotFoundError(IrmError):
+ """Raised when a name lookup or unregister target does not exist."""
+
+
+class NameExistsError(IrmError):
+ """Raised when a name already exists."""
+
+
+class InvalidNameError(IrmError, ValueError):
+ """Raised when a name is malformed or otherwise invalid."""
+
+
+# --- Flow / dev-side ---
+
+class FlowError(OuroborosError):
+ """Base class for flow-plane errors."""
+
+
+class FlowAlreadyAllocatedError(FlowError):
+ """Raised when allocating on a Flow that already holds an fd."""
+
+
+class FlowNotAllocatedError(FlowError):
+ """Raised when operating on a Flow that has no fd."""
+
+
+class FlowDownError(FlowError, ConnectionError):
+ """Raised when the flow has gone down."""
+
+
+class FlowPeerError(FlowDownError):
+ """Raised when the flow's peer has timed out."""
+
+
+class FlowPermissionError(FlowError, PermissionError):
+ """Raised when an operation is not permitted on the flow."""
+
+
+class FlowTimeout(FlowError, TimeoutError):
+ """Raised when a flow operation times out."""
+
+
+class FlowCryptError(FlowError):
+ """Raised on flow encryption errors."""
+
+
+class FlowAuthError(FlowError):
+ """Raised on flow authentication errors."""
+
+
+class FlowReplayError(FlowError):
+ """Raised when OAP replay is detected."""
+
+
+class FlowEventError(FlowError):
+ """Raised on errors from the flow event subsystem."""
+
+
+class FlowDeallocWarning(Warning):
+ """Warning issued when a deallocation reports a non-fatal problem."""
+
+
+# --- errno translation ---
+
+# Errno -> Python exception class. Errnos are in their canonical
+# positive form here; raise_errno() flips the sign.
+#
+# Mapped errnos override the caller's `default=` argument, so we only
+# map errnos whose semantics are unambiguous across both flow and IRM
+# contexts. EPERM and EINVAL are intentionally NOT mapped: they can
+# mean very different things on different operations, and a global
+# mapping would silently override the call-site's chosen default (e.g.
+# masking IpcpBootstrapError as a bare ValueError).
+_ERRNO_MAP: dict[int, type[BaseException]] = {
+ errno.ETIMEDOUT: TimeoutError,
+ errno.EAGAIN: BlockingIOError,
+ errno.EWOULDBLOCK: BlockingIOError,
+ errno.ENOMEM: MemoryError,
+ errno.EACCES: PermissionError,
+ errno.ENOTCONN: ConnectionError,
+ errno.ECONNRESET: ConnectionResetError,
+ ENOTALLOC: FlowNotAllocatedError,
+ EIPCPTYPE: IpcpTypeError,
+ EIRMD: IrmdError,
+ EIPCP: IpcpdError,
+ EIPCPSTATE: IpcpStateError,
+ EFLOWDOWN: FlowDownError,
+ EFLOWPEER: FlowPeerError,
+ ENAME: NameNotFoundError,
+ ECRYPT: FlowCryptError,
+ EAUTH: FlowAuthError,
+ EREPLAY: FlowReplayError,
+}
+
+
+def _strerror(err: int) -> str:
+ if err >= 1000:
+ return _O7S_ERRNO_STR.get(err, f"ouroboros errno {err}")
+ return os.strerror(err)
+
+
+_O7S_ERRNO_STR: dict[int, str] = {
+ ENOTALLOC: "flow is not allocated",
+ EIPCPTYPE: "unknown IPCP type",
+ EIRMD: "failed to communicate with IRMd",
+ EIPCP: "failed to communicate with IPCP",
+ EIPCPSTATE: "target in wrong state",
+ EFLOWDOWN: "flow is down",
+ EFLOWPEER: "flow peer timed out",
+ ENAME: "naming error",
+ ECRYPT: "encryption error",
+ EAUTH: "authentication error",
+ EREPLAY: "OAP replay detected",
+}
+
+
+def raise_errno(rc: int, default: type[OuroborosError] = FlowError) -> int:
+ """Translate a non-negative-or-negative-errno return code.
+
+ Returns *rc* unchanged when it is non-negative. Otherwise raises
+ the mapped exception (or *default* if no mapping exists).
+ """
+ if rc >= 0:
+ return rc
+ err = -rc
+ exc_cls = _ERRNO_MAP.get(err, default)
+ raise exc_cls(_strerror(err))
+
+
+def check_ouroboros_version(o7s_major: int, o7s_minor: int) -> None:
+ """Verify that the installed pyouroboros matches the linked library.
+
+ Compares *o7s_major*/*o7s_minor* (from
+ ``OUROBOROS_VERSION_{MAJOR,MINOR}`` in the CFFI module) against
+ the installed pyouroboros distribution metadata. Silently returns
+ when running from a source tree without distribution metadata.
+ """
+ try:
+ pyo7s_parts = version("PyOuroboros").split(".")
+ except PackageNotFoundError:
+ return
+ if o7s_major != int(pyo7s_parts[0]) or \
+ o7s_minor != int(pyo7s_parts[1]):
+ raise RuntimeError(
+ f"Ouroboros version mismatch: library is "
+ f"{o7s_major}.{o7s_minor}, pyouroboros is "
+ f"{pyo7s_parts[0]}.{pyo7s_parts[1]}"
+ )