Skip to content

Errors

In Pydantic you can define a model as a Union of possible models: whichever validates first – that one gets returned. For us, it means that you can simply Union[...] all possible successful and error models – and use the Union as a return type.

Discriminated unions

Consider using discriminated unions to define error models for different error codes.

In addition, Combadge provides some base response classes, which come in handy:

combadge.core.response.BaseResponse

Base model representing any possible service response.

It's got a few abstract methods, which are then implemented by SuccessfulResponse and ErrorResponse.

Notes
  • BaseResponse is the lower-level API, users should consider inheriting from SuccessfulResponse and ErrorResponse.
Source code in combadge/core/response.py
class BaseResponse(ABC, BaseModel):
    """
    Base model representing any possible service response.

    It's got a few abstract methods, which are then implemented by `SuccessfulResponse` and `ErrorResponse`.

    Notes:
        - `#!python BaseResponse` is the lower-level API,
          users should consider inheriting from `#!python SuccessfulResponse` and `#!python ErrorResponse`.
    """

    @abstractmethod
    def raise_for_result(self, exception: BaseException | None = None) -> None | Never:
        """
        Raise an exception if the service call has failed.

        Raises:
            ErrorResponse.Error: an error derived from `ErrorResponse`

        Returns:
            always `#!python None`

        Tip: Calling `raise_for_result()` is always possible
            `#!python BaseResponse`, `#!python SuccessfulResponse`, and
            `#!python ErrorResponse` are designed so that calling `raise_for_result()` on **any**
            response would either result in an exception, or guarantee a valid successful response otherwise.
        """
        raise NotImplementedError

    @abstractmethod
    def unwrap(self) -> Self | Never:
        """
        Return a response if the call was successful, raise an exception otherwise.

        This method allows «unpacking» a response with proper type hinting.
        The trick here is that all error responses' `unwrap()` are annotated with `Never`,
        which suggests a type linter, that `unwrap()` may never return an error.

        Tip: Calling `unwrap()` is always possible
            `#!python BaseResponse`, `#!python SuccessfulResponse`, and
            `#!python ErrorResponse` are designed so that calling `unwrap()` on **any**
            response would either result in an exception, or return a valid successful response otherwise.

            Futhermore, Mypy should be able to figure out the correct type afterwards.

        Examples:
            >>> class MyResponse(SuccessfulResponse): ...
            >>>
            >>> class MyErrorResponse(ErrorResponse): ...
            >>>
            >>> class Service(Protocol):
            >>>     def call(self) -> MyResponse | MyErrorResponse: ...
            >>>
            >>> service: Service
            >>>
            >>> assert_type(service.call(), Union[MyResponse, MyErrorResponse])
            >>> assert_type(service.call().unwrap(), MyResponse)

        Raises:
            ErrorResponse.Error: an error derived from `ErrorResponse`

        Returns:
            returns `self` by default, may be overridden in user response models
        """
        raise NotImplementedError

raise_for_result abstractmethod

raise_for_result(exception: BaseException | None = None) -> None | Never

Raise an exception if the service call has failed.

Raises:

Type Description
Error

an error derived from ErrorResponse

Returns:

Type Description
None | Never

always None

Calling raise_for_result() is always possible

BaseResponse, SuccessfulResponse, and ErrorResponse are designed so that calling raise_for_result() on any response would either result in an exception, or guarantee a valid successful response otherwise.

Source code in combadge/core/response.py
@abstractmethod
def raise_for_result(self, exception: BaseException | None = None) -> None | Never:
    """
    Raise an exception if the service call has failed.

    Raises:
        ErrorResponse.Error: an error derived from `ErrorResponse`

    Returns:
        always `#!python None`

    Tip: Calling `raise_for_result()` is always possible
        `#!python BaseResponse`, `#!python SuccessfulResponse`, and
        `#!python ErrorResponse` are designed so that calling `raise_for_result()` on **any**
        response would either result in an exception, or guarantee a valid successful response otherwise.
    """
    raise NotImplementedError

unwrap abstractmethod

