fix(feishu): defer the lark_oapi import off the startup path

Salvage of #57657, ported onto the plugin layout (the adapter moved
from gateway/platforms/feishu.py to plugins/platforms/feishu/adapter.py
since the PR's base). lark_oapi takes seconds to import and holds the
GIL doing it; the module-level import made every gateway boot pay that
cost even with Feishu unconfigured.

- _load_lark_oapi() with double-checked locking binds the SDK globals
  on first use; connect() and _standalone_send() call it via
  asyncio.to_thread so the loop never blocks on the import.
- probe_bot() also calls _load_lark_oapi() (sync context) so the SDK
  probe path is preserved rather than silently degrading to the HTTP
  fallback before a first connect.
- check_feishu_requirements() is install-only and no longer rebinds
  globals; test_feishu.py gets a setUpModule that binds them eagerly
  for tests that inject fake clients.

Includes the dedicated lazy-import test file (check-does-not-import,
connect-loads-on-worker-thread).
This commit is contained in:
baau 2026-07-17 16:35:52 +08:00 committed by kshitij
parent 0422479031
commit b51c4e6a78
3 changed files with 148 additions and 74 deletions

View File

@ -85,45 +85,35 @@ try:
except ImportError:
websockets = None # type: ignore[assignment]
try:
import lark_oapi as lark
from lark_oapi.api.application.v6 import GetApplicationRequest
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
GetChatRequest,
GetMessageRequest,
GetMessageResourceRequest,
P2ImMessageMessageReadV1,
ReplyMessageRequest,
ReplyMessageRequestBody,
UpdateMessageRequest,
UpdateMessageRequestBody,
)
from lark_oapi.core import AccessTokenType, HttpMethod
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
from lark_oapi.core.model import BaseRequest
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackCard,
P2CardActionTriggerResponse,
)
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
from lark_oapi.ws import Client as FeishuWSClient
FEISHU_AVAILABLE = True
except ImportError:
FEISHU_AVAILABLE = False
lark = None # type: ignore[assignment]
CallBackCard = None # type: ignore[assignment]
P2CardActionTriggerResponse = None # type: ignore[assignment]
EventDispatcherHandler = None # type: ignore[assignment]
FeishuWSClient = None # type: ignore[assignment]
FEISHU_DOMAIN = None # type: ignore[assignment]
LARK_DOMAIN = None # type: ignore[assignment]
# lark_oapi takes a noticeable amount of time to import. Keep the gateway
# configuration path responsive by importing it only when Feishu connects.
lark = None # type: ignore[assignment]
GetApplicationRequest = None # type: ignore[assignment]
CreateFileRequest = None # type: ignore[assignment]
CreateFileRequestBody = None # type: ignore[assignment]
CreateImageRequest = None # type: ignore[assignment]
CreateImageRequestBody = None # type: ignore[assignment]
CreateMessageRequest = None # type: ignore[assignment]
CreateMessageRequestBody = None # type: ignore[assignment]
GetChatRequest = None # type: ignore[assignment]
GetMessageRequest = None # type: ignore[assignment]
GetMessageResourceRequest = None # type: ignore[assignment]
P2ImMessageMessageReadV1 = None # type: ignore[assignment]
ReplyMessageRequest = None # type: ignore[assignment]
ReplyMessageRequestBody = None # type: ignore[assignment]
UpdateMessageRequest = None # type: ignore[assignment]
UpdateMessageRequestBody = None # type: ignore[assignment]
AccessTokenType = None # type: ignore[assignment]
HttpMethod = None # type: ignore[assignment]
FEISHU_DOMAIN = None # type: ignore[assignment]
LARK_DOMAIN = None # type: ignore[assignment]
BaseRequest = None # type: ignore[assignment]
CallBackCard = None # type: ignore[assignment]
P2CardActionTriggerResponse = None # type: ignore[assignment]
EventDispatcherHandler = None # type: ignore[assignment]
FeishuWSClient = None # type: ignore[assignment]
FEISHU_AVAILABLE = False
_lark_import_lock = threading.Lock()
FEISHU_WEBSOCKET_AVAILABLE = websockets is not None
FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None
@ -1395,36 +1385,38 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
adapter._ws_thread_loop = None
def check_feishu_requirements() -> bool:
"""Check if Feishu/Lark dependencies are available.
Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")``
on first call if not present. Rebinds all module-level globals on success.
"""
def _load_lark_oapi() -> bool:
"""Import and bind the Feishu SDK after an explicit connection request."""
if FEISHU_AVAILABLE:
return True
def _import():
import lark_oapi as lark
from lark_oapi.api.application.v6 import GetApplicationRequest
from lark_oapi.api.im.v1 import (
CreateFileRequest, CreateFileRequestBody,
CreateImageRequest, CreateImageRequestBody,
CreateMessageRequest, CreateMessageRequestBody,
GetChatRequest, GetMessageRequest, GetMessageResourceRequest,
P2ImMessageMessageReadV1,
ReplyMessageRequest, ReplyMessageRequestBody,
UpdateMessageRequest, UpdateMessageRequestBody,
)
from lark_oapi.core import AccessTokenType, HttpMethod
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
from lark_oapi.core.model import BaseRequest
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackCard, P2CardActionTriggerResponse,
)
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
from lark_oapi.ws import Client as FeishuWSClient
return {
with _lark_import_lock:
if FEISHU_AVAILABLE:
return True
try:
import lark_oapi as lark
from lark_oapi.api.application.v6 import GetApplicationRequest
from lark_oapi.api.im.v1 import (
CreateFileRequest, CreateFileRequestBody,
CreateImageRequest, CreateImageRequestBody,
CreateMessageRequest, CreateMessageRequestBody,
GetChatRequest, GetMessageRequest, GetMessageResourceRequest,
P2ImMessageMessageReadV1,
ReplyMessageRequest, ReplyMessageRequestBody,
UpdateMessageRequest, UpdateMessageRequestBody,
)
from lark_oapi.core import AccessTokenType, HttpMethod
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
from lark_oapi.core.model import BaseRequest
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackCard, P2CardActionTriggerResponse,
)
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
from lark_oapi.ws import Client as FeishuWSClient
except ImportError:
return False
globals().update({
"lark": lark,
"GetApplicationRequest": GetApplicationRequest,
"CreateFileRequest": CreateFileRequest,
@ -1451,10 +1443,22 @@ def check_feishu_requirements() -> bool:
"EventDispatcherHandler": EventDispatcherHandler,
"FeishuWSClient": FeishuWSClient,
"FEISHU_AVAILABLE": True,
}
})
return True
from tools.lazy_deps import ensure_and_bind
return ensure_and_bind("platform.feishu", _import, globals(), prompt=False)
def check_feishu_requirements() -> bool:
"""Ensure Feishu dependencies are installed without importing the SDK."""
if FEISHU_AVAILABLE:
return True
from tools.lazy_deps import ensure
try:
ensure("platform.feishu", prompt=False)
return True
except Exception:
return False
class FeishuAdapter(BasePlatformAdapter):
@ -1752,9 +1756,6 @@ class FeishuAdapter(BasePlatformAdapter):
# A fresh connect (or reconnect) re-arms the SDK executor after a prior
# disconnect set the closing flag.
self._sdk_executor_closing = False
if not FEISHU_AVAILABLE:
logger.error("[Feishu] lark-oapi not installed")
return False
if not self._app_id or not self._app_secret:
logger.error("[Feishu] FEISHU_APP_ID or FEISHU_APP_SECRET not set")
return False
@ -1769,6 +1770,9 @@ class FeishuAdapter(BasePlatformAdapter):
"[Feishu] Webhook mode requires FEISHU_VERIFICATION_TOKEN or FEISHU_ENCRYPT_KEY."
)
return False
if not await asyncio.to_thread(_load_lark_oapi):
logger.error("[Feishu] lark-oapi not installed")
return False
try:
self._app_lock_identity = self._app_id
@ -5410,7 +5414,10 @@ def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
Note: ``bot_open_id`` here is the bot's app-scoped open_id — the same ID
that Feishu puts in @mention payloads. It is NOT the app_id.
"""
if FEISHU_AVAILABLE:
# The SDK import is deferred until connect(); onboarding runs before any
# connect, so load it here to keep the SDK probe path reachable rather
# than silently degrading every setup run to the HTTP fallback.
if _load_lark_oapi():
return _probe_bot_sdk(app_id, app_secret, domain)
return _probe_bot_http(app_id, app_secret, domain)
@ -5598,7 +5605,7 @@ async def _standalone_send(
FeishuAdapter, hydrates its lark client, and sends text + native media
(images, video, voice, documents). Replaces the legacy _send_feishu helper.
"""
if not FEISHU_AVAILABLE:
if not await asyncio.to_thread(_load_lark_oapi):
return {"error": "Feishu dependencies not installed. Run `hermes setup` to install Feishu support."}
media_files = media_files or []

