Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/+graphql-error-details.added.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog/+graphql-error-message.changed.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 64 additions & 1 deletion infrahub_sdk/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any


Expand All @@ -10,6 +11,50 @@ 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.
"""

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
Expand Down Expand Up @@ -40,13 +85,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."""
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/sdk/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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_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 == []