diff --git a/changelog/+graphql-error-details.added.md b/changelog/+graphql-error-details.added.md new file mode 100644 index 00000000..3a9d291b --- /dev/null +++ b/changelog/+graphql-error-details.added.md @@ -0,0 +1 @@ +`GraphQLError` now parses the error catalogue extensions returned by the Infrahub server: each entry in `errors` is exposed as a structured `GraphQLErrorDetail` (message, catalogue `code`, `http_status`, `data`, `path`) via the new `details` attribute, and `codes` lists the catalogue error codes present in the response. diff --git a/changelog/+graphql-error-message.changed.md b/changelog/+graphql-error-message.changed.md new file mode 100644 index 00000000..b9083d3b --- /dev/null +++ b/changelog/+graphql-error-message.changed.md @@ -0,0 +1 @@ +The `GraphQLError` exception message no longer embeds the executed query and the raw error payload; it now joins the server-provided error messages only. The query and variables remain available on the `query` and `variables` attributes for programmatic access, keeping potentially large or sensitive payloads out of logs and tracebacks. diff --git a/infrahub_sdk/exceptions.py b/infrahub_sdk/exceptions.py index f0774c2d..7a49b94f 100644 --- a/infrahub_sdk/exceptions.py +++ b/infrahub_sdk/exceptions.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass from typing import Any @@ -10,6 +11,53 @@ def __init__(self, message: str | None = None) -> None: super().__init__(self.message) +@dataclass(frozen=True) +class GraphQLErrorDetail: + """Structured view of a single entry from a GraphQL response's `errors` array. + + `code`, `http_status` and `data` come from the error catalogue extensions + that the Infrahub server attaches to each error (`extensions.code`, + `extensions.http_status`, `extensions.data`). They are `None` when the + server did not provide them. + + The raw `locations` field is intentionally not exposed here; the unparsed + entry remains available through the exception's `errors` attribute. + """ + + message: str | None = None + code: str | None = None + http_status: int | None = None + data: dict[str, Any] | None = None + path: list[str | int] | None = None + + +def _parse_graphql_error_details(errors: Any) -> list[GraphQLErrorDetail]: + entries = errors if isinstance(errors, list) else [errors] + details: list[GraphQLErrorDetail] = [] + for entry in entries: + if isinstance(entry, dict): + extensions = entry.get("extensions") + if not isinstance(extensions, dict): + extensions = {} + message = entry.get("message") + code = extensions.get("code") + http_status = extensions.get("http_status") + data = extensions.get("data") + path = entry.get("path") + details.append( + GraphQLErrorDetail( + message=str(message) if message is not None else None, + code=code if isinstance(code, str) else None, + http_status=http_status if isinstance(http_status, int) else None, + data=data if isinstance(data, dict) else None, + path=path if isinstance(path, list) else None, + ) + ) + elif entry is not None: + details.append(GraphQLErrorDetail(message=str(entry))) + return details + + class JsonDecodeError(Error): def __init__(self, message: str | None = None, content: str | None = None, url: str | None = None) -> None: self.message = message @@ -40,13 +88,31 @@ def __init__(self, url: str, timeout: int | None = None, message: str | None = N class GraphQLError(Error): + """Raised when a GraphQL response contains entries in its `errors` array. + + The exception message only carries the server-provided error messages; the + executed query and its variables stay available on the `query` and + `variables` attributes so they never leak into logs or tracebacks by + default. Catalogue metadata attached by the server is exposed through + `details` and `codes`. + """ + def __init__(self, errors: list[dict[str, Any]], query: str | None = None, variables: dict | None = None) -> None: self.query = query self.variables = variables self.errors = errors - self.message = f"An error occurred while executing the GraphQL Query {self.query}, {self.errors}" + self.details = _parse_graphql_error_details(errors) + detail_messages = "; ".join(detail.message for detail in self.details if detail.message) + self.message = "An error occurred while executing the GraphQL Query" + if detail_messages: + self.message += f": {detail_messages}" super().__init__(self.message) + @property + def codes(self) -> list[str]: + """Return the catalogue error codes reported by the server, one per error that carried one.""" + return [detail.code for detail in self.details if detail.code] + class VersionNotSupportedError(Error): """Raised when a feature is used against an Infrahub server version that does not support it.""" diff --git a/tests/unit/sdk/test_exceptions.py b/tests/unit/sdk/test_exceptions.py new file mode 100644 index 00000000..bdbe6dcc --- /dev/null +++ b/tests/unit/sdk/test_exceptions.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any + +from infrahub_sdk.exceptions import GraphQLError, GraphQLErrorDetail + + +def test_graphql_error_parses_catalogue_extensions() -> None: + error = GraphQLError( + errors=[ + { + "message": "Query is not valid: Cannot query field 'DemoMissingNode' on type 'Query'.", + "path": ["CoreGraphQLQueryCreate"], + "extensions": {"code": "GRAPHQL_QUERY_INVALID", "http_status": 422, "data": {}}, + } + ], + query="mutation { CoreGraphQLQueryCreate { ok } }", + variables={"secret": "should-not-leak"}, + ) + + assert error.details == [ + GraphQLErrorDetail( + message="Query is not valid: Cannot query field 'DemoMissingNode' on type 'Query'.", + code="GRAPHQL_QUERY_INVALID", + http_status=422, + data={}, + path=["CoreGraphQLQueryCreate"], + ) + ] + assert error.codes == ["GRAPHQL_QUERY_INVALID"] + + +def test_graphql_error_message_excludes_query_and_variables() -> None: + error = GraphQLError( + errors=[ + {"message": "first error"}, + {"message": "second error"}, + ], + query="query Sensitive { secretField }", + variables={"password": "should-not-leak"}, + ) + + assert str(error) == "An error occurred while executing the GraphQL Query: first error; second error" + assert "secretField" not in str(error) + assert "should-not-leak" not in str(error) + assert error.query == "query Sensitive { secretField }" + assert error.variables == {"password": "should-not-leak"} + + +def test_graphql_error_without_extensions() -> None: + error = GraphQLError(errors=[{"message": "plain error"}]) + + assert error.details == [GraphQLErrorDetail(message="plain error")] + assert error.codes == [] + assert str(error) == "An error occurred while executing the GraphQL Query: plain error" + + +def test_graphql_error_with_non_dict_entries() -> None: + errors: list[Any] = ["a bare string error"] + error = GraphQLError(errors=errors) + + assert error.details == [GraphQLErrorDetail(message="a bare string error")] + assert str(error) == "An error occurred while executing the GraphQL Query: a bare string error" + + +def test_graphql_error_with_non_list_errors() -> None: + error = GraphQLError(errors="a single scalar error") # type: ignore[arg-type] + + assert error.details == [GraphQLErrorDetail(message="a single scalar error")] + assert error.codes == [] + assert str(error) == "An error occurred while executing the GraphQL Query: a single scalar error" + + +def test_graphql_error_with_empty_errors() -> None: + error = GraphQLError(errors=[]) + + assert error.details == [] + assert error.codes == [] + assert str(error) == "An error occurred while executing the GraphQL Query" + + +def test_graphql_error_ignores_malformed_extensions() -> None: + error = GraphQLError( + errors=[ + { + "message": "typed wrong", + "extensions": {"code": 42, "http_status": "not-an-int", "data": ["not", "a", "dict"]}, + }, + {"message": "extensions not a dict", "extensions": "bogus"}, + ] + ) + + assert error.details == [ + GraphQLErrorDetail(message="typed wrong"), + GraphQLErrorDetail(message="extensions not a dict"), + ] + assert error.codes == []