mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-06-02 06:13:46 +02:00
Surface upstream provider errors
Include copyable upstream status and error bodies in provider SSE failures for OpenAI-compatible and native Anthropic transports, while preserving retry behavior and safe logging defaults.
This commit is contained in:
+226
-1
@@ -1,8 +1,13 @@
|
||||
"""Provider-specific exception mapping."""
|
||||
"""Provider-specific exception mapping and user-visible diagnostics."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
|
||||
from config.constants import PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES
|
||||
from core.anthropic import get_user_facing_error_message
|
||||
from providers.exceptions import (
|
||||
APIError,
|
||||
@@ -13,14 +18,234 @@ from providers.exceptions import (
|
||||
)
|
||||
from providers.rate_limit import GlobalRateLimiter
|
||||
|
||||
_BODY_ATTR = "_fcc_provider_error_body"
|
||||
_BODY_TRUNCATED_ATTR = "_fcc_provider_error_body_truncated"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderErrorDetail:
|
||||
"""Structured upstream error detail surfaced directly to users."""
|
||||
|
||||
status_code: int | None = None
|
||||
body_text: str | None = None
|
||||
exception_text: str | None = None
|
||||
error_type_hint: str | None = None
|
||||
body_truncated: bool = False
|
||||
|
||||
|
||||
def attach_provider_error_body(
|
||||
exc: Exception, body: bytes | str, *, truncated: bool = False
|
||||
) -> None:
|
||||
"""Attach a streamed HTTP error body to an exception for later formatting."""
|
||||
setattr(exc, _BODY_ATTR, body)
|
||||
setattr(exc, _BODY_TRUNCATED_ATTR, truncated)
|
||||
|
||||
|
||||
def _status_code_from_exception(exc: Exception) -> int | None:
|
||||
status = getattr(exc, "status_code", None)
|
||||
if isinstance(status, int):
|
||||
return status
|
||||
response = getattr(exc, "response", None)
|
||||
response_status = getattr(response, "status_code", None)
|
||||
if isinstance(response_status, int):
|
||||
return response_status
|
||||
return None
|
||||
|
||||
|
||||
def _body_from_response(exc: Exception) -> Any:
|
||||
response = getattr(exc, "response", None)
|
||||
if response is None:
|
||||
return None
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError, RuntimeError:
|
||||
pass
|
||||
try:
|
||||
return response.text
|
||||
except httpx.ResponseNotRead, RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_body_text(body: Any) -> str | None:
|
||||
if body is None:
|
||||
return None
|
||||
if isinstance(body, bytes):
|
||||
text = body.decode("utf-8", errors="replace")
|
||||
elif isinstance(body, str):
|
||||
text = body
|
||||
else:
|
||||
try:
|
||||
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
||||
except TypeError:
|
||||
text = str(body)
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except ValueError:
|
||||
return stripped
|
||||
return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _cap_text_bytes(text: str, max_bytes: int) -> tuple[str, bool]:
|
||||
encoded = text.encode("utf-8", errors="replace")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text, False
|
||||
capped = encoded[:max_bytes].decode("utf-8", errors="replace")
|
||||
return f"{capped}\n... [truncated after {max_bytes} bytes]", True
|
||||
|
||||
|
||||
def _error_type_hint_from_body(body: Any, body_text: str | None) -> str | None:
|
||||
parsed = body
|
||||
if isinstance(parsed, bytes):
|
||||
text = parsed.decode("utf-8", errors="replace")
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
elif isinstance(parsed, str):
|
||||
try:
|
||||
parsed = json.loads(parsed)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
error = parsed.get("error")
|
||||
if isinstance(error, dict):
|
||||
for key in ("type", "code"):
|
||||
value = error.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
for key in ("type", "code"):
|
||||
value = parsed.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if (
|
||||
body_text
|
||||
and "model" in body_text.lower()
|
||||
and "unsupported" in body_text.lower()
|
||||
):
|
||||
return "upstream_model_error"
|
||||
return None
|
||||
|
||||
|
||||
def extract_provider_error_detail(exc: Exception) -> ProviderErrorDetail:
|
||||
"""Extract copyable upstream status/body/exception detail from provider errors."""
|
||||
raw_body = getattr(exc, _BODY_ATTR, None)
|
||||
raw_body_truncated = bool(getattr(exc, _BODY_TRUNCATED_ATTR, False))
|
||||
if raw_body is None:
|
||||
raw_body = getattr(exc, "body", None)
|
||||
if raw_body is None:
|
||||
raw_body = _body_from_response(exc)
|
||||
|
||||
body_text = _normalize_body_text(raw_body)
|
||||
display_truncated = raw_body_truncated
|
||||
if body_text is not None:
|
||||
body_text, cap_truncated = _cap_text_bytes(
|
||||
body_text, PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES
|
||||
)
|
||||
display_truncated = display_truncated or cap_truncated
|
||||
|
||||
exception_text = str(exc).strip() or None
|
||||
if exception_text is not None:
|
||||
exception_text, _ = _cap_text_bytes(
|
||||
exception_text, PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES
|
||||
)
|
||||
|
||||
return ProviderErrorDetail(
|
||||
status_code=_status_code_from_exception(exc),
|
||||
body_text=body_text,
|
||||
exception_text=exception_text,
|
||||
error_type_hint=_error_type_hint_from_body(raw_body, body_text),
|
||||
body_truncated=display_truncated,
|
||||
)
|
||||
|
||||
|
||||
def _provider_error_category(mapped: Exception) -> str | None:
|
||||
error_type = getattr(mapped, "error_type", None)
|
||||
if isinstance(error_type, str) and error_type.strip():
|
||||
return error_type.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _append_request_id_lines(lines: list[str], request_id: str | None) -> None:
|
||||
if request_id:
|
||||
lines.extend(("", f"Request ID: {request_id}"))
|
||||
|
||||
|
||||
def format_provider_error_message(
|
||||
mapped: Exception,
|
||||
detail: ProviderErrorDetail,
|
||||
*,
|
||||
provider_name: str,
|
||||
read_timeout_s: float | None,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
"""Return a copyable user-facing provider error including upstream detail."""
|
||||
stable_message = get_user_facing_error_message(
|
||||
mapped, read_timeout_s=read_timeout_s
|
||||
)
|
||||
has_upstream_detail = detail.status_code is not None or detail.body_text is not None
|
||||
if not has_upstream_detail:
|
||||
lines = [stable_message]
|
||||
if detail.exception_text and detail.exception_text != stable_message:
|
||||
lines.extend(("", "Provider exception:", detail.exception_text))
|
||||
_append_request_id_lines(lines, request_id)
|
||||
return "\n".join(lines)
|
||||
|
||||
if detail.status_code == 405:
|
||||
lines = [
|
||||
f"Upstream provider {provider_name} rejected the request method "
|
||||
"or endpoint (HTTP 405)."
|
||||
]
|
||||
elif detail.status_code is not None:
|
||||
lines = [
|
||||
f"Upstream provider {provider_name} returned HTTP {detail.status_code}."
|
||||
]
|
||||
else:
|
||||
lines = [f"Upstream provider {provider_name} returned an error."]
|
||||
|
||||
category = detail.error_type_hint or _provider_error_category(mapped)
|
||||
if category:
|
||||
lines.append(f"Category: {category}")
|
||||
if stable_message and stable_message != lines[0]:
|
||||
lines.append(f"Mapped message: {stable_message}")
|
||||
|
||||
lines.extend(("", "Upstream error:"))
|
||||
if detail.body_text:
|
||||
lines.append(detail.body_text)
|
||||
else:
|
||||
lines.append("(empty upstream error body)")
|
||||
if detail.body_truncated and (
|
||||
detail.body_text is None
|
||||
or f"truncated after {PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES} bytes"
|
||||
not in detail.body_text
|
||||
):
|
||||
lines.append(
|
||||
f"... [truncated after {PROVIDER_ERROR_BODY_DISPLAY_CAP_BYTES} bytes]"
|
||||
)
|
||||
|
||||
_append_request_id_lines(lines, request_id)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def user_visible_message_for_mapped_provider_error(
|
||||
mapped: Exception,
|
||||
*,
|
||||
provider_name: str,
|
||||
read_timeout_s: float | None,
|
||||
detail: ProviderErrorDetail | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> str:
|
||||
"""Return the user-visible string after :func:`map_error` (405 + mapped types)."""
|
||||
if detail is not None:
|
||||
return format_provider_error_message(
|
||||
mapped,
|
||||
detail,
|
||||
provider_name=provider_name,
|
||||
read_timeout_s=read_timeout_s,
|
||||
request_id=request_id,
|
||||
)
|
||||
if getattr(mapped, "status_code", None) == 405:
|
||||
return (
|
||||
f"Upstream provider {provider_name} rejected the request method "
|
||||
|
||||
Reference in New Issue
Block a user