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:
2026-07-13 13:10:00 -04:00
parent 237656702f
commit b4d0cb22e4
4 changed files with 782 additions and 19 deletions
+106 -16
View File
@@ -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: