Skip to content
Merged
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
11 changes: 11 additions & 0 deletions graphistry/compute/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class ErrorCode:
E303 = "invalid-node-reference"
E304 = "invalid-edge-reference"
E305 = "graph-not-bound"
E306 = "call-execution-error"

# Remote transport/response errors (E4xx)
E401 = "remote-request-failed"
Expand Down Expand Up @@ -137,6 +138,16 @@ class GFQLSchemaError(GFQLValidationError):
pass


class GFQLCallExecutionError(GFQLTypeError):
"""A safelisted Call handler raised after parameter validation.

Distinct E306 subtype of ``GFQLTypeError`` for source compatibility with
callers that catch the prior broad class; it identifies engine/handler
failures so callers do not rewrite a syntactically valid query.
"""
pass


class GFQLRemoteError(GFQLValidationError, ValueError):
"""Remote call failed or returned a response the client cannot use.

Expand Down
13 changes: 12 additions & 1 deletion graphistry/compute/gfql/call/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
execute_row_pipeline_call,
is_row_pipeline_call,
)
from graphistry.compute.exceptions import ErrorCode, GFQLSchemaError, GFQLTypeError
from graphistry.compute.exceptions import ErrorCode, GFQLCallExecutionError, GFQLSchemaError, GFQLTypeError, GFQLValidationError
from graphistry.compute.engine_coercion import ensure_engine_match
from graphistry.compute.gfql.policy import PolicyContext, PolicyException
from graphistry.compute.gfql.policy.stats import extract_graph_stats
Expand Down Expand Up @@ -295,6 +295,17 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En
if error is not None:
raise policy_error from error
raise policy_error
engine_name = str(getattr(engine, "value", engine))
if error is not None and not isinstance(error, (PolicyException, GFQLValidationError, NotImplementedError)):
raise GFQLCallExecutionError(
ErrorCode.E306,
f"Call execution failed in '{function}': {error}",
field="function",
value=function,
suggestion="The query passed validation; inspect the chained engine error and engine compatibility.",
engine=engine_name,
original_exception=type(error).__name__,
) from error
if isinstance(error, TypeError):
raise GFQLTypeError(
ErrorCode.E201,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ def test_scalar_rebind_stays_an_error(engine: Literal["pandas", "polars"]) -> No
if engine == "pandas":
with pytest.raises(GFQLTypeError) as exc_info:
_run(PEOPLE_NODES, PEOPLE_EDGES, query, engine)
assert exc_info.value.code == ErrorCode.E303
assert exc_info.value.code == ErrorCode.E306
assert exc_info.value.context["field"] == "function"
assert exc_info.value.context["value"] == "select"
else:
Expand Down
4 changes: 2 additions & 2 deletions graphistry/tests/compute/gfql/test_row_pipeline_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -2135,7 +2135,7 @@ def _assert_runtime_where_rows(

with pytest.raises(GFQLTypeError) as exc_info:
g.gfql(steps + [return_([("id", "id")])])
assert exc_info.value.code == ErrorCode.E303
assert exc_info.value.code == ErrorCode.E306
assert expected_message in exc_info.value.message

@pytest.mark.parametrize(
Expand Down Expand Up @@ -2563,7 +2563,7 @@ def test_row_pipeline_semi_apply_mark_runtime_rejects_blank_join_alias(self):
),
]
)
assert exc_info.value.code == ErrorCode.E303
assert exc_info.value.code == ErrorCode.E306
assert "join_aliases" in exc_info.value.message

def test_row_pipeline_apply_helpers_use_prefixed_join_key_and_payload(self, monkeypatch):
Expand Down
38 changes: 38 additions & 0 deletions graphistry/tests/compute/test_call_runtime_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

import pandas as pd
import pytest

import graphistry
from graphistry.Engine import Engine
from graphistry.compute.exceptions import ErrorCode, GFQLCallExecutionError, GFQLTypeError
from graphistry.compute.gfql.call.executor import execute_call


@pytest.fixture
def graph():
edges = pd.DataFrame({"src": ["a", "b"], "dst": ["b", "a"]})
return graphistry.edges(edges, "src", "dst")


def test_handler_exception_is_call_execution_error(graph, monkeypatch):
def fail(*args, **kwargs):
raise RuntimeError("engine kernel failed")

monkeypatch.setattr(graph, "get_degrees", fail)

with pytest.raises(GFQLCallExecutionError) as info:
execute_call(graph, "get_degrees", {}, engine=Engine.PANDAS)

assert info.value.code == ErrorCode.E306
assert info.value.context["engine"] == "pandas"
assert info.value.context["original_exception"] == "RuntimeError"
assert isinstance(info.value.__cause__, RuntimeError)


def test_unknown_function_keeps_parameter_validation_error(graph):
with pytest.raises(GFQLTypeError) as info:
execute_call(graph, "not_safelisted", {}, engine=Engine.PANDAS)

assert info.value.code == ErrorCode.E303
assert not isinstance(info.value, GFQLCallExecutionError)
4 changes: 2 additions & 2 deletions graphistry/tests/layout/test_circle_entry_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import graphistry
from graphistry.Engine import Engine
from graphistry.compute.exceptions import GFQLTypeError
from graphistry.compute.exceptions import GFQLCallExecutionError
from graphistry.compute.gfql.call.executor import execute_call

TRIANGLE_EDGES = pd.DataFrame({'s': [0, 1, 2], 'd': [1, 2, 0]})
Expand Down Expand Up @@ -209,7 +209,7 @@ def test_cudf_null_partition_key_names_the_partition_column() -> None:
def test_gfql_call_surface_reports_the_missing_bounding_box() -> None:
n = pd.DataFrame({'id': [0, 1, 2]})
g = graphistry.edges(TRIANGLE_EDGES, 's', 'd').nodes(n, 'id')
with pytest.raises(GFQLTypeError, match='requires either bounding_box= or existing x/y node positions'):
with pytest.raises(GFQLCallExecutionError, match='requires either bounding_box= or existing x/y node positions'):
execute_call(g, 'circle_layout', {}, Engine.PANDAS)


Expand Down
Loading