diff --git a/README.rst b/README.rst index ecb0cb05..9f5c8cee 100644 --- a/README.rst +++ b/README.rst @@ -61,6 +61,10 @@ Contents * `Asynchronous Usage`_ + * `Error Handling`_ + + * `Validation errors`_ + * `Requests without a Workspace in Scope`_ * `Personal Access Token without a Workspace`_ @@ -486,6 +490,38 @@ and ``flatten`` returns an async generator. The ``AsyncSeamWithoutWorkspace`` client is the equivalent async variant of ``SeamWithoutWorkspace``. +Error Handling +~~~~~~~~~~~~~~ + +Requests rejected by the Seam API raise a ``SeamHttpApiError`` subclass +carrying the HTTP ``status_code``, API error ``code``, and ``request_id``. + +Validation errors +^^^^^^^^^^^^^^^^^ + +When the API rejects a request because a parameter is invalid, it raises a +``SeamHttpInvalidInputError``. Look up messages for a parameter you are already +rendering, for example a field in a form: + +.. code-block:: python + + from seam import SeamHttpInvalidInputError + + try: + seam.devices.list(device_ids=["not-a-uuid"]) + except SeamHttpInvalidInputError as error: + print(error.get_validation_error_messages("device_ids")) + +Or read every parameter that failed validation to summarize the request: + +.. code-block:: python + + for validation_error in error.validation_errors: + print( + f"{validation_error.parameter_name}: " + f"{', '.join(validation_error.error_messages)}" + ) + Requests without a Workspace in Scope ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/seam/__init__.py b/seam/__init__.py index 73fa3bf8..2a382784 100644 --- a/seam/__init__.py +++ b/seam/__init__.py @@ -9,6 +9,7 @@ SeamHttpApiError, SeamHttpUnauthorizedError, SeamHttpInvalidInputError, + SeamValidationError, SeamActionAttemptError, SeamActionAttemptFailedError, SeamActionAttemptTimeoutError, diff --git a/seam/exceptions.py b/seam/exceptions.py index 7e7c2327..afdec1e8 100644 --- a/seam/exceptions.py +++ b/seam/exceptions.py @@ -1,7 +1,16 @@ +from dataclasses import dataclass from typing import Any, Dict, List, Optional from .resources import ActionAttempt +@dataclass(frozen=True) +class SeamValidationError: + """A request parameter that failed validation and its error messages.""" + + parameter_name: str + error_messages: List[str] + + # HTTP class SeamHttpApiError(Exception): """ @@ -88,6 +97,18 @@ def __init__( self.code = "invalid_input" self._validation_errors = error.get("validation_errors") or {} + @property + def validation_errors(self) -> List[SeamValidationError]: + """Validation errors, one entry per failed request parameter.""" + + return [ + SeamValidationError( + param_name, self.get_validation_error_messages(param_name) + ) + for param_name in self._validation_errors + if param_name != "_errors" + ] + def get_validation_error_messages(self, param_name: str) -> List[str]: """ The validation messages for a request parameter, or an empty list when diff --git a/test/http_error_test.py b/test/http_error_test.py index 40d1d350..fbd0aadb 100644 --- a/test/http_error_test.py +++ b/test/http_error_test.py @@ -5,6 +5,7 @@ SeamHttpApiError, SeamHttpInvalidInputError, SeamHttpUnauthorizedError, + SeamValidationError, ) @@ -48,6 +49,29 @@ def test_seam_http_throws_invalid_input_error(server): assert err.get_validation_error_messages("device_ids") == [ "Expected array, received number" ] + assert err.validation_errors == [ + SeamValidationError( + parameter_name="device_ids", + error_messages=["Expected array, received number"], + ) + ] + + +def test_validation_errors_exclude_request_wide_errors(): + err = SeamHttpInvalidInputError( + { + "validation_errors": { + "_errors": ["Request is invalid"], + "device_ids": {"_errors": ["Invalid device IDs"]}, + } + }, + 400, + None, + ) + + assert err.validation_errors == [ + SeamValidationError("device_ids", ["Invalid device IDs"]) + ] def test_seam_http_invalid_input_error_has_no_messages_for_unknown_param(server):