myxce/src/xceadmin/tests/test_errors.py
Rongsong Shen b64875f4c3
feat(scheduler-core): build xceadmin scheduler recording client functions (task 10.3)
Adds record_start()/record_stop()/record_status() to new
src/xceadmin/src/xceadmin/scheduler.py, following push_config()'s exact
pipeline shape (load_schema -> follow_redirects -> decode_reply). Deliberately
diverges from push_config()'s "return failure as data" convention because
this task's own Observable text requires raising a typed error on an error
reply, not just connection/decode failure -- a new SchedAdminError
(xcecommon/errors.py) carries the full reply state (error/recording/file/
event_count/started_at) when SchedAdminReply.ok is false.

test_scheduler.py mirrors test_config_push_mock.py's real mock-TLS-server
technique, covering all three actions, redirect-following (standby-to-
primary), and the error-reply-raises-SchedAdminError path across the
error codes reference table.

188 passed, 3 skipped via the real xceadmin test runner (meson compile
-C xce.build xceadmin).
2026-08-06 04:43:40 +00:00

230 lines
8.9 KiB
Python

"""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,
SchedAdminError,
SchemaNotFoundError,
TooManyRedirectsError,
ValidationError,
XceAdminError,
)
ALL_SUBCLASSES = [
SchemaNotFoundError,
ValidationError,
ReservedNameError,
DuplicateEntryError,
ConnectionTimeoutError,
ReplyDecodeError,
TooManyRedirectsError,
NoPrimaryError,
SchedAdminError,
]
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_sched_admin_error_carries_error_and_reply_state() -> None:
"""Requirement 5.10 (declared here, raised by a later task): a
``SchedAdminReply{ok=false, error=...}`` is surfaced as a typed error
carrying the reason plus the reply's recording/file/event/started-at
state (e.g. "already recording" still reports the current file)."""
with pytest.raises(XceAdminError) as exc_info:
raise SchedAdminError(
"scheduler admin request failed: already recording",
error="already recording",
recording=True,
file="/var/log/xce/current.replay",
event_count=42,
started_at=1234567890,
)
caught = exc_info.value
assert isinstance(caught, SchedAdminError)
assert caught.message == "scheduler admin request failed: already recording"
assert caught.error == "already recording"
assert caught.recording is True
assert caught.file == "/var/log/xce/current.replay"
assert caught.event_count == 42
assert caught.started_at == 1234567890
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"),
(
SchedAdminError(
"msg-i", error="e", recording=False, file="", event_count=0, started_at=0
),
"msg-i",
),
]
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")