fix: map Gitea auth failures to structured MCP tool errors (Closes #699)
HTTP 401/scope-403/network failures become typed client errors with stable reason codes. The FastMCP Tool.run boundary returns CallToolResult isError payloads so stdio transport survives; daemon logs use sanitized reason codes only. Regression tests cover author/reconciler profiles, transport survival, and non-misclassification of unexpected exceptions. Cross-links: #685, #695, #697, #698, PR #696 (scopes not absorbed).
This commit is contained in:
+106
-16
@@ -243,6 +243,96 @@ def _redact(text):
|
||||
return str(text)
|
||||
|
||||
|
||||
# ── Classified client failures (#699) ─────────────────────────────────────────
|
||||
# Subclasses of RuntimeError preserve existing ``except RuntimeError`` call
|
||||
# sites. The MCP tool-error boundary maps these to sanitized CallToolResult
|
||||
# isError payloads so auth-class failures never terminate stdio transport.
|
||||
|
||||
|
||||
class GiteaClientError(RuntimeError):
|
||||
"""Base for known Gitea client failures with a stable reason_code."""
|
||||
|
||||
reason_code = "client_error"
|
||||
error_class = "client"
|
||||
http_status = None
|
||||
|
||||
def __init__(self, message, *, reason_code=None, http_status=None):
|
||||
super().__init__(message)
|
||||
if reason_code is not None:
|
||||
self.reason_code = reason_code
|
||||
if http_status is not None:
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
class GiteaAuthError(GiteaClientError):
|
||||
"""Authentication failure (invalid/revoked credentials → typically HTTP 401)."""
|
||||
|
||||
reason_code = "auth_failed"
|
||||
error_class = "authentication"
|
||||
http_status = 401
|
||||
|
||||
|
||||
class GiteaAuthzError(GiteaClientError):
|
||||
"""Authorization / insufficient-scope failure (typically HTTP 403 + scope)."""
|
||||
|
||||
reason_code = "authz_insufficient_scope"
|
||||
error_class = "authorization"
|
||||
http_status = 403
|
||||
|
||||
|
||||
class GiteaNetworkError(GiteaClientError):
|
||||
"""Transport / DNS / timeout failure contacting Gitea."""
|
||||
|
||||
reason_code = "network_error"
|
||||
error_class = "network"
|
||||
http_status = None
|
||||
|
||||
|
||||
class GiteaConfigError(GiteaClientError):
|
||||
"""Local configuration / credential resolution failure (not HTTP auth)."""
|
||||
|
||||
reason_code = "config_error"
|
||||
error_class = "configuration"
|
||||
http_status = None
|
||||
|
||||
|
||||
def _looks_like_insufficient_scope(detail: str) -> bool:
|
||||
"""True when a 403 body indicates token scope deficiency, not generic deny."""
|
||||
lower = (detail or "").lower()
|
||||
markers = (
|
||||
"insufficient scope",
|
||||
"required scope",
|
||||
"does not have at least one of required scope",
|
||||
"token does not have",
|
||||
"missing scope",
|
||||
"scope(s)",
|
||||
)
|
||||
return any(m in lower for m in markers)
|
||||
|
||||
|
||||
def _raise_http_error(code: int, detail: str) -> None:
|
||||
"""Raise a classified client error for a non-retryable HTTP failure."""
|
||||
safe = _redact(detail).strip()
|
||||
if code == 401:
|
||||
msg = f"HTTP 401: {safe}" if safe else "HTTP 401: authentication failed"
|
||||
raise GiteaAuthError(
|
||||
msg,
|
||||
reason_code="auth_invalid_token",
|
||||
http_status=401,
|
||||
)
|
||||
if code == 403 and _looks_like_insufficient_scope(safe):
|
||||
msg = f"HTTP 403: {safe}" if safe else "HTTP 403: insufficient scope"
|
||||
raise GiteaAuthzError(
|
||||
msg,
|
||||
reason_code="authz_insufficient_scope",
|
||||
http_status=403,
|
||||
)
|
||||
if code in (502, 503, 504):
|
||||
msg = f"HTTP {code}: Gitea upstream unavailable"
|
||||
raise RuntimeError(f"{msg}: {safe}" if safe else msg)
|
||||
raise RuntimeError(f"HTTP {code}: {safe}" if safe else f"HTTP {code}")
|
||||
|
||||
|
||||
def _add_query(url, **params):
|
||||
"""Return *url* with the given query parameters added or overridden.
|
||||
|
||||
@@ -315,23 +405,21 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
"""Make an authenticated JSON request to the Gitea API.
|
||||
|
||||
Returns parsed JSON on success (or ``None`` for an empty body), and raises
|
||||
``RuntimeError`` on failure.
|
||||
a classified client error on failure.
|
||||
|
||||
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
||||
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
||||
using capped jittered exponential backoff. Successful responses are
|
||||
unchanged.
|
||||
|
||||
All failures are converted to a ``RuntimeError`` with a clear, secret
|
||||
-redacted message (no raw stack traces or credential material):
|
||||
All failures use a clear, secret-redacted message (no raw stack traces or
|
||||
credential material). Classification (#699):
|
||||
|
||||
- Non-429 HTTP errors surface the status code and a redacted response body.
|
||||
502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
|
||||
message.
|
||||
- Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
|
||||
a generic "network error contacting Gitea" message.
|
||||
- A malformed (non-JSON) success body surfaces a "malformed JSON response"
|
||||
message rather than a raw decode error.
|
||||
- HTTP 401 → :class:`GiteaAuthError` (``auth_invalid_token``)
|
||||
- HTTP 403 with scope deficiency → :class:`GiteaAuthzError`
|
||||
- Other non-429 HTTP errors → ``RuntimeError`` (502/503/504 note upstream)
|
||||
- Timeouts / DNS / ``URLError`` → :class:`GiteaNetworkError`
|
||||
- Malformed success JSON → ``RuntimeError`` (not reclassified as auth)
|
||||
|
||||
The ``*_func`` parameters and ``timeout`` are injection points for
|
||||
deterministic testing.
|
||||
@@ -370,14 +458,16 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
except Exception:
|
||||
error_body = ""
|
||||
detail = _redact(error_body).strip()
|
||||
if e.code in (502, 503, 504):
|
||||
msg = f"HTTP {e.code}: Gitea upstream unavailable"
|
||||
raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
|
||||
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
|
||||
try:
|
||||
_raise_http_error(e.code, detail)
|
||||
except Exception as mapped:
|
||||
raise mapped from e
|
||||
raise RuntimeError(f"HTTP {e.code}: {detail}") from e # pragma: no cover
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
reason = getattr(e, "reason", e)
|
||||
raise RuntimeError(
|
||||
f"network error contacting Gitea: {_redact(reason)}"
|
||||
raise GiteaNetworkError(
|
||||
f"network error contacting Gitea: {_redact(reason)}",
|
||||
reason_code="network_error",
|
||||
) from e
|
||||
|
||||
if not body:
|
||||
|
||||
Reference in New Issue
Block a user