Skip to content
Draft
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
19 changes: 12 additions & 7 deletions packages/cli/src/pipefy_cli/commands/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@
from typing import Any, TypeVar

import typer
from gql.transport.exceptions import TransportError, TransportQueryError
from pipefy_sdk import PipefyClient, PipefySettings, stream_bytes
from gql.transport.exceptions import TransportError
from pipefy_sdk import (
PipefyClient,
PipefyGraphQLError,
PipefySettings,
stream_bytes,
)
from pipefy_sdk.exceptions import PipefyError
from pipefy_sdk.label_color import normalize_label_color
from pipefy_sdk.report_filter_preflight import prepare_report_cards_filter
Expand Down Expand Up @@ -221,15 +226,15 @@ async def _run() -> _T:
raise typer.Exit(value_error_exit_code) from exc
except BrokenPipeError:
raise typer.Exit(0) from None
except TransportQueryError as exc:
except PipefyGraphQLError as exc:
typer.echo(_format_transport_query_error(exc), err=True)
raise typer.Exit(1) from exc
except TransportError as exc:
typer.echo(f"Pipefy transport error: {exc}", err=True)
raise typer.Exit(1) from exc


def _format_transport_query_error(exc: TransportQueryError) -> str:
def _format_transport_query_error(exc: PipefyGraphQLError) -> str:
"""Render a GraphQL transport error as a clean single-line message for the CLI.

Falls back to ``str(exc)`` when the structured ``errors`` payload is missing or empty.
Expand All @@ -243,7 +248,7 @@ def _format_transport_query_error(exc: TransportQueryError) -> str:
return f"{message} ({code})" if code else message


def format_card_get_transport_query_error(exc: TransportQueryError) -> str:
def format_card_get_transport_query_error(exc: PipefyGraphQLError) -> str:
"""Like :func:`_format_transport_query_error` with a hint for missing/deleted cards."""
base = _format_transport_query_error(exc)
errors = getattr(exc, "errors", None) or []
Expand Down Expand Up @@ -334,7 +339,7 @@ def run_cli_command(
coro_factory: Callable[[PipefyClient], Awaitable[_R]],
*,
exit_code_2_on_value_error: bool = True,
format_transport_query_error: Callable[[TransportQueryError], str] | None = None,
format_transport_query_error: Callable[[PipefyGraphQLError], str] | None = None,
) -> None:
"""Run an async coroutine factory with a configured client and render the result.

Expand All @@ -358,7 +363,7 @@ async def _run() -> _R:
except PipefyError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(1) from exc
except TransportQueryError as exc:
except PipefyGraphQLError as exc:
typer.echo(transport_fmt(exc), err=True)
raise typer.Exit(1) from exc
except TransportError as exc:
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/pipefy_cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import typer
from gql.transport.exceptions import (
TransportError,
TransportQueryError,
TransportServerError,
)
from pipefy_auth import (
Expand All @@ -33,7 +32,7 @@
store_session,
)
from pipefy_auth.settings import _LEGACY_ENV_KEYS_TO_NEW
from pipefy_sdk import MePayload, PipefySettings
from pipefy_sdk import MePayload, PipefyGraphQLError, PipefySettings

from pipefy_cli._docs import DOCS_CLI_AUTH_REF
from pipefy_cli.auth import (
Expand Down Expand Up @@ -402,7 +401,7 @@ def _fetch_identity(
raise _StatusExit(
report=report, exit_code=1, stderr=f"Identity fetch failed: {exc}"
) from exc
except (TransportQueryError, TransportError) as exc:
except (PipefyGraphQLError, TransportError) as exc:
raise _StatusExit(
report=report, exit_code=1, stderr=f"Pipefy transport error: {exc}"
) from exc
Expand Down
8 changes: 3 additions & 5 deletions packages/cli/tests/test_card_get.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

import pytest
from _shared.live_settings import live_pipefy_settings, require_live_creds
from gql.transport.exceptions import TransportQueryError
from pipefy_sdk import PipefySettings
from pipefy_sdk import PipefyGraphQLError, PipefySettings
from pipefy_sdk.exceptions import PipefyAPIError

from pipefy_cli.main import app
Expand Down Expand Up @@ -80,9 +79,8 @@ def test_card_get_permission_denied_hint_on_stderr(
monkeypatch.setenv("PIPEFY_SERVICE_ACCOUNT_CLIENT_ID", "cid")
monkeypatch.setenv("PIPEFY_SERVICE_ACCOUNT_CLIENT_SECRET", "sec")

exc = TransportQueryError(
"unused",
errors=[{"message": "Forbidden", "extensions": {"code": "PERMISSION_DENIED"}}],
exc = PipefyGraphQLError(
[{"message": "Forbidden", "extensions": {"code": "PERMISSION_DENIED"}}]
)
mock_client = MagicMock()
mock_client.get_card = AsyncMock(side_effect=exc)
Expand Down
8 changes: 3 additions & 5 deletions packages/mcp/src/pipefy_mcp/tools/ai_tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@

from pipefy_mcp.tools.graphql_error_helpers import (
extract_error_strings,
strip_internal_api_diagnostic_markers,
)
from pipefy_mcp.tools.tool_error_envelope import (
ToolErrorDetail,
Expand Down Expand Up @@ -306,17 +305,16 @@ def enrich_behavior_error(
) -> str:
"""Build an enriched error message with behavior context and actionable hints.

Extracts GraphQL messages, strips internal_api-style ``[code=…]`` /
``[correlation_id=…]`` markers from the primary line, appends a behavior
summary, and matches known error patterns to actionable advice.
Extracts the GraphQL messages, appends a behavior summary, and matches known
error patterns to actionable advice.

Args:
exc: The exception from the service call.
behaviors: The original behavior dicts sent by the caller (for context).
"""
msgs = extract_error_strings(exc)
base = "; ".join(msgs) if msgs else str(exc)
base = strip_internal_api_diagnostic_markers(base).strip()
base = base.strip()
if not base:
base = _BEHAVIOR_ERROR_EMPTY_AFTER_SANITIZE

Expand Down
2 changes: 0 additions & 2 deletions packages/mcp/src/pipefy_mcp/tools/automation_tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
extract_error_strings,
extract_graphql_correlation_id,
extract_graphql_error_codes,
strip_internal_api_diagnostic_markers,
try_enrich_graphql_error,
with_debug_suffix,
)
Expand Down Expand Up @@ -173,7 +172,6 @@ async def handle_automation_tool_graphql_error(

msgs = extract_error_strings(exc)
base = "; ".join(msgs) if msgs else _AUTOMATION_REQUEST_FAILED
base = strip_internal_api_diagnostic_markers(base)
if not base.strip():
base = _AUTOMATION_REQUEST_FAILED
base = with_debug_suffix(base, debug=debug, codes=codes, correlation_id=cid)
Expand Down
80 changes: 20 additions & 60 deletions packages/mcp/src/pipefy_mcp/tools/graphql_error_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,6 @@
import pipefy_mcp.settings as _settings_mod
from pipefy_mcp.tools.tool_error_envelope import tool_error

# Suffixes appended by the Internal API executor for service-layer diagnostics;
# MCP tools strip these from default user-visible errors.
_INTERNAL_API_CODE_SUFFIX_RE = re.compile(r"\s*\[code=[^\]]*\]")
_INTERNAL_API_CORRELATION_SUFFIX_RE = re.compile(r"\s*\[correlation_id=[^\]]*\]")
_INTERNAL_API_CODE_BRACKET_CAPTURE_RE = re.compile(r"\[code=([^\]]*)\]")
_INTERNAL_API_CORRELATION_BRACKET_CAPTURE_RE = re.compile(
r"\[correlation_id=([^\]]*)\]"
)

_DICT_REPR_PREFIX_RE = re.compile(r"^\s*\{\s*['\"]message['\"]\s*:")


Expand Down Expand Up @@ -73,54 +64,11 @@ def _try_extract_message_from_dict_repr(raw: str) -> str | None:
return None


def strip_internal_api_diagnostic_markers(message: str) -> str:
"""Remove ``[code=…]`` / ``[correlation_id=…]`` markers from a message string.

The Internal API executor appends these to GraphQL error text for logs and
tests. Multiple occurrences (e.g. ``; ``-joined errors) are all removed.

Args:
message: Raw error text, often ``str(ValueError(...))`` from the executor.
"""
stripped = _INTERNAL_API_CORRELATION_SUFFIX_RE.sub("", message)
stripped = _INTERNAL_API_CODE_SUFFIX_RE.sub("", stripped)
return stripped.strip()


def extract_internal_api_bracket_codes(message: str) -> list[str]:
"""Collect distinct ``code`` values from ``[code=…]`` markers (Internal API executor).

Args:
message: Raw exception text before stripping markers.
"""
seen: set[str] = set()
out: list[str] = []
for raw_code in _INTERNAL_API_CODE_BRACKET_CAPTURE_RE.findall(message):
code = raw_code.strip()
if code and code not in seen:
seen.add(code)
out.append(code)
return out


def extract_internal_api_bracket_correlation_id(message: str) -> str | None:
"""Return the first non-empty correlation id from ``[correlation_id=…]`` markers.

Args:
message: Raw exception text before stripping markers.
"""
for raw_cid in _INTERNAL_API_CORRELATION_BRACKET_CAPTURE_RE.findall(message):
cid = raw_cid.strip()
if cid:
return cid
return None


def extract_error_strings(exc: BaseException) -> list[str]:
"""Best-effort extraction of error messages from gql/GraphQL exceptions.

When the exception carries a structured ``errors`` list (e.g. gql
``TransportQueryError``), only the extracted ``message`` strings are
When the exception carries a structured ``errors`` list (e.g.
``PipefyGraphQLError``), only the extracted ``message`` strings are
returned; the raw ``str(exc)`` is skipped because it often contains
the full error dict with ``locations`` / ``extensions`` noise. The
raw string is used as a fallback only when no structured messages
Expand Down Expand Up @@ -173,7 +121,6 @@ def extract_graphql_error_codes(exc: BaseException) -> list[str]:
if raw:
for match in re.findall(r"""['"]code['"]\s*[:=]\s*['"]([A-Z_]+)['"]""", raw):
codes.append(match)
codes.extend(extract_internal_api_bracket_codes(raw))

seen: set[str] = set()
unique: list[str] = []
Expand All @@ -185,15 +132,31 @@ def extract_graphql_error_codes(exc: BaseException) -> list[str]:


def extract_graphql_correlation_id(exc: BaseException) -> str | None:
"""Best-effort extraction of correlation_id from GraphQL exception strings."""
"""Best-effort extraction of correlation_id from a GraphQL exception.

Reads the structured ``errors`` list first (each error's ``extensions``),
then falls back to a regex over ``str(exc)``.
"""
errors = getattr(exc, "errors", None)
if isinstance(errors, list):
for item in errors:
if not isinstance(item, dict):
continue
extensions = item.get("extensions")
if not isinstance(extensions, dict):
continue
correlation_id = extensions.get("correlation_id")
if isinstance(correlation_id, str) and correlation_id:
return correlation_id

raw = str(exc)
if not raw:
return None

match = re.search(r"""['"]correlation_id['"]\s*[:=]\s*['"]([^'"]+)['"]""", raw)
if match:
return match.group(1)
return extract_internal_api_bracket_correlation_id(raw)
return None


def with_debug_suffix(
Expand Down Expand Up @@ -581,10 +544,7 @@ async def enrich_permission_denied_error(
"extract_error_strings",
"extract_graphql_correlation_id",
"extract_graphql_error_codes",
"extract_internal_api_bracket_codes",
"extract_internal_api_bracket_correlation_id",
"handle_tool_graphql_error",
"strip_internal_api_diagnostic_markers",
"try_enrich_graphql_error",
"with_debug_suffix",
]
4 changes: 2 additions & 2 deletions packages/mcp/src/pipefy_mcp/tools/observability_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ def _rewrite_ai_agent_log_not_found(exc: BaseException, log_uuid: str) -> str |
The ``aiAgentLogDetails`` resolver looks up by ``AutomationAction`` under
the hood; when the UUID isn't found, the upstream error string exposes
that internal type (``"Couldn't find AutomationAction with 'id'=..."``).
``TransportQueryError`` hides per-error messages behind ``.errors``, so we
check both ``str(exc)`` and the structured error list.
``PipefyGraphQLError`` keeps per-error messages in ``.errors``, so we check
both ``str(exc)`` and the structured error list.
"""
candidates = [str(exc), *extract_error_strings(exc)]
for msg in candidates:
Expand Down
6 changes: 3 additions & 3 deletions packages/mcp/src/pipefy_mcp/tools/pipe_config_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ async def _diagnose_phase_field_cascade(
"""
if pipe_id is None:
return None
# gql's ``TransportQueryError`` hides the structured per-error ``message``
# and ``extensions.code`` behind attributes, so ``str(exc)`` alone only
# returns the outer wrapper ("GraphQL Error"). Combine both signals.
# ``PipefyGraphQLError`` keeps the structured per-error ``message`` and
# ``extensions.code`` in ``.errors``, so ``str(exc)`` alone only returns the
# joined message text. Combine both signals.
signal_parts: list[str] = [str(exc).lower()]
signal_parts.extend(m.lower() for m in extract_error_strings(exc))
signal_parts.extend(c.lower() for c in extract_graphql_error_codes(exc))
Expand Down
13 changes: 4 additions & 9 deletions packages/mcp/src/pipefy_mcp/tools/portal_tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@
from collections.abc import Awaitable, Callable
from typing import Any

from gql.transport.exceptions import TransportQueryError
from pipefy_sdk import PipefyGraphQLError
from pipefy_sdk.exceptions import PortalPermissionError
from pydantic import ValidationError

from pipefy_mcp.tools.graphql_error_helpers import (
extract_error_strings,
extract_graphql_error_codes,
strip_internal_api_diagnostic_markers,
)
from pipefy_mcp.tools.introspection_tool_helpers import (
build_error_payload,
Expand Down Expand Up @@ -63,16 +62,12 @@ def map_portal_error_to_message(exc: BaseException) -> str:
if "PERMISSION_DENIED" in codes or "permission denied" in lowered:
return _PORTAL_PERMISSION_GUIDANCE

if isinstance(exc, TransportQueryError):
if isinstance(exc, PipefyGraphQLError):
messages = extract_error_strings(exc)
if messages:
return strip_internal_api_diagnostic_markers("; ".join(messages))
return "; ".join(messages)

return (
strip_internal_api_diagnostic_markers(text)
if text
else "Portal operation failed. Try again or contact support."
)
return text or "Portal operation failed. Try again or contact support."


def validate_tool_ids(
Expand Down
Loading