Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

fix(registry): 节点插件发现失败不可观测且不可重试——`_discovered` 在扫描前置位、`ep.load()` 异常只留一行无 traceback 的 warning,解析期报成「节点类型不存在」

Closed
#98 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
66/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
python
Domain
backend

Research direction

Start in discover() and _discover_entry_points() in plaita/node/init.py: the _discovered flag is set before the scan, and the per-entry-point except Exception drops both the exception and its traceback. Reproduce with a mocked entry_points that raises, then call discover() again to confirm it returns without rescanning. Done when a failed scan allows a retry, plugin load errors are kept on the registry and logged with exc_info, and the new test file tests/unit/test_node_registry_discovery.py passes.

Written by the indexing model from the issue text.

Description

优先级 P2 · 依赖:无

基线 HEAD = 65f48f2

Summary

NodeRegistry.discover() 把「发现一次」的标记 _discovered 在扫描之前置位,且扫描本身、以及每个 entry point 的 ep.load() 失败都被静默吞掉。结果是:插件发现一旦失败,本进程内永久不再重试,且失败原因(缺哪个模块、什么异常)从日志与错误文案里完全消失。

证据 1 — _discovered 在扫描前置位,失败后不再重试

plaita/node/__init__.py:190-199(逐字):

    def discover(self) -> "NodeRegistry":
        """Lazily discover node plugins via ``plaita.nodes`` entry points.

        Idempotent: only scans on the first call for this registry instance.
        """
        if self._discovered:
            return self
        self._discovered = True
        self._discover_entry_points()
        return self

_discovered 初值在 plaita/node/__init__.py:82:

        self._discovered = False

_discover_entry_points() 一旦抛异常,_discovered 已经是 True,此后任何 discover() 调用都在 :195-196 直接 return self,不再进入扫描。

证据 2 — entry_points() 调用不在任何 try 内

plaita/node/__init__.py:168-188(逐字,注意 :174 无保护):

    def _discover_entry_points(self) -> None:
        # entry_points(group=...) requires Python 3.10+.
        # Python 3.9 returns a dict-like SelectableGroups from entry_points().
        # Test mocks may patch entry_points to return a flat list directly.
        import sys
        if sys.version_info >= (3, 10):
            eps = entry_points(group="plaita.nodes")
        else:
            raw = entry_points()
            if hasattr(raw, "get"):
                # Python 3.9 SelectableGroups / dict API
                eps = raw.get("plaita.nodes", [])
            else:
                # Flat iterable (e.g. test mock returns [ep, ...] directly)
                eps = raw
        for ep in eps:
            try:
                node_cls = ep.load()
                self._nodes[node_cls.node_type] = node_cls
            except Exception:
                _logger.warning("Failed to load node plugin: %s", ep.name)

:187-188 的 except Exception: 没有绑定异常对象、没有 exc_info=True、也没有记录到任何可查询的属性上。

证据 3 — 失败插件的异常信息与 traceback 全部丢失(实跑)

在 /tmp/plaita-audit-venv/bin/python(Python 3.12,editable 装 plaita[dev,all] + plaita-nodes)下实跑:

$ cd /Users/jeff.jie/projects/infra4agent/plaita && /tmp/plaita-audit-venv/bin/python - <<'EOF'
import logging
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s')
from unittest.mock import patch, MagicMock
from plaita.node import NodeRegistry
reg = NodeRegistry()
ep = MagicMock(); ep.name = 'delay'
ep.load.side_effect = ModuleNotFoundError("No module named 'kafka'")
with patch('plaita.node.entry_points', return_value=[ep]):
    reg.discover()
print('hasattr _load_errors:', hasattr(reg, '_load_errors'))
try:
    reg.parse_node({'type': 'delay'})
except RuntimeError as e:
    print('parse_node ->', e)
EOF
WARNING:plaita.node:Failed to load node plugin: delay
hasattr _load_errors: False
parse_node -> unRecognized node type: delay. Available node types: ['assignment', 'case', 'child', 'end', 'event', 'filter', 'find', 'http', 'if', 'loop', 'map', 'mock', 'parallel', 'reduce', 'reference', 'start', 'switch', 'while']

日志里只有一个插件名 delay,异常文本 No module named 'kafka' 与 traceback 全部丢失;registry 上不存在任何承载加载失败的属性(_load_errors 为 False)。

证据 4 — 解析期错误文案把方向引向「节点名写错」

plaita/node/__init__.py:127-142(逐字):

        node_cls = self._nodes.get(node_type)
        if not node_cls:
            import difflib

            available = sorted(self._nodes.keys())
            close = difflib.get_close_matches(node_type, available, n=3, cutoff=0.6)
            hint = ""
            if node_type == "code":
                hint = (
                    " CodeNode was moved out of the default registry in 0.4.0; "
                    "call register_code_node() at startup to opt in."
                )
            elif close:
                hint = f" Did you mean {close!r}?"
            hint += f" Available node types: {available}"
            raise RuntimeError(f"unRecognized node type: {node_type}.{hint}")

hint 分支只覆盖 code 与 difflib 近似名,没有任何「该类型本应由插件提供、但插件加载失败」的提示。上面证据 3 的输出即为该分支的真实产物。

证据 5 — 重试入口只有显式 init_default_registry,隐式路径无 try

plaita/node/__init__.py:241-247(逐字):

    global _default_registry_explicitly_initialized
    reg = _default_registry
    reg._nodes.clear()
    reg._discovered = False
    reg._register_builtins()
    if auto_discover:
        reg.discover()

plaita/node/__init__.py:258-277(逐字,:276 无 try 包裹):

def get_default_registry() -> NodeRegistry:
    """Return the process-wide default ``NodeRegistry``.

    Plugin discovery via ``plaita.nodes`` entry points happens lazily on the
    first call, not at import time, so importing ``plaita.node`` does not pull
    in optional server/plugin dependencies.

    若未先调 ``init_default_registry``, 首次调用走"隐式 discover"并 ``debug``
    提示——建议在启动脚本显式调 ``init_default_registry()`` 一次, 把进程级
    单例状态变成显式、可复现的启动步骤。
    """
    global _default_registry_explicitly_initialized
    if not _default_registry_explicitly_initialized and not _default_registry._discovered:
        _logger.debug(
            "get_default_registry() called without explicit init_default_registry(); "
            "doing implicit plugin discovery. Call init_default_registry() at startup "
            "for an explicit, reproducible node set."
        )
    _default_registry.discover()
    return _default_registry

即:entry_points() 抛错时异常从 get_default_registry() 冒出(:276),调用方若在启动期宽 catch 吞掉,本进程内除了显式再调一次 init_default_registry() 之外没有任何重试通道。

证据 6 — 发现一次即不再重试(实跑)
$ cd /Users/jeff.jie/projects/infra4agent/plaita && /tmp/plaita-audit-venv/bin/python - <<'EOF'
from unittest.mock import patch
from plaita.node import NodeRegistry
reg = NodeRegistry()
with patch('plaita.node.entry_points', side_effect=RuntimeError('boom')) as m:
    try: reg.discover()
    except RuntimeError as e: print('first raised:', e)
    print('call_count after first:', m.call_count)
    reg.discover(); print('second returned silently, call_count:', m.call_count)
print('_discovered =', reg._discovered)
EOF
first raised: boom
call_count after first: 1
second returned silently, call_count: 1
_discovered = True

第二次 discover() 静默返回,entry_points 调用次数仍为 1,_discovered 已为 True——「失败一次,本进程永久放弃发现」。

证据 7 — 解析链路默认走 get_default_registry()

plaita/core/flow.py:158-162(逐字):

        from plaita.node import Node, get_default_registry

        if not self.nodes:
            return
        reg = registry if registry is not None else get_default_registry()
证据 8 — 被发现的入口点数量(本机实跑)
$ /tmp/plaita-audit-venv/bin/python -c "from importlib.metadata import entry_points; eps=list(entry_points(group='plaita.nodes')); print(len(eps))"
27

其中 plaita 自带 5 个(pyproject.toml:94-99),plaita-nodes 注册 22 个;pyproject.toml:94-99(逐字):

[project.entry-points."plaita.nodes"]
delay = "plaita.server.nodes.delay_node:DelayNode"
redis_queue = "plaita.server.nodes.redis_queue_node:RedisQueueNode"
kafka_queue = "plaita.server.nodes.kafka_queue_node:KafkaQueueNode"
http_callback = "plaita.server.nodes.http_callback_node:HttpCallbackNode"
approval = "plaita.server.nodes.approval_node:ApprovalNode"

这 27 个入口点全部走 :184-188 同一个 except Exception(注意 kafka_queue 等条目依赖可选三方包,缺失即命中该分支)。全仓无任何调用点检查加载失败(grep -rn "_load_errors" plaita/ 零命中,且该属性不存在)。

影响

  • 谁会踩到:任何按文档装了 plaita[all] 或 plaita 单仓 plaita(仅 core + 自带 server 节点)的部署。只要宿主环境里缺某个节点的可选依赖(例如 kafka_queue 需要 kafka 客户端、redis_queue 需要 redis 客户端),或插件包本身 import 期报错,该节点就在 registry 里静默缺席。
  • 代价:
    1. 不可观测:日志只有一行 Failed to load node plugin: delay,没有异常类型、异常文本、traceback,也没有任何可查询属性;运维看到的是「节点类型不存在」,而不是「延迟节点插件因缺 kafka 模块未加载」。
    2. 误导排查方向:resolve_nodes() 抛出的 unRecognized node type: delay. Available node types: [...] 把作者引向「节点名写错/版本升级改名」,而真实原因是环境缺依赖。
    3. 不可恢复:若失败发生在 entry_points() 调用本身(:174 不在 try 内),异常从 get_default_registry()(:276)冒出,启动期宽 catch 的调用方吞掉后,进程内 _discovered=True,不再有任何隐式重试,该进程后续所有 flow 解析都缺这 27 个插件节点;即使临时把依赖装上,也必须重启进程或显式再调 init_default_registry()。
  • 生产可达:worker / server 进程与 flow 解析链路(core/flow.py:162)默认都走 get_default_registry(),无需库嵌入者特殊操作即可命中。
  • 不修的后果:一次环境缺依赖会被当成「流程写错」长期排查,且同一进程内的后续所有解析都带着错误的节点集合运行,直到重启。

建议

  1. 保留失败现场:在 _discover_entry_points() 中把每个失败记录到实例属性(例如 self._load_errors: Dict[str, BaseException],key 为 ep.name 或 ep.value),并让日志携带异常:

    except Exception as exc:
        self._load_errors[ep.name] = exc
        _logger.warning("Failed to load node plugin: %s", ep.name, exc_info=True)
    

    最小修法就是这一处:_logger.warning(..., exc_info=True) 一行即可让 traceback 进日志。

  2. 让失败可重试:discover() 中把 self._discovered = True 移到 self._discover_entry_points() 成功返回之后;或对扫描整体加 try,失败时复位 _discovered = False 再抛。同时把 :174 的 entry_points(group=...) 调用纳入 try,使 importlib.metadata 层面的失败与插件加载失败走同一条可观测路径。

  3. 错误文案带上加载失败信息:parse_node() 的 hint 分支(:139-141)在 self._load_errors 命中 node_type 时追加「该节点插件存在但加载失败:<异常摘要>」,避免把它归因为拼写错误。

  4. 验收条件(可执行、可断言):

    • 新增单测(建议 tests/unit/test_node_registry_discovery.py):
      • mock entry_points(side_effect=RuntimeError('boom')):第一次 discover() 抛 RuntimeError,且断言 reg._discovered is False;第二次 discover() 再次调用 entry_points(断言 call_count == 2)。
      • mock ep.load(side_effect=ModuleNotFoundError("No module named 'kafka'")):discover() 不抛,且断言 reg._load_errors['delay'] 是该 ModuleNotFoundError;用 caplog 断言日志记录 exc_info 非空(或断言日志文本包含 kafka)。
      • 随后 reg.parse_node({'type': 'delay'}) 的 RuntimeError 文案包含 plugin 或加载失败原因,而非仅 Available node types。
    • 复现脚本(本文证据 3 / 证据 6)在修复后应变为:证据 6 的 call_count after second 为 2;证据 3 的日志包含 No module named 'kafka',且 hasattr(reg, '_load_errors') 为 True。

边界

  • 本条只处理「插件发现失败不可观测 / 不可重试 / 报错误导」这一处;不顺手重构 entry point 注册顺序、register() 的覆盖告警、内置节点集合,也不改动 init_default_registry() 的显式初始化语义。
  • 与 #46(扩展服务启动器缺 logging 配置)不同根:#46 是服务进程根本没配 logging 导致全域静默;本条是 logging 正常、但发现阶段的异常对象与 traceback 被 except Exception: 丢弃,且失败后状态位不可回退。两者修复点不同,不构成重复。
  • 本次查重(gh issue list -R jeffkit/plaita --state all --limit 100,33 条)未发现与本条同根的既有单。
  • 本条为生产可达缺陷,当前触发条件是「宿主环境缺某个节点插件依赖,或插件 import 期失败」,无需额外前置说明。
Dominant language
Python
Stars
0
Forks
1
Avg merge
2h 33m
Merged PRs (30d)
9

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from jeffkit/plaita

All issues in jeffkit/plaita

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.