View File

@ -22,6 +22,18 @@ except ImportError:
_HAS_LARK_OAPI = False
def setUpModule():
"""Bind the lark SDK globals before tests that inject a mock ``_client``.
The adapter defers the lark import to ``connect()`` (``_load_lark_oapi``),
so tests that skip connect() and set ``adapter._client`` directly would
otherwise see the request-builder globals still bound to None.
"""
if _HAS_LARK_OAPI:
from plugins.platforms.feishu.adapter import _load_lark_oapi
_load_lark_oapi()
class _FakeRequestContent:
def __init__(self, body: bytes):
self.body = body

View File

@ -0,0 +1,55 @@
"""Regression coverage for deferred Feishu SDK loading."""
import asyncio
import os
import tempfile
from unittest.mock import AsyncMock, patch
def _feishu_adapter_module():
"""Import the adapter with the Windows app-data root available in CI."""
with patch.dict(os.environ, {"LOCALAPPDATA": tempfile.gettempdir()}):
from plugins.platforms.feishu import adapter
return adapter
def test_configured_feishu_dependency_check_does_not_load_sdk():
"""Gateway configuration can validate Feishu without importing its SDK."""
feishu_adapter = _feishu_adapter_module()
with (
patch.object(feishu_adapter, "FEISHU_AVAILABLE", False),
patch("tools.lazy_deps.ensure", autospec=True) as ensure,
):
assert feishu_adapter.check_feishu_requirements() is True
assert feishu_adapter.FEISHU_AVAILABLE is False
ensure.assert_called_once_with("platform.feishu", prompt=False)
def test_feishu_connect_loads_sdk_on_worker_thread():
"""The first SDK import is deferred until a configured adapter connects."""
from gateway.config import PlatformConfig
feishu_adapter = _feishu_adapter_module()
adapter = feishu_adapter.FeishuAdapter(
PlatformConfig(
extra={
"app_id": "cli_test",
"app_secret": "secret_test",
"connection_mode": "websocket",
}
)
)
with (
patch.object(feishu_adapter, "FEISHU_AVAILABLE", False),
patch.object(feishu_adapter, "_load_lark_oapi", return_value=True) as load_sdk,
patch.object(feishu_adapter.asyncio, "to_thread", new_callable=AsyncMock, return_value=True) as to_thread,
patch.object(adapter, "_connect_with_retry", new_callable=AsyncMock),
patch.object(feishu_adapter, "acquire_scoped_lock", return_value=(True, {})),
):
assert asyncio.run(adapter.connect()) is True
to_thread.assert_awaited_once_with(load_sdk)