Files
wehub-resource-sync 5296d0e97c
CI / Ban suppressions and legacy annotations (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:44 +08:00

376 lines
11 KiB
Python

"""Raw provider failure classification into the canonical neutral model."""
from collections.abc import Callable
from dataclasses import dataclass
from unittest.mock import Mock
import httpx
import openai
import pytest
from free_claude_code.core.diagnostics import (
ERROR_DETAIL_DISPLAY_CAP_BYTES,
attach_upstream_error_body,
)
from free_claude_code.core.failures import ExecutionFailure, FailureKind
from free_claude_code.providers.failure_policy import classify_provider_failure
def _openai_status_error(
error_type: type[openai.APIStatusError],
*,
status_code: int,
message: str,
body: object | None = None,
) -> openai.APIStatusError:
request = httpx.Request("POST", "https://provider.test/v1/chat/completions")
response = httpx.Response(status_code, request=request)
return error_type(
message,
response=response,
body=body or {"error": {"message": message}},
)
def _statusless_openai_error(message: str, body: object | None) -> openai.APIError:
return openai.APIError(
message,
request=httpx.Request("POST", "https://provider.test/v1/chat/completions"),
body=body,
)
def _http_status_error(status_code: int, message: str) -> httpx.HTTPStatusError:
request = httpx.Request("POST", "https://provider.test/v1/messages")
response = httpx.Response(
status_code,
request=request,
json={"error": {"message": message, "api_key": "SECRET"}},
)
return httpx.HTTPStatusError(message, request=request, response=response)
@dataclass(frozen=True, slots=True)
class _ClassificationCase:
name: str
error: Callable[[], Exception]
kind: FailureKind
status_code: int
retryable: bool
rate_limit_block_seconds: int | None = None
_CASES = (
_ClassificationCase(
"openai_authentication",
lambda: _openai_status_error(
openai.AuthenticationError,
status_code=401,
message="Unauthorized",
),
FailureKind.AUTHENTICATION,
401,
False,
),
_ClassificationCase(
"openai_rate_limit",
lambda: _openai_status_error(
openai.RateLimitError,
status_code=429,
message="Too many requests",
),
FailureKind.RATE_LIMIT,
429,
True,
60,
),
_ClassificationCase(
"openai_bad_request",
lambda: _openai_status_error(
openai.BadRequestError,
status_code=400,
message="bad tool shape",
),
FailureKind.INVALID_REQUEST,
400,
False,
),
_ClassificationCase(
"openai_overload_marker",
lambda: _openai_status_error(
openai.InternalServerError,
status_code=500,
message="No capacity available",
),
FailureKind.OVERLOADED,
529,
True,
),
_ClassificationCase(
"openai_generic_503_preserved",
lambda: _openai_status_error(
openai.InternalServerError,
status_code=503,
message="generic server failure",
),
FailureKind.UPSTREAM,
503,
True,
),
_ClassificationCase(
"statusless_openai_rate_limit_body",
lambda: _statusless_openai_error(
"stream embedded error",
{"error": {"message": "too many requests", "code": 429}},
),
FailureKind.RATE_LIMIT,
429,
True,
60,
),
_ClassificationCase(
"statusless_openai_overload_body",
lambda: _statusless_openai_error(
"ResourceExhausted: limit reached",
{"error": {"message": "ResourceExhausted: limit reached"}},
),
FailureKind.OVERLOADED,
529,
True,
),
_ClassificationCase(
"statusless_openai_unknown_is_not_retryable",
lambda: _statusless_openai_error(
"stream embedded error",
{"error": {"message": "unknown provider failure"}},
),
FailureKind.UPSTREAM,
500,
False,
),
_ClassificationCase(
"http_403_keeps_authentication_quirk",
lambda: _http_status_error(403, "Forbidden"),
FailureKind.AUTHENTICATION,
401,
False,
),
_ClassificationCase(
"http_502_keeps_overload_quirk",
lambda: _http_status_error(502, "Bad gateway"),
FailureKind.OVERLOADED,
529,
True,
),
_ClassificationCase(
"http_599_preserves_status",
lambda: _http_status_error(599, "Upstream failure"),
FailureKind.UPSTREAM,
599,
True,
),
_ClassificationCase(
"http_405_is_not_retryable",
lambda: _http_status_error(405, "Wrong endpoint"),
FailureKind.UPSTREAM,
405,
False,
),
_ClassificationCase(
"read_timeout_keeps_pre_start_status",
lambda: httpx.ReadTimeout(
"",
request=httpx.Request("POST", "https://provider.test/v1/messages"),
),
FailureKind.TIMEOUT,
502,
True,
),
_ClassificationCase(
"openai_connection_error_keeps_status",
lambda: openai.APIConnectionError(
request=httpx.Request("POST", "https://provider.test/v1/chat/completions")
),
FailureKind.UNAVAILABLE,
500,
True,
),
_ClassificationCase(
"unknown_exception_keeps_gateway_status",
lambda: RuntimeError("unexpected provider failure"),
FailureKind.UPSTREAM,
502,
False,
),
)
@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.name)
def test_raw_provider_failure_maps_to_canonical_failure(
case: _ClassificationCase,
) -> None:
mark_rate_limited = Mock()
failure = classify_provider_failure(
case.error(),
provider_name="TEST_PROVIDER",
read_timeout_s=30.0,
request_id="req_classification",
mark_rate_limited=mark_rate_limited,
)
assert isinstance(failure, ExecutionFailure)
assert failure.kind is case.kind
assert failure.status_code == case.status_code
assert failure.retryable is case.retryable
assert failure.message.strip()
assert "Request ID: req_classification" in failure.message
assert "SECRET" not in failure.message
if case.rate_limit_block_seconds is None:
mark_rate_limited.assert_not_called()
else:
mark_rate_limited.assert_called_once_with(case.rate_limit_block_seconds)
def test_classification_preserves_useful_body_while_redacting_credentials() -> None:
error = _http_status_error(
400,
"unsupported model format authorization: Bearer AUTH_SECRET",
)
failure = classify_provider_failure(
error,
provider_name="LOCAL",
read_timeout_s=60.0,
request_id="req_body",
mark_rate_limited=Mock(),
)
assert failure.kind is FailureKind.INVALID_REQUEST
assert failure.status_code == 400
assert "Upstream provider LOCAL returned HTTP 400." in failure.message
assert "unsupported model format" in failure.message
assert "Request ID: req_body" in failure.message
assert "AUTH_SECRET" not in failure.message
assert "SECRET" not in failure.message
def test_auth_failure_preserves_model_error_body_instead_of_masking_it() -> None:
error = _openai_status_error(
openai.AuthenticationError,
status_code=401,
message="Unauthorized",
body={
"type": "error",
"error": {
"type": "ModelError",
"message": ("Model qwen3.7-max is not supported for format oa-compat"),
},
},
)
failure = classify_provider_failure(
error,
provider_name="OPENCODE_GO",
read_timeout_s=60.0,
request_id="req_model",
mark_rate_limited=Mock(),
)
assert failure.kind is FailureKind.AUTHENTICATION
assert failure.status_code == 401
assert "Category: ModelError" in failure.message
assert "Provider authentication failed. Check API key." in failure.message
assert "Model qwen3.7-max is not supported for format oa-compat" in failure.message
assert "Request ID: req_model" in failure.message
def test_empty_http_error_body_is_reported_explicitly() -> None:
request = httpx.Request("POST", "https://provider.test/v1/messages")
response = httpx.Response(500, request=request, content=b"")
error = httpx.HTTPStatusError(
"Server Error",
request=request,
response=response,
)
failure = classify_provider_failure(
error,
provider_name="EMPTY",
read_timeout_s=30.0,
request_id="req_empty",
mark_rate_limited=Mock(),
)
assert failure.kind is FailureKind.UPSTREAM
assert failure.status_code == 500
assert "Upstream provider EMPTY returned HTTP 500." in failure.message
assert "(empty upstream error body)" in failure.message
def test_http_405_diagnostic_names_rejected_upstream_endpoint() -> None:
failure = classify_provider_failure(
_http_status_error(405, "Method Not Allowed"),
provider_name="LOCAL",
read_timeout_s=30.0,
request_id="req_405",
mark_rate_limited=Mock(),
)
assert failure.kind is FailureKind.UPSTREAM
assert failure.status_code == 405
assert (
"Upstream provider LOCAL rejected the request method or endpoint (HTTP 405)."
in failure.message
)
assert "Request ID: req_405" in failure.message
def test_connection_cause_chain_is_redacted_and_capped() -> None:
request = httpx.Request("POST", "https://provider.test/v1/chat/completions")
error = openai.APIConnectionError(request=request)
error.__cause__ = httpx.ConnectError(
"connect failed authorization: Bearer CAUSE_SECRET "
+ "x" * (ERROR_DETAIL_DISPLAY_CAP_BYTES + 10),
request=request,
)
failure = classify_provider_failure(
error,
provider_name="NIM",
read_timeout_s=30.0,
request_id="req_cause",
mark_rate_limited=Mock(),
)
assert "Caused by:" in failure.message
assert "ConnectError: connect failed authorization: <redacted>" in failure.message
assert "CAUSE_SECRET" not in failure.message
assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in failure.message
assert "Request ID: req_cause" in failure.message
def test_attached_streamed_error_body_remains_bounded() -> None:
request = httpx.Request("POST", "https://provider.test/v1/messages")
response = httpx.Response(500, request=request, content=b"")
error = httpx.HTTPStatusError(
"Server Error",
request=request,
response=response,
)
attach_upstream_error_body(
error,
"x" * (ERROR_DETAIL_DISPLAY_CAP_BYTES + 10),
)
failure = classify_provider_failure(
error,
provider_name="LONG",
read_timeout_s=30.0,
request_id="req_long",
mark_rate_limited=Mock(),
)
assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in failure.message
assert "x" * 100 in failure.message