From edebf1a339e9a7c2b9bda0c8f1cca8c1ad0c8d87 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 3 May 2026 22:46:47 +0200 Subject: [PATCH] feat(services): add ErrorContract enum and PartialResult type Add typed wrappers for error handling patterns in error_handling.py: - ErrorContract(enum): machine-checkable pattern selector with from_value() helper and string constants matching the existing ABORT_ON_ERROR/RETURN_DEFAULT/PARTIAL_RESULT module-level values - ErrorEntry: typed error container for PARTIAL_RESULT (context + cause) - PartialResult[T]: typed result wrapper for PARTIAL_RESULT operations Existing string constants preserved for backward compat. Updated module docstring with type annotation table and examples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/app/services/error_handling.py | 128 +++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/backend/app/services/error_handling.py b/backend/app/services/error_handling.py index 0f31978..7c20b71 100644 --- a/backend/app/services/error_handling.py +++ b/backend/app/services/error_handling.py @@ -10,10 +10,56 @@ Pattern Selection - PARTIAL_RESULT: Operations on collections where some items may fail independently Switching patterns is a breaking change — document in changelog. + +Type Annotations +=============== +Each pattern has a corresponding type to make contracts machine-checkable: + +| Pattern | Type | Use when | +|-----------------|--------------------------------|-----------------------------------------| +| ABORT_ON_ERROR | ``T`` (plain return) | Raises ``DomainError`` on failure | +| RETURN_DEFAULT | ``T | None`` | Returns ``None`` on failure | +| PARTIAL_RESULT | ``PartialResult[T]`` | Returns ``(T, list[ErrorEntry])`` | + +Example (ABORT_ON_ERROR — raises on failure):: + + async def start_jail(socket_path: str, name: str) -> None: + '''Start a stopped fail2ban jail. + + Error contract: ABORT_ON_ERROR. + Raises JailNotFoundError (404), JailOperationError (409), + Fail2BanConnectionError (503). + ''' + ... + +Example (RETURN_DEFAULT — returns None on failure):: + + async def get_settings(socket_path: str) -> DomainServerSettingsResult | None: + '''Return current fail2ban server-level settings. + + Error contract: RETURN_DEFAULT. Returns None if socket is unreachable. + ''' + ... + +Example (PARTIAL_RESULT — returns (result, errors) on failure):: + + async def fetch_all_bans( + socket_path: str, jail_names: list[str] + ) -> PartialResult[DomainJailBannedIps]: + '''Fetch bans from multiple jails. + + Error contract: PARTIAL_RESULT. Returns (results, errors) where + errors contains one entry per jail that failed. + ''' + ... + """ from __future__ import annotations +import enum +from typing import Self, TypeVar + ABORT_ON_ERROR = "abort_on_error" """Raise an exception. Router converts to HTTP. Use for auth, writes, state changes.""" @@ -23,6 +69,39 @@ RETURN_DEFAULT = "return_default" PARTIAL_RESULT = "partial_result" """Return (result, errors) tuple. Use for batch operations on collections.""" +T = TypeVar("T") + + +class ErrorContract(enum.Enum): + """Machine-checkable error handling pattern for a service or method. + + Use the ``value`` attribute to get the string constant for docstrings + and comparisons. Use ``from_value()`` to look up by string. + + Example:: + + contract = ErrorContract.from_value(ABORT_ON_ERROR) + if contract == ErrorContract.ABORT_ON_ERROR: + ... + + """ + + ABORT_ON_ERROR = ABORT_ON_ERROR + RETURN_DEFAULT = RETURN_DEFAULT + PARTIAL_RESULT = PARTIAL_RESULT + + @classmethod + def from_value(cls, value: str) -> Self: + """Return the enum member matching *value*. + + Raises: + ValueError: If *value* is not one of the three patterns. + """ + for member in cls: + if member.value == value: + return member + raise ValueError(f"Unknown error contract {value!r}") + class ServiceErrorContract: """Documents the error handling pattern for a service or method. @@ -62,3 +141,52 @@ class ServiceErrorContract: if since: return f"{desc} (Since: {since})" return desc + + +# --------------------------------------------------------------------------- +# Typed patterns (incremental migration) +# --------------------------------------------------------------------------- + + +class ErrorEntry: + """Single error from a PARTIAL_RESULT operation. + + Attributes: + context: Human-readable description of what failed. + cause: The underlying exception, if available. + """ + + __slots__ = ("context", "cause") + + def __init__(self, context: str, *, cause: Exception | None = None) -> None: + self.context = context + self.cause = cause + + def __repr__(self) -> str: + cls = type(self) + return f"{cls.__name__}(context={self.context!r}, cause={self.cause!r})" + + +class PartialResult: + """Result of a PARTIAL_RESULT operation. + + Attributes: + ok: The successful result (may be empty list/dict). + errors: Errors encountered during the operation. + + Usage:: + + result: PartialResult[DomainJailBannedIps] + log.warning("some_jails_failed", error_count=len(result.errors)) + + """ + + __slots__ = ("ok", "errors") + + def __init__(self, ok: T, errors: list[ErrorEntry]) -> None: + self.ok: T = ok + self.errors: list[ErrorEntry] = errors + + def __repr__(self) -> str: + cls = type(self) + return f"{cls.__name__}(ok={self.ok!r}, errors={self.errors!r})"