126 lines
4.2 KiB
Python
126 lines
4.2 KiB
Python
"""Unit tests for the Stage 10 error hierarchy + PTY-output classifier."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from claude_code_api import (
|
|
AuthError,
|
|
BackendError,
|
|
CLINotFoundError,
|
|
MessageParseError,
|
|
ProcessError,
|
|
RateLimitError,
|
|
SessionError,
|
|
classify_pty_failure,
|
|
)
|
|
|
|
|
|
def test_hierarchy_roots_under_backend_error() -> None:
|
|
# Every backend-emitted exception must descend from BackendError so a
|
|
# gateway can install a single catch-all handler.
|
|
for cls in (
|
|
AuthError,
|
|
MessageParseError,
|
|
ProcessError,
|
|
RateLimitError,
|
|
SessionError,
|
|
):
|
|
assert issubclass(cls, BackendError)
|
|
assert issubclass(CLINotFoundError, ProcessError)
|
|
|
|
|
|
def test_process_error_carries_exit_code_and_stderr_in_message() -> None:
|
|
exc = ProcessError("boom", exit_code=7, stderr="line1\nline2")
|
|
assert exc.exit_code == 7
|
|
assert exc.stderr == "line1\nline2"
|
|
rendered = str(exc)
|
|
assert "boom" in rendered
|
|
assert "exit code: 7" in rendered
|
|
assert "line1" in rendered # included in the tail
|
|
|
|
|
|
def test_process_error_tail_caps_huge_stderr() -> None:
|
|
# A 5KB blob should not embed wholesale in the message.
|
|
blob = "x" * 5000
|
|
exc = ProcessError("oops", stderr=blob)
|
|
rendered = str(exc)
|
|
# Tail is capped to 2000 chars in the formatter.
|
|
assert rendered.count("x") <= 2000 + 10 # +slack for any literal 'x' in prefix
|
|
|
|
|
|
def test_cli_not_found_appends_executable() -> None:
|
|
exc = CLINotFoundError(executable="/usr/local/bin/claude")
|
|
assert "/usr/local/bin/claude" in str(exc)
|
|
assert exc.executable == "/usr/local/bin/claude"
|
|
# Default constructor is also valid.
|
|
bare = CLINotFoundError()
|
|
assert "not found" in str(bare).lower()
|
|
|
|
|
|
def test_classify_pty_failure_returns_none_when_no_marker() -> None:
|
|
assert classify_pty_failure(b"the model is thinking...") is None
|
|
assert classify_pty_failure("") is None
|
|
|
|
|
|
def test_classify_auth_markers() -> None:
|
|
assert classify_pty_failure(b"Failed to authenticate (status 401)") is AuthError
|
|
assert classify_pty_failure(b"API Error: 403 Forbidden") is AuthError
|
|
# claude-p's compact match handles "Please run /login" even when ANSI
|
|
# / spinner punctuation splits the words.
|
|
assert classify_pty_failure(b"Please run /login to continue.") is AuthError
|
|
assert (
|
|
classify_pty_failure(b"\x1b[31mPlease\x1b[0m run /login")
|
|
is AuthError
|
|
)
|
|
|
|
|
|
def test_classify_rate_limit_markers() -> None:
|
|
assert classify_pty_failure(b"You've hit your limit. Try again later.") is RateLimitError
|
|
assert classify_pty_failure(b"You have hit your limit.") is RateLimitError
|
|
# Bare form (TUI sometimes wraps the noun out).
|
|
assert classify_pty_failure(b"hit your limit") is RateLimitError
|
|
|
|
|
|
def test_classify_strips_ansi_before_matching() -> None:
|
|
# Common SGR sequences should not block the marker.
|
|
coloured = b"\x1b[1;31mYou've hit your limit\x1b[0m"
|
|
assert classify_pty_failure(coloured) is RateLimitError
|
|
|
|
|
|
def test_classify_accepts_str_or_bytes() -> None:
|
|
assert classify_pty_failure("Failed to authenticate") is AuthError
|
|
assert classify_pty_failure(b"Failed to authenticate") is AuthError
|
|
|
|
|
|
def test_auth_and_rate_limit_default_messages() -> None:
|
|
# Default messages are descriptive enough to surface to a gateway.
|
|
assert "auth" in str(AuthError()).lower()
|
|
assert "rate" in str(RateLimitError()).lower() or "limit" in str(RateLimitError()).lower()
|
|
|
|
|
|
def test_session_error_is_plain_backend_error() -> None:
|
|
# No special fields — just a typed marker.
|
|
exc = SessionError("never appeared")
|
|
assert isinstance(exc, BackendError)
|
|
assert "never appeared" in str(exc)
|
|
|
|
|
|
def test_message_parse_error_carries_data() -> None:
|
|
payload = {"oops": True}
|
|
exc = MessageParseError("bad shape", data=payload)
|
|
assert exc.data is payload
|
|
|
|
|
|
def test_session_error_is_not_a_timeout_error() -> None:
|
|
# We deliberately broke the TimeoutError lineage: gateways that used to
|
|
# catch TimeoutError must migrate to SessionError. Pin that.
|
|
assert not issubclass(SessionError, TimeoutError)
|
|
|
|
|
|
def test_raise_chain_smoke() -> None:
|
|
with pytest.raises(AuthError):
|
|
raise AuthError()
|
|
with pytest.raises(BackendError):
|
|
raise RateLimitError()
|