142 lines
4.6 KiB
Python
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
|