feat: Enhance error handling and caching for Dify and WeCom interactions

This commit is contained in:
fdc310
2026-07-11 22:40:08 +08:00
parent 5eb2b90ae6
commit 4b6931d294
8 changed files with 202 additions and 31 deletions
@@ -53,6 +53,26 @@ def test_telegram_invalid_select_callback_is_rejected():
assert _telegram_form_action_from_callback({'f': 1, 'x': 'invalid'}) is None
def test_telegram_form_callback_cache_consumes_the_whole_form_group():
adapter = TelegramAdapter.model_construct()
adapter._form_action_titles = {}
adapter._cache_form_action_titles({'callback-a': 'A', 'callback-b': 'B'}, now=100.0)
assert adapter._take_form_action_title('callback-a', now=101.0) == 'A'
assert adapter._take_form_action_title('callback-a', now=101.0) is None
assert adapter._take_form_action_title('callback-b', now=101.0) is None
assert adapter._form_action_titles == {}
def test_telegram_form_callback_cache_prunes_expired_entries():
adapter = TelegramAdapter.model_construct()
adapter._form_action_titles = {}
adapter._cache_form_action_titles({'callback-a': 'A'}, now=100.0)
assert adapter._take_form_action_title('callback-a', now=100.0 + adapter._FORM_ACTION_CACHE_TTL) is None
assert adapter._form_action_titles == {}
@pytest.mark.asyncio
async def test_telegram_select_field_sends_two_column_inline_keyboard():
bot = MagicMock()
@@ -9,6 +9,7 @@ logger_module.EventLogger = object
sys.modules.setdefault('langbot.pkg.platform.logger', logger_module)
from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
WecomBotClient,
build_button_interaction_payload,
build_human_input_template_card_payload,
build_button_interaction_update_card,
@@ -254,7 +255,7 @@ async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
@pytest.mark.asyncio
async def test_ws_stream_sends_delta_chunks_to_wecom():
async def test_ws_stream_sends_cumulative_snapshots_to_wecom():
client = WecomBotWsClient('bot-id', 'secret', object())
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {}
@@ -272,8 +273,29 @@ async def test_ws_stream_sends_delta_chunks_to_wecom():
assert sent == [
('req-1', 'stream-1', '', False),
('req-1', 'stream-1', '', False),
('req-1', 'stream-1', '', True),
('req-1', 'stream-1', '', False),
('req-1', 'stream-1', '你好', True),
]
@pytest.mark.asyncio
async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
client = WecomBotClient('', '', '', object(), unified_mode=True)
session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-1', 'chatid': '', 'from': {'userid': 'user-1'}})
assert await client.push_stream_chunk('msg-1', '', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
chunks = [
await client.stream_sessions.consume(session.stream_id),
await client.stream_sessions.consume(session.stream_id),
await client.stream_sessions.consume(session.stream_id),
]
assert [(chunk.content, chunk.is_final) for chunk in chunks] == [
('', False),
('你好', False),
('你好', True),
]
@@ -11,6 +11,62 @@ from unittest.mock import AsyncMock, MagicMock
import langbot_plugin.api.entities.builtin.platform.message as platform_message
class TestDifyWorkflowSubmitClient:
@pytest.mark.asyncio
async def test_rejects_empty_error_response_before_iterating_sse(self, monkeypatch):
from langbot.libs.dify_service_api.v1 import client, errors
class FakeResponse:
status_code = 503
async def aread(self):
return b''
async def aiter_lines(self):
raise AssertionError('error responses must not enter the SSE loop')
yield
class FakeStreamContext:
async def __aenter__(self):
return FakeResponse()
async def __aexit__(self, exc_type, exc, traceback):
return False
class FakeClient:
def __init__(self, **kwargs):
del kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
async def post(self, *args, **kwargs):
del args, kwargs
response = MagicMock()
response.status_code = 200
return response
def stream(self, *args, **kwargs):
del args, kwargs
return FakeStreamContext()
monkeypatch.setattr(client.httpx, 'AsyncClient', FakeClient)
dify_client = client.AsyncDifyServiceClient('test-key', 'https://dify.example/v1')
with pytest.raises(errors.DifyAPIError, match='503'):
await anext(
dify_client.workflow_submit(
form_token='token-1',
workflow_run_id='run-1',
inputs={},
user='person_user-1',
)
)
class TestDifyExtractTextOutput:
"""Tests for _extract_dify_text_output method."""
@@ -834,7 +890,7 @@ class TestDifyHumanInputForms:
assert difysvapi._get_pending_form_by_token(session_key, 'token-1') is None
@pytest.mark.asyncio
async def test_failed_blocking_resume_keeps_pending_form_for_retry(self):
async def test_incomplete_blocking_resume_keeps_pending_form_for_retry(self):
from langbot.libs.dify_service_api.v1.errors import DifyAPIError
from langbot.pkg.provider.runners import difysvapi
@@ -843,8 +899,7 @@ class TestDifyHumanInputForms:
async def workflow_submit(**kwargs):
del kwargs
raise DifyAPIError('temporary failure')
yield
yield {'event': 'message', 'answer': 'partial answer'}
runner.dify_client.workflow_submit = workflow_submit
query = MagicMock()
@@ -873,12 +928,57 @@ class TestDifyHumanInputForms:
}
}
with pytest.raises(DifyAPIError, match='temporary failure'):
with pytest.raises(DifyAPIError, match='before a terminal event'):
_ = [message async for message in runner._workflow_messages(query)]
assert difysvapi._get_pending_form_by_token(session_key, 'token-1') is not None
difysvapi._PENDING_FORMS.clear()
@pytest.mark.asyncio
async def test_incomplete_streaming_resume_keeps_pending_form_for_retry(self):
from langbot.libs.dify_service_api.v1.errors import DifyAPIError
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
query = MagicMock()
query.session.launcher_type.value = 'person'
query.session.launcher_id = 'user-1'
query.bot_uuid = 'bot-1'
query.pipeline_uuid = 'pipeline-1'
query.variables = {
'_dify_form_action': {
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'action_id': 'yes',
'user': 'person_user-1',
'inputs': {},
}
}
session_key = difysvapi._session_key_from_query(query)
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'yes', 'title': 'Yes'}],
'inputs': {},
'user': 'person_user-1',
},
)
async def workflow_submit(**kwargs):
del kwargs
yield {'event': 'text_chunk', 'data': {'text': 'partial answer'}}
runner.dify_client.workflow_submit = workflow_submit
with pytest.raises(DifyAPIError, match='before a terminal event'):
_ = [message async for message in runner._workflow_messages_chunk(query)]
assert difysvapi._get_pending_form_by_token(session_key, 'token-1') is not None
difysvapi._PENDING_FORMS.clear()
class TestDifyCumulativeStreaming:
def _create_runner(self, *, remove_think: bool = False):