unwrap() -> Self | Never

Return a response if the call was successful, raise an exception otherwise.

This method allows «unpacking» a response with proper type hinting. The trick here is that all error responses' unwrap() are annotated with Never, which suggests a type linter, that unwrap() may never return an error.

Calling unwrap() is always possible

BaseResponse, SuccessfulResponse, and ErrorResponse are designed so that calling unwrap() on any response would either result in an exception, or return a valid successful response otherwise.

Futhermore, Mypy should be able to figure out the correct type afterwards.

Examples:

>>> class MyResponse(SuccessfulResponse): ...
>>>
>>> class MyErrorResponse(ErrorResponse): ...
>>>
>>> class Service(Protocol):
>>>     def call(self) -> MyResponse | MyErrorResponse: ...
>>>
>>> service: Service
>>>
>>> assert_type(service.call(), Union[MyResponse, MyErrorResponse])
>>> assert_type(service.call().unwrap(), MyResponse)

Raises:

Type Description
Error

an error derived from ErrorResponse

Returns:

Type Description
Self | Never

returns self by default, may be overridden in user response models

Source code in combadge/core/response.py
@abstractmethod
def unwrap(self) -> Self | Never:
    """
    Return a response if the call was successful, raise an exception otherwise.

    This method allows «unpacking» a response with proper type hinting.
    The trick here is that all error responses' `unwrap()` are annotated with `Never`,
    which suggests a type linter, that `unwrap()` may never return an error.

    Tip: Calling `unwrap()` is always possible
        `#!python BaseResponse`, `#!python SuccessfulResponse`, and
        `#!python ErrorResponse` are designed so that calling `unwrap()` on **any**
        response would either result in an exception, or return a valid successful response otherwise.

        Futhermore, Mypy should be able to figure out the correct type afterwards.

    Examples:
        >>> class MyResponse(SuccessfulResponse): ...
        >>>
        >>> class MyErrorResponse(ErrorResponse): ...
        >>>
        >>> class Service(Protocol):
        >>>     def call(self) -> MyResponse | MyErrorResponse: ...
        >>>
        >>> service: Service
        >>>
        >>> assert_type(service.call(), Union[MyResponse, MyErrorResponse])
        >>> assert_type(service.call().unwrap(), MyResponse)

    Raises:
        ErrorResponse.Error: an error derived from `ErrorResponse`

    Returns:
        returns `self` by default, may be overridden in user response models
    """
    raise NotImplementedError

That looks rusty, huh

The resemblance with Rust is not concidential: the author was inspired by std::result::Result.


combadge.core.response.SuccessfulResponse

Parent model for successful responses.

Users should not use it directly, but inherit their response models from it.

Source code in combadge/core/response.py
class SuccessfulResponse(BaseResponse):
    """
    Parent model for successful responses.

    Users should not use it directly, but inherit their response models from it.
    """

    def raise_for_result(self, exception: BaseException | None = None) -> None:
        """
        Do nothing.

        This call is a no-op since the response is successful by definition.
        """

    def unwrap(self) -> Self:
        """Return the response since there's no error by definition."""
        return self

raise_for_result

raise_for_result(exception: BaseException | None = None) -> None

Do nothing.

This call is a no-op since the response is successful by definition.

Source code in combadge/core/response.py
def raise_for_result(self, exception: BaseException | None = None) -> None:
    """
    Do nothing.

    This call is a no-op since the response is successful by definition.
    """

unwrap

unwrap() -> Self

Return the response since there's no error by definition.

Source code in combadge/core/response.py
def unwrap(self) -> Self:
    """Return the response since there's no error by definition."""
    return self


combadge.core.response.ErrorResponse

Parent model for error responses.

Users should not use it directly, but inherit their response models from it.

Source code in combadge/core/response.py
class ErrorResponse(BaseResponse, ABC):
    """
    Parent model for error responses.

    Users should not use it directly, but inherit their response models from it.
    """

    Error: ClassVar[type[_BaseDerivedError]] = _BaseDerivedError
    """
    Dynamically derived exception class.

    For each model inherited from `ErrorResponse` Combadge generates an exception
    class, which is accessible through the `<ModelClass>.Error` attribute.

    Examples:
        >>> class InvalidInput(ErrorResponse):
        >>>     code: Literal["INVALID_INPUT"]
        >>>
        >>> try:
        >>>     service.call(...).raise_for_result()
        >>> except InvalidInput.Error:
        >>>     ...

    Note: Why dynamically constructed class?
        The problem with Pydantic is that you can't inherit from `BaseModel` and `Exception`
        at the same time. Thus, Combadge dynamically constructs a derived exception class,
        which is available via the class attribute and raised by `raise_for_result()` and `unwrap()`.
    """

    def __init_subclass__(cls, exception_bases: Iterable[type[BaseException]] = (), **kwargs: Any) -> None:
        """
        Build the derived exception class.

        Args:
            exception_bases: additional bases for the derived exception class
            kwargs: forwarded to the superclass
        """

        super().__init_subclass__(**kwargs)

        exception_bases = (
            # Inherit from the parent models' errors:
            *(base.Error for base in cls.__bases__ if issubclass(base, ErrorResponse)),
            # And from the user-provided ones:
            *exception_bases,
        )

        class DerivedException(*exception_bases):  # type: ignore[misc]
            """
            Derived exception class.

            Notes:
                - This docstring is overridden by the corresponding model docstring.
            """

        DerivedException.__module__ = cls.__module__
        DerivedException.__name__ = f"{cls.__name__}.Error"
        DerivedException.__qualname__ = f"{cls.__qualname__}.Error"
        DerivedException.__doc__ = cls.__doc__ or DerivedException.__doc__
        cls.Error = DerivedException

    def raise_for_result(self, exception: BaseException | None = None) -> Never:
        """
        Raise the derived exception.

        Args:
            exception: if set, raise the specified exception instead of the derived one.

        Raises:
            Self.Error: derived error
        """
        if not exception:
            raise self.Error(self)
        raise exception from self.as_exception()

    def unwrap(self) -> Never:
        """
        Raise the derived exception.

        Raises:
            Self.Error: derived error
        """
        raise self.as_exception()

    def as_exception(self) -> _BaseDerivedError:
        """
        Return the derived exception without raising it.

        Convenience wrapper around `self.Error(self)` as the exception class requires the response as an argument.
        """
        return self.Error(self)

Error class-attribute

Error: type[_BaseDerivedError] = _BaseDerivedError

Dynamically derived exception class.

For each model inherited from ErrorResponse Combadge generates an exception class, which is accessible through the <ModelClass>.Error attribute.

Examples:

>>> class InvalidInput(ErrorResponse):
>>>     code: Literal["INVALID_INPUT"]
>>>
>>> try:
>>>     service.call(...).raise_for_result()
>>> except InvalidInput.Error:
>>>     ...
Why dynamically constructed class?

The problem with Pydantic is that you can't inherit from BaseModel and Exception at the same time. Thus, Combadge dynamically constructs a derived exception class, which is available via the class attribute and raised by raise_for_result() and unwrap().

raise_for_result

raise_for_result(exception: BaseException | None = None) -> Never

Raise the derived exception.

Parameters:

Name Type Description Default
exception BaseException | None

if set, raise the specified exception instead of the derived one.

None

Raises:

Type Description
Error

derived error

Source code in combadge/core/response.py
def raise_for_result(self, exception: BaseException | None = None) -> Never:
    """
    Raise the derived exception.

    Args:
        exception: if set, raise the specified exception instead of the derived one.

    Raises:
        Self.Error: derived error
    """
    if not exception:
        raise self.Error(self)
    raise exception from self.as_exception()

unwrap

unwrap() -> Never

Raise the derived exception.

Raises:

Type Description
Error

derived error

Source code in combadge/core/response.py
def unwrap(self) -> Never:
    """
    Raise the derived exception.

    Raises:
        Self.Error: derived error
    """
    raise self.as_exception()