# 🎫 [TOO-145](https://linear.app/arcadedev/issue/TOO-145/adding-gql-error-interceptor-in-tdk) - add GraphQL-specific error adapter, expose it, and slot it ahead of HTTP fallback - map gql query/transport errors (incl. rate-limit handoff) without logging sensitive data - cover adapter + tool chain behavior with new unit tests (pathload, dedupe, edge cases) closes TOO-145 --------- Co-authored-by: Francisco Liberal <francisco@arcade.dev>
186 lines
6.6 KiB
Python
186 lines
6.6 KiB
Python
import importlib
|
|
import logging
|
|
from functools import lru_cache
|
|
from http import HTTPStatus
|
|
from typing import Any
|
|
|
|
from arcade_core.errors import ToolRuntimeError, UpstreamError
|
|
|
|
from arcade_tdk.providers.http.error_adapter import BaseHTTPErrorMapper
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Standard Apollo/GraphQL error codes mapped to HTTP status codes
|
|
_GQL_CODE_TO_STATUS = {
|
|
"UNAUTHENTICATED": 401,
|
|
"NOT_AUTHENTICATED": 401,
|
|
"FORBIDDEN": 403,
|
|
"ACCESS_DENIED": 403,
|
|
"NOT_FOUND": 404,
|
|
"BAD_USER_INPUT": 400,
|
|
"GRAPHQL_VALIDATION_FAILED": 400,
|
|
"GRAPHQL_PARSE_FAILED": 400,
|
|
"INTERNAL_SERVER_ERROR": 500,
|
|
}
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _load_gql_transport_errors() -> (
|
|
tuple[type[Any], type[Any], type[Any], type[Any], type[Any]] | None
|
|
):
|
|
"""Import gql transport exceptions lazily and cache the result."""
|
|
try:
|
|
module = importlib.import_module("gql.transport.exceptions")
|
|
except ImportError:
|
|
logger.debug("gql not installed; GraphQL adapter disabled")
|
|
return None
|
|
else:
|
|
return (
|
|
module.TransportError,
|
|
module.TransportQueryError,
|
|
module.TransportServerError,
|
|
module.TransportConnectionFailed,
|
|
module.TransportProtocolError,
|
|
)
|
|
|
|
|
|
def _extract_error_message(message: Any) -> str:
|
|
"""Return the error message or a fallback."""
|
|
if not message:
|
|
return "Unknown GraphQL error"
|
|
try:
|
|
return str(message) or "Unknown GraphQL error"
|
|
except Exception:
|
|
return "Unknown GraphQL error"
|
|
|
|
|
|
class GraphQLErrorAdapter(BaseHTTPErrorMapper):
|
|
"""Error adapter for GraphQL clients (specifically 'gql' library)."""
|
|
|
|
slug = "_graphql"
|
|
|
|
def from_exception(self, exc: Exception) -> ToolRuntimeError | None:
|
|
"""Translate a gql exception into a ToolRuntimeError."""
|
|
gql_types = _load_gql_transport_errors()
|
|
if not gql_types:
|
|
return None
|
|
|
|
(
|
|
TransportError,
|
|
TransportQueryError,
|
|
TransportServerError,
|
|
TransportConnectionFailed,
|
|
TransportProtocolError,
|
|
) = gql_types
|
|
|
|
# GraphQL errors in response (HTTP 200 with errors array)
|
|
if isinstance(exc, TransportQueryError):
|
|
return self._handle_query_error(exc)
|
|
|
|
# HTTP-level errors (4xx, 5xx) - these can have rate limit headers
|
|
if isinstance(exc, TransportServerError):
|
|
return self._handle_transport_error(exc)
|
|
|
|
# Network/protocol errors - simple 502
|
|
if isinstance(exc, (TransportConnectionFailed, TransportProtocolError)):
|
|
return UpstreamError(
|
|
message=f"Upstream GraphQL error: {type(exc).__name__}",
|
|
status_code=HTTPStatus.BAD_GATEWAY.value,
|
|
developer_message=str(exc),
|
|
extra={"service": self.slug, "error_type": type(exc).__name__},
|
|
)
|
|
|
|
# Catch-all for unknown TransportError subclasses
|
|
if isinstance(exc, TransportError):
|
|
return self._handle_transport_error(exc)
|
|
|
|
return None
|
|
|
|
def _handle_query_error(self, exc: Any) -> UpstreamError:
|
|
"""Handle TransportQueryError (GraphQL errors in response body)."""
|
|
errors_list = exc.errors or []
|
|
logger.debug("GraphQL query errors: %s", errors_list)
|
|
|
|
messages = [_extract_error_message(e.get("message")) for e in errors_list]
|
|
joined = "; ".join(messages) if messages else "Unknown GraphQL error"
|
|
|
|
# Extract error codes and map to HTTP status
|
|
codes: list[str] = []
|
|
status = HTTPStatus.UNPROCESSABLE_ENTITY.value
|
|
|
|
for e in errors_list:
|
|
ext = e.get("extensions") if isinstance(e, dict) else None
|
|
code = ext.get("code") if isinstance(ext, dict) else None
|
|
if isinstance(code, str):
|
|
codes.append(code)
|
|
mapped = _GQL_CODE_TO_STATUS.get(code)
|
|
if mapped and mapped > status:
|
|
status = mapped
|
|
|
|
unique_codes = sorted(set(codes))
|
|
|
|
return UpstreamError(
|
|
message=f"Upstream GraphQL error: {joined}",
|
|
status_code=status,
|
|
developer_message=f"GraphQL error codes: {', '.join(unique_codes)}"
|
|
if unique_codes
|
|
else "GraphQL error",
|
|
extra={
|
|
"service": self.slug,
|
|
"error_type": "TransportQueryError",
|
|
"gql_error_codes": unique_codes,
|
|
},
|
|
)
|
|
|
|
def _handle_transport_error(self, exc: Any) -> UpstreamError:
|
|
"""Handle TransportServerError and other transport errors."""
|
|
status = getattr(exc, "code", None)
|
|
if not isinstance(status, int):
|
|
status = HTTPStatus.INTERNAL_SERVER_ERROR.value
|
|
|
|
# Extract headers for rate limit detection (check exc and __cause__)
|
|
headers = self._get_headers(exc) or self._get_headers(exc.__cause__)
|
|
|
|
# Extract URL from __cause__ (aiohttp/httpx/requests store it there)
|
|
url, method = self._get_request_info(exc.__cause__)
|
|
|
|
return self._map_status_to_error(
|
|
status=status,
|
|
headers=headers or {},
|
|
msg=f"Upstream GraphQL error: {_extract_error_message(str(exc))}",
|
|
request_url=url,
|
|
request_method=method,
|
|
)
|
|
|
|
def _get_headers(self, obj: Any) -> dict[str, str] | None:
|
|
"""Extract headers from an object if available."""
|
|
if obj and hasattr(obj, "response") and hasattr(obj.response, "headers"):
|
|
return {k.lower(): v for k, v in obj.response.headers.items()}
|
|
return None
|
|
|
|
def _get_request_info(self, cause: Any) -> tuple[str | None, str | None]:
|
|
"""Extract URL and method from the __cause__ exception."""
|
|
if not cause:
|
|
return None, None
|
|
|
|
# aiohttp: request_info.url
|
|
if hasattr(cause, "request_info"):
|
|
ri = cause.request_info
|
|
url = getattr(ri, "url", None) or getattr(ri, "real_url", None)
|
|
return (str(url), getattr(ri, "method", None)) if url else (None, None)
|
|
|
|
# httpx/requests: response.request.url
|
|
if hasattr(cause, "response") and hasattr(cause.response, "request"):
|
|
req = cause.response.request
|
|
url = getattr(req, "url", None)
|
|
return (str(url), getattr(req, "method", None)) if url else (None, None)
|
|
|
|
return None, None
|
|
|
|
def _build_extra_metadata(
|
|
self, request_url: str | None = None, request_method: str | None = None
|
|
) -> dict[str, str]:
|
|
"""Override to use GraphQL service slug."""
|
|
extra = super()._build_extra_metadata(request_url, request_method)
|
|
extra["service"] = self.slug
|
|
return extra
|