feat(admin-config-tool): task 1.2 - shared exception taxonomy
XceAdminError base plus 8 subclasses (schema-not-found, validation, reserved-name, duplicate-entry, connection-timeout, reply-decode, too-many-redirects, no-primary) giving the future CLI layer one dispatch surface instead of catching unrelated types per module. Requirements: 7.1, 7.2, 7.3, 7.4
This commit is contained in:
parent
b30a856a2d
commit
2e4ef4e209
3 changed files with 327 additions and 1 deletions
|
|
@ -7,7 +7,7 @@
|
|||
- Observable: running the entry point after an editable install prints usage/help text without error, showing zero commands
|
||||
- _Requirements: 5.4_
|
||||
|
||||
- [ ] 1.2 Establish a shared error-handling foundation
|
||||
- [x] 1.2 Establish a shared error-handling foundation
|
||||
- One common base exception type carries a human-readable message
|
||||
- Every specific failure mode (schema not found, validation failure, reserved name, duplicate entry, connection timeout, corrupt reply, too many redirects, no primary available) has its own subclass
|
||||
- Observable: raising any of these subclasses and catching the common base type recovers the message
|
||||
|
|
|
|||
129
src/xceadmin/src/xcecommon/errors.py
Normal file
129
src/xceadmin/src/xcecommon/errors.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Shared exception hierarchy for xcecommon/xceadmin.
|
||||
|
||||
Every raising exception across ``xcecommon`` and ``xceadmin`` subclasses one
|
||||
base, :class:`XceAdminError`. This gives ``xceadmin.cli`` a single dispatch
|
||||
surface — ``except XceAdminError as e: raise
|
||||
click.ClickException(e.message)`` — instead of catching eight unrelated
|
||||
exception types individually (see design.md, "xceadmin.cli — Implementation
|
||||
Note"; Requirements 7.1, 7.2, 7.3, 7.4).
|
||||
|
||||
Each subclass carries the human-readable ``message`` inherited from the
|
||||
base, plus whatever structured fields its raise site needs to expose (e.g.
|
||||
``ConnectionTimeoutError.host``/``.port``) as real constructor
|
||||
parameters/attributes — not just text embedded in the message string — so
|
||||
callers can act on the specifics programmatically instead of parsing a
|
||||
formatted string.
|
||||
|
||||
This module only defines the exception types. The code that actually
|
||||
raises them (schema loading, YAML validation, wire protocol, redirect
|
||||
discovery) is built in later tasks.
|
||||
"""
|
||||
|
||||
|
||||
class XceAdminError(Exception):
|
||||
"""Base exception for every xcecommon/xceadmin failure mode.
|
||||
|
||||
Catching ``XceAdminError`` and reading ``.message`` (or ``str(e)``)
|
||||
recovers a human-readable message regardless of which subclass was
|
||||
actually raised.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class SchemaNotFoundError(XceAdminError):
|
||||
"""``controller.capnp`` (or its ``c.capnp`` import) was not found at the
|
||||
resolved path.
|
||||
|
||||
Requirement 7.4: report the missing path and suggest checking the
|
||||
installation.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, path: str) -> None:
|
||||
super().__init__(message)
|
||||
self.path = path
|
||||
|
||||
|
||||
class ValidationError(XceAdminError):
|
||||
"""A YAML config file failed JSON Schema validation.
|
||||
|
||||
Requirement 4.1: report the file, the JSON-pointer field path, and the
|
||||
violated constraint.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, file: str, json_pointer: str, constraint: str) -> None:
|
||||
super().__init__(message)
|
||||
self.file = file
|
||||
self.json_pointer = json_pointer
|
||||
self.constraint = constraint
|
||||
|
||||
|
||||
class ReservedNameError(XceAdminError):
|
||||
"""A YAML file used the reserved name ``"lost_and_found"`` for a queue
|
||||
or host.
|
||||
|
||||
Requirement 4.2: reject with a clear message explaining the
|
||||
reservation, even if the file otherwise passes schema validation.
|
||||
"""
|
||||
|
||||
|
||||
class DuplicateEntryError(XceAdminError):
|
||||
"""A YAML file contained duplicate entries within its own file/section.
|
||||
|
||||
Requirement 4.3.
|
||||
"""
|
||||
|
||||
|
||||
class ConnectionTimeoutError(XceAdminError):
|
||||
"""A network operation to the controller did not complete within the
|
||||
30-second timeout.
|
||||
|
||||
Requirement 7.6 (declared here; raised by a later task — see
|
||||
``xcecommon.wire``): report "timeout connecting to <host:port>".
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, host: str, port: int) -> None:
|
||||
super().__init__(message)
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
|
||||
class ReplyDecodeError(XceAdminError):
|
||||
"""The capnp reply bytes from the controller (or an input
|
||||
``cluster.bin``) were corrupt or truncated and could not be decoded.
|
||||
|
||||
Requirement 7.3: report "invalid cluster.bin: <reason>".
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, reason: str) -> None:
|
||||
super().__init__(message)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class TooManyRedirectsError(XceAdminError):
|
||||
"""More than 3 redirect hops were followed while discovering the
|
||||
primary controller.
|
||||
|
||||
Requirement 3.2 (declared here; raised by a later task — see
|
||||
``xceadmin.discovery``).
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, hops: int) -> None:
|
||||
super().__init__(message)
|
||||
self.hops = hops
|
||||
|
||||
|
||||
class NoPrimaryError(XceAdminError):
|
||||
"""No primary controller could be reached within the discovery
|
||||
timeout.
|
||||
|
||||
Requirement 3.4 (declared here; raised by a later task — see
|
||||
``xceadmin.discovery``): report "no primary available".
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, host: str, port: int) -> None:
|
||||
super().__init__(message)
|
||||
self.host = host
|
||||
self.port = port
|
||||
197
src/xceadmin/tests/test_errors.py
Normal file
197
src/xceadmin/tests/test_errors.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Tests for the xcecommon.errors exception hierarchy (task 1.2).
|
||||
|
||||
Covers Requirements 7.1, 7.2, 7.3, 7.4: every specific failure mode gets its
|
||||
own XceAdminError subclass, and catching the common base type recovers a
|
||||
human-readable message regardless of which subclass was actually raised —
|
||||
the single CLI dispatch surface described in design.md's "xceadmin.cli —
|
||||
Implementation Note" (`except XceAdminError as e: raise
|
||||
click.ClickException(e.message)`).
|
||||
|
||||
This task only builds the exception hierarchy itself — the code that would
|
||||
actually raise these exceptions (schema loading, YAML validation, wire
|
||||
protocol) lands in later tasks, so these tests raise each exception
|
||||
directly rather than driving it through real application logic.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from xcecommon.errors import (
|
||||
ConnectionTimeoutError,
|
||||
DuplicateEntryError,
|
||||
NoPrimaryError,
|
||||
ReplyDecodeError,
|
||||
ReservedNameError,
|
||||
SchemaNotFoundError,
|
||||
TooManyRedirectsError,
|
||||
ValidationError,
|
||||
XceAdminError,
|
||||
)
|
||||
|
||||
ALL_SUBCLASSES = [
|
||||
SchemaNotFoundError,
|
||||
ValidationError,
|
||||
ReservedNameError,
|
||||
DuplicateEntryError,
|
||||
ConnectionTimeoutError,
|
||||
ReplyDecodeError,
|
||||
TooManyRedirectsError,
|
||||
NoPrimaryError,
|
||||
]
|
||||
|
||||
|
||||
def test_base_exception_carries_message() -> None:
|
||||
"""XceAdminError itself carries a .message attribute, recoverable via
|
||||
both .message and str(e)."""
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise XceAdminError("something went wrong")
|
||||
|
||||
assert exc_info.value.message == "something went wrong"
|
||||
assert str(exc_info.value) == "something went wrong"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc_type", ALL_SUBCLASSES)
|
||||
def test_every_subclass_is_a_xceadminerror(exc_type: type[XceAdminError]) -> None:
|
||||
"""Every specific failure mode subclasses the common base — this is what
|
||||
lets xceadmin.cli use one dispatch surface instead of catching 8
|
||||
unrelated types (design.md, xceadmin.cli Implementation Note)."""
|
||||
assert issubclass(exc_type, XceAdminError)
|
||||
assert issubclass(exc_type, Exception)
|
||||
|
||||
|
||||
def test_schema_not_found_error_carries_path_and_message() -> None:
|
||||
"""Requirement 7.4: missing controller.capnp reports the path so the
|
||||
operator can check the installation."""
|
||||
path = "/opt/xceadmin/schema_files/controller.capnp"
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise SchemaNotFoundError(f"schema not found: {path}", path=path)
|
||||
|
||||
caught = exc_info.value
|
||||
assert isinstance(caught, SchemaNotFoundError)
|
||||
assert caught.message == f"schema not found: {path}"
|
||||
assert caught.path == path
|
||||
|
||||
|
||||
def test_validation_error_carries_file_json_pointer_and_constraint() -> None:
|
||||
"""Requirement 4.1 / design.md xcecommon.validation: schema validation
|
||||
failures report file, JSON-pointer field path, and violated constraint."""
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise ValidationError(
|
||||
"queues.yaml: /queues/0/name does not match pattern",
|
||||
file="queues.yaml",
|
||||
json_pointer="/queues/0/name",
|
||||
constraint="pattern",
|
||||
)
|
||||
|
||||
caught = exc_info.value
|
||||
assert isinstance(caught, ValidationError)
|
||||
assert caught.message == "queues.yaml: /queues/0/name does not match pattern"
|
||||
assert caught.file == "queues.yaml"
|
||||
assert caught.json_pointer == "/queues/0/name"
|
||||
assert caught.constraint == "pattern"
|
||||
|
||||
|
||||
def test_reserved_name_error_recovers_message_via_base_type() -> None:
|
||||
"""Requirement 4.2: the reserved name "lost_and_found" is rejected with
|
||||
a clear message explaining the reservation."""
|
||||
message = '"lost_and_found" is reserved and cannot be used as a queue or host name'
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise ReservedNameError(message)
|
||||
|
||||
assert isinstance(exc_info.value, ReservedNameError)
|
||||
assert exc_info.value.message == message
|
||||
|
||||
|
||||
def test_duplicate_entry_error_recovers_message_via_base_type() -> None:
|
||||
"""Requirement 4.3: duplicate entries within a file/section are
|
||||
reported by name."""
|
||||
message = "duplicate queue name 'batch' in queues.yaml"
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise DuplicateEntryError(message)
|
||||
|
||||
assert isinstance(exc_info.value, DuplicateEntryError)
|
||||
assert exc_info.value.message == message
|
||||
|
||||
|
||||
def test_connection_timeout_error_carries_host_and_port() -> None:
|
||||
"""Requirement 7.6 (declared here, raised by a later task): "timeout
|
||||
connecting to <host:port>"."""
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise ConnectionTimeoutError(
|
||||
"timeout connecting to localhost:9991", host="localhost", port=9991
|
||||
)
|
||||
|
||||
caught = exc_info.value
|
||||
assert isinstance(caught, ConnectionTimeoutError)
|
||||
assert caught.message == "timeout connecting to localhost:9991"
|
||||
assert caught.host == "localhost"
|
||||
assert caught.port == 9991
|
||||
|
||||
|
||||
def test_reply_decode_error_carries_reason() -> None:
|
||||
"""Requirement 7.3: a corrupt/truncated capnp reply or cluster.bin
|
||||
reports "invalid cluster.bin: <reason>"."""
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise ReplyDecodeError(
|
||||
"invalid cluster.bin: truncated capnp message",
|
||||
reason="truncated capnp message",
|
||||
)
|
||||
|
||||
caught = exc_info.value
|
||||
assert isinstance(caught, ReplyDecodeError)
|
||||
assert caught.message == "invalid cluster.bin: truncated capnp message"
|
||||
assert caught.reason == "truncated capnp message"
|
||||
|
||||
|
||||
def test_too_many_redirects_error_carries_hop_count() -> None:
|
||||
"""Requirement 3.2 (declared here, raised by a later task): more than 3
|
||||
redirect hops is an error."""
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise TooManyRedirectsError(
|
||||
"followed 3 redirects without reaching a primary", hops=3
|
||||
)
|
||||
|
||||
caught = exc_info.value
|
||||
assert isinstance(caught, TooManyRedirectsError)
|
||||
assert caught.message == "followed 3 redirects without reaching a primary"
|
||||
assert caught.hops == 3
|
||||
|
||||
|
||||
def test_no_primary_error_carries_host_and_port() -> None:
|
||||
"""Requirement 3.4 (declared here, raised by a later task): no primary
|
||||
elected within the discovery timeout."""
|
||||
with pytest.raises(XceAdminError) as exc_info:
|
||||
raise NoPrimaryError(
|
||||
"no primary available at localhost:9991", host="localhost", port=9991
|
||||
)
|
||||
|
||||
caught = exc_info.value
|
||||
assert isinstance(caught, NoPrimaryError)
|
||||
assert caught.message == "no primary available at localhost:9991"
|
||||
assert caught.host == "localhost"
|
||||
assert caught.port == 9991
|
||||
|
||||
|
||||
def test_catching_base_type_recovers_message_for_every_subclass() -> None:
|
||||
"""The entire point of the shared exception contract (design.md,
|
||||
xceadmin.cli Implementation Note): a single `except XceAdminError as e`
|
||||
clause recovers e.message no matter which of the 8 subclasses was
|
||||
actually raised — this is what lets the future CLI layer (task 4.1) use
|
||||
one dispatch surface instead of 8 separate except clauses."""
|
||||
cases: list[tuple[XceAdminError, str]] = [
|
||||
(SchemaNotFoundError("msg-a", path="/x"), "msg-a"),
|
||||
(ValidationError("msg-b", file="f", json_pointer="/p", constraint="c"), "msg-b"),
|
||||
(ReservedNameError("msg-c"), "msg-c"),
|
||||
(DuplicateEntryError("msg-d"), "msg-d"),
|
||||
(ConnectionTimeoutError("msg-e", host="h", port=1), "msg-e"),
|
||||
(ReplyDecodeError("msg-f", reason="r"), "msg-f"),
|
||||
(TooManyRedirectsError("msg-g", hops=3), "msg-g"),
|
||||
(NoPrimaryError("msg-h", host="h", port=1), "msg-h"),
|
||||
]
|
||||
|
||||
for exc, expected_message in cases:
|
||||
try:
|
||||
raise exc
|
||||
except XceAdminError as caught:
|
||||
assert caught.message == expected_message
|
||||
else:
|
||||
pytest.fail("expected XceAdminError to be raised and caught")
|
||||
Loading…
Add table
Add a link
Reference in a new issue