diff --git a/graphistry/compute/exceptions.py b/graphistry/compute/exceptions.py index 3100a78953..8ede4ab41b 100644 --- a/graphistry/compute/exceptions.py +++ b/graphistry/compute/exceptions.py @@ -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" @@ -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. diff --git a/graphistry/compute/gfql/call/executor.py b/graphistry/compute/gfql/call/executor.py index e3232bc764..15f3f9c1f9 100644 --- a/graphistry/compute/gfql/call/executor.py +++ b/graphistry/compute/gfql/call/executor.py @@ -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 @@ -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, diff --git a/graphistry/tests/compute/gfql/test_alias_scoping_semantics.py b/graphistry/tests/compute/gfql/test_alias_scoping_semantics.py index bb7aceb4f2..05b5ebab99 100644 --- a/graphistry/tests/compute/gfql/test_alias_scoping_semantics.py +++ b/graphistry/tests/compute/gfql/test_alias_scoping_semantics.py @@ -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: diff --git a/graphistry/tests/compute/gfql/test_row_pipeline_ops.py b/graphistry/tests/compute/gfql/test_row_pipeline_ops.py index 1e893baad6..ee65717602 100644 --- a/graphistry/tests/compute/gfql/test_row_pipeline_ops.py +++ b/graphistry/tests/compute/gfql/test_row_pipeline_ops.py @@ -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( @@ -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): diff --git a/graphistry/tests/compute/test_call_runtime_errors.py b/graphistry/tests/compute/test_call_runtime_errors.py new file mode 100644 index 0000000000..368c83eb25 --- /dev/null +++ b/graphistry/tests/compute/test_call_runtime_errors.py @@ -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) diff --git a/graphistry/tests/layout/test_circle_entry_guards.py b/graphistry/tests/layout/test_circle_entry_guards.py index 18c9914c19..ca4d68a1b3 100644 --- a/graphistry/tests/layout/test_circle_entry_guards.py +++ b/graphistry/tests/layout/test_circle_entry_guards.py @@ -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]}) @@ -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)