Files
claude-code-api/tests/test_errors.py
T

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()