Files

142 lines
4.6 KiB
Python

"""Typed errors for the connector tool gateway, plus THE envelope parser.
House idiom: one ``RuntimeError`` base with a small set of subclasses, one
per condition a caller actually branches on (``microsoft_graph_auth.py`` /
``image_source.py`` precedent). HTTP-level failures use the gateway's nested
error envelope ``{"error": {"code", "message", ...}, "requestId"}`` and are
parsed in exactly one place: :func:`parse_gateway_error`.
Per-tool errors inside a 200 execute envelope are NOT exceptions — they are
result entries, rendered by ``merge.py`` (CONNECTION_REQUIRED payloads via
:func:`render_connection_required`, the single producer of the connect-link
dict shown to the model; the link is deliberately not redacted).
stdlib-only: this module must not import pydantic or any sibling module.
"""
from __future__ import annotations
from typing import Any, Mapping, Optional
__all__ = [
"GatewayAuthError",
"GatewayUnavailable",
"IdempotencyConflict",
"ToolGatewayError",
"parse_gateway_error",
"render_connection_required",
]
class ToolGatewayError(RuntimeError):
"""A connector gateway request failed at the HTTP level.
``retryable`` encodes the retry policy decision (at most one retry, same
idempotency key, transport failures and 5xx only) so the client never
re-derives it from the status code.
"""
def __init__(
self,
message: str,
*,
code: str = "GATEWAY_ERROR",
status: Optional[int] = None,
request_id: Optional[str] = None,
retryable: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.status = status
self.request_id = request_id
self.retryable = retryable
class GatewayAuthError(ToolGatewayError):
"""401/403 — the portal token is missing, expired, or not entitled."""
class GatewayUnavailable(ToolGatewayError):
"""404 from any connector route — connectors are dark for this principal.
This is the silent-degradation signal: callers fall back to local-only
behavior and the model never sees a connector error.
"""
class IdempotencyConflict(ToolGatewayError):
"""409 — the idempotency key was reused with a different body.
Always a client bug; never retried.
"""
def parse_gateway_error(status: int, body: Any) -> ToolGatewayError:
"""Parse an HTTP-level gateway failure into the right exception.
The one place that understands the nested error envelope. Total: any
body shape (dict, text, ``None``) produces a usable exception rather
than raising.
"""
code = f"HTTP_{status}"
message = ""
request_id = None
if isinstance(body, Mapping):
envelope = body.get("error")
if isinstance(envelope, Mapping):
code = str(envelope.get("code") or code)
message = str(envelope.get("message") or "")
raw_request_id = body.get("requestId")
if raw_request_id is not None:
request_id = str(raw_request_id)
elif body:
message = str(body)[:500]
if not message:
message = f"tool gateway request failed with status {status}"
kwargs = {
"code": code,
"status": status,
"request_id": request_id,
}
if status in (401, 403):
return GatewayAuthError(message, **kwargs)
if status == 404:
return GatewayUnavailable(message, **kwargs)
if status == 409:
return IdempotencyConflict(message, **kwargs)
return ToolGatewayError(message, retryable=status >= 500, **kwargs)
def render_connection_required(
*,
connector: Optional[str] = None,
message: Optional[str] = None,
connect_url: Optional[str] = None,
hint: Optional[str] = None,
) -> dict[str, Any]:
"""Render the model-facing CONNECTION_REQUIRED payload.
The single producer of this dict, shared by the execute merge and the
connections tool so the model always sees one shape. The connect link is
passed through un-redacted — the model is allowed to show it to the user.
Wording beyond a fallback message is the gateway's job; this function
does not invent instructions.
"""
payload: dict[str, Any] = {
"code": "CONNECTION_REQUIRED",
"message": message
or (
f"The {connector} connector is not connected for this account."
if connector
else "This connector is not connected for this account."
),
}
if connector:
payload["connector"] = connector
if connect_url:
payload["connect_url"] = connect_url
if hint:
payload["hint"] = hint
return payload