Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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.
68 changes: 67 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,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
Expand Down Expand Up @@ -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."""
Expand Down
97 changes: 97 additions & 0 deletions tests/unit/sdk/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -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 == []