Files
MoviePilot/tests/test_telegram_rich_message_fallback.py
jxxghp cc2e02793e fix(telegram): wait for final streaming edit interval
Wait once for the remaining message edit interval during Agent finalization, without bypassing Telegram 429 cooldown or replaying confirmed text.

Refs #7124
2026-10-22 08:25:25 +08:00

430 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Telegram Rich Message 失败降级与重试策略测试。
"""
from unittest.mock import MagicMock, patch
import pytest
from app.modules.telegram.botapi import TelegramApiError, TelegramNetworkError
from app.modules.telegram.telegram import (
TELEGRAM_EDIT_MIN_INTERVAL_SECONDS,
NonRetryableTelegramError,
RetryException,
Telegram,
_is_non_retryable_api_error,
)
def _make_client() -> Telegram:
"""
构造一个不启动轮询线程的 Telegram 客户端。
"""
with patch("app.modules.telegram.telegram.TelegramBotApi"), patch(
"app.modules.telegram.telegram.threading.Thread"
):
client = Telegram(TELEGRAM_TOKEN="token", TELEGRAM_CHAT_ID="chat")
client._bot = MagicMock()
return client
class TestNonRetryableApiError:
"""
400 属于确定性拒绝,重试无意义。
"""
def test_400_is_non_retryable(self):
"""
明确的 400 拒绝无需退避重试。
"""
err = TelegramApiError("sendRichMessage", "Bad Request: ...", 400)
assert _is_non_retryable_api_error(err) is True
def test_500_is_retryable(self):
"""
服务端临时失败保留重试能力。
"""
err = TelegramApiError("sendRichMessage", "Internal Server Error", 500)
assert _is_non_retryable_api_error(err) is False
def test_network_error_is_retryable(self):
"""
网络临时异常保留重试能力。
"""
assert _is_non_retryable_api_error(TelegramNetworkError("m", "timeout")) is False
def test_429_is_non_retryable_until_cooldown(self):
"""
429 不能在通用重试器内立即重放。
"""
err = TelegramApiError(
"editMessageText", "Too Many Requests: retry after 17", 429,
{"retry_after": 17},
)
assert _is_non_retryable_api_error(err) is True
def test_unrelated_error_is_retryable(self):
"""
非 API 异常不被误判为确定性拒绝。
"""
assert _is_non_retryable_api_error(ValueError("boom")) is False
class TestSendRichMessageRetry:
"""
Rich Message 发送失败时的重试与异常类型。
"""
def test_400_skips_backoff_and_raises_immediately(self):
"""
400 不应进入 retry 的退避等待。
"""
client = _make_client()
client._api.send_rich_message.side_effect = TelegramApiError(
"sendRichMessage", "Bad Request: can't parse entities", 400
)
with patch("time.sleep") as sleep_mock:
with pytest.raises(NonRetryableTelegramError):
client._Telegram__send_rich_message(
chat_id="chat", rich_message="# 结果"
)
sleep_mock.assert_not_called()
assert client._api.send_rich_message.call_count == 1
def test_500_retries(self):
"""
5xx 仍按原逻辑重试。
"""
client = _make_client()
client._api.send_rich_message.side_effect = TelegramApiError(
"sendRichMessage", "Internal Server Error", 500
)
with patch("time.sleep"), pytest.raises(RetryException):
client._Telegram__send_rich_message(
chat_id="chat", rich_message="# 结果"
)
assert client._api.send_rich_message.call_count == 3
def test_error_message_keeps_telegram_description(self):
"""
异常文本必须保留 Telegram 原始描述,否则无法定位失败原因。
"""
client = _make_client()
client._api.send_rich_message.side_effect = TelegramApiError(
"sendRichMessage", "Bad Request: can't parse entities", 400
)
with patch("time.sleep"), pytest.raises(NonRetryableTelegramError) as excinfo:
client._Telegram__send_rich_message(
chat_id="chat", rich_message="# 结果"
)
message = str(excinfo.value)
assert "can't parse entities" in message
assert "400" in message
class TestSendMsgFallsBackToPlainText:
"""
Rich Message 发送失败时降级为纯文本,通知不丢失。
"""
def test_falls_back_when_rich_message_fails(self):
"""
富文本发送失败后降级为纯文本,成功结果保留降级消息标识。
"""
client = _make_client()
client._api.send_rich_message.side_effect = TelegramApiError(
"sendRichMessage", "Bad Request", 400
)
client._api.send_message.return_value = {"message_id": 99, "chat": {"id": "chat"}}
with patch("time.sleep"):
result = client.send_msg(
title="结果", text="正文", rich_message="# 结果"
)
assert result["success"] is True
assert result["message_id"] == 99
# parse_mode 必须显式为空字符串:None 会回落到客户端默认的 MarkdownV2,
# 那正是 Rich Message 已经失败过的解析路径。
assert client._api.send_message.call_args.kwargs["text"] == "# 结果"
assert client._api.send_message.call_args.kwargs["parse_mode"] == ""
def test_no_fallback_when_rich_message_succeeds(self):
"""
富文本发送成功时不重复发送纯文本消息。
"""
client = _make_client()
client._api.send_rich_message.return_value = {
"message_id": 1, "chat": {"id": "chat"}
}
result = client.send_msg(title="结果", text="正文", rich_message="# 结果")
assert result["success"] is True
client._api.send_message.assert_not_called()
def test_reports_failure_when_both_paths_fail(self):
"""
富文本和纯文本发送均失败时,向调用方返回失败。
"""
client = _make_client()
client._api.send_rich_message.side_effect = TelegramApiError(
"sendRichMessage", "Bad Request", 400
)
client._api.send_message.side_effect = TelegramApiError(
"sendMessage", "Bad Request", 400
)
with patch("time.sleep"):
result = client.send_msg(title="结果", text="正文", rich_message="# 结果")
assert result["success"] is False
def test_send_429_skips_fallback_and_retry(self):
"""
Rich 发送收到 429 时应立即返回失败,不再重试或发送纯文本。
"""
client = _make_client()
client._api.send_rich_message.side_effect = TelegramApiError(
"sendRichMessage", "Too Many Requests: retry after 17", 429,
{"retry_after": 17},
)
with patch("time.sleep") as sleep_mock:
result = client.send_msg(title="结果", text="正文", rich_message="# 结果")
assert result["success"] is False
client._api.send_rich_message.assert_called_once()
client._api.send_message.assert_not_called()
sleep_mock.assert_not_called()
def test_plain_send_429_skips_retry(self):
"""
普通发送收到 429 时也应隔离输出失败,不能阻塞 Agent。
"""
client = _make_client()
client._api.send_message.side_effect = TelegramApiError(
"sendMessage", "Too Many Requests: retry after 17", 429,
{"retry_after": 17},
)
with patch("time.sleep") as sleep_mock:
result = client.send_msg(title="结果", text="正文")
assert result["success"] is False
client._api.send_message.assert_called_once()
sleep_mock.assert_not_called()
class TestEditMsgFallsBackToPlainText:
"""
编辑路径同样需要降级,避免回复停留在旧内容。
"""
def test_falls_back_when_rich_edit_fails(self):
"""
富文本编辑失败后以纯文本再次编辑原消息。
"""
client = _make_client()
client._api.edit_message_text.side_effect = [
TelegramApiError("editMessageText", "Bad Request", 400),
{"message_id": 5, "chat": {"id": "chat"}},
]
result = client.edit_msg(
chat_id="chat", message_id=5, text="正文", rich_message="# 结果"
)
assert result is True
assert client._api.edit_message_text.call_count == 2
# 第二次调用以纯文本覆盖原消息
assert client._api.edit_message_text.call_args.kwargs["text"] == "# 结果"
def test_no_fallback_when_rich_edit_succeeds(self):
"""
富文本编辑成功时不触发额外的纯文本编辑。
"""
client = _make_client()
client._api.edit_message_text.return_value = {
"message_id": 5, "chat": {"id": "chat"}
}
result = client.edit_msg(
chat_id="chat", message_id=5, text="正文", rich_message="# 结果"
)
assert result is True
assert client._api.edit_message_text.call_count == 1
def test_429_does_not_try_plain_text_fallback(self):
"""
Rich 编辑收到 429 时不能再用纯文本编辑放大限流。
"""
client = _make_client()
client._api.edit_message_text.side_effect = TelegramApiError(
"editMessageText", "Too Many Requests: retry after 17", 429,
{"retry_after": 17},
)
result = client.edit_msg(
chat_id="chat", message_id=5, text="正文", rich_message="# 结果"
)
assert result is False
client._api.edit_message_text.assert_called_once()
@pytest.mark.parametrize("wait_for_interval", [False, True])
def test_429_cooldown_skips_follow_up_edits(self, monkeypatch, wait_for_interval):
"""
同一聊天的冷却期内后续编辑不应再触碰 Bot API。
"""
client = _make_client()
now = [100.0]
monkeypatch.setattr("app.modules.telegram.telegram.time.monotonic", lambda: now[0])
client._api.edit_message_text.side_effect = TelegramApiError(
"editMessageText", "Too Many Requests: retry after 17", 429,
{"retry_after": 17},
)
assert client.edit_msg(chat_id="chat", message_id=5, text="正文", rich_message="# 结果") is False
now[0] += 10
with patch("app.modules.telegram.telegram.time.sleep") as sleep_mock:
assert client.edit_msg(
chat_id="chat", message_id=5, text="更新", rich_message="# 更新",
wait_for_interval=wait_for_interval,
) is False
sleep_mock.assert_not_called()
assert client._api.edit_message_text.call_count == 1
now[0] += 8
client._api.edit_message_text.side_effect = None
client._api.edit_message_text.return_value = {"message_id": 5}
assert client.edit_msg(chat_id="chat", message_id=5, text="更新", rich_message="# 更新") is True
assert client._api.edit_message_text.call_count == 2
@pytest.mark.parametrize("interruption", ["429", "edit"])
def test_final_edit_rechecks_cooldown_and_interval_after_one_wait(self, monkeypatch, interruption):
"""
等待期间出现聊天冷却或同消息并发编辑时,不追加请求或无限延长等待。
"""
client = _make_client()
now = [100.0]
waits = []
monkeypatch.setattr("app.modules.telegram.telegram.time.monotonic", lambda: now[0])
client._api.edit_message_text.return_value = {"message_id": 5}
assert client.edit_msg(chat_id="chat", message_id=5, text="首帧", rich_message="# 首帧") is True
def interrupt_wait(seconds):
"""
在虚拟等待结束时模拟其他请求带来的状态变化。
:param seconds: 本次等待秒数
"""
waits.append(seconds)
now[0] += seconds
if interruption == "429":
client._mark_rate_limit(
"chat", TelegramApiError("editMessageText", "Too Many Requests", 429, {"retry_after": 17}),
)
else:
assert client.edit_msg(chat_id="chat", message_id=5, text="并发编辑") is True
monkeypatch.setattr("app.modules.telegram.telegram.time.sleep", interrupt_wait)
assert client.edit_msg(
chat_id="chat", message_id=5, text="完整正文", rich_message="# 完整正文",
wait_for_interval=True,
) is False
assert waits == [TELEGRAM_EDIT_MIN_INTERVAL_SECONDS]
assert client._api.edit_message_text.call_count == (1 if interruption == "429" else 2)
def test_successful_edits_are_throttled_and_latest_text_wins(self, monkeypatch):
"""
最小编辑间隔内只保留最新一次刷新,解除后发送当前文本。
"""
client = _make_client()
now = [100.0]
monkeypatch.setattr("app.modules.telegram.telegram.time.monotonic", lambda: now[0])
client._api.edit_message_text.return_value = {"message_id": 5}
assert client.edit_msg(chat_id="chat", message_id=5, text="首帧", rich_message="# 首帧") is True
now[0] += TELEGRAM_EDIT_MIN_INTERVAL_SECONDS / 2
assert client.edit_msg(chat_id="chat", message_id=5, text="最新", rich_message="# 最新") is False
assert client._api.edit_message_text.call_count == 1
now[0] += TELEGRAM_EDIT_MIN_INTERVAL_SECONDS
assert client.edit_msg(chat_id="chat", message_id=5, text="最新", rich_message="# 最新") is True
assert client._api.edit_message_text.call_count == 2
def test_final_edit_waits_for_message_interval(self, monkeypatch):
"""
收尾编辑应等待消息级间隔,确保完整正文覆盖原消息。
"""
client = _make_client()
now = [100.0]
monkeypatch.setattr("app.modules.telegram.telegram.time.monotonic", lambda: now[0])
def advance(seconds):
"""
用虚拟时钟推进消息级编辑间隔。
:param seconds: 等待秒数
"""
now[0] += seconds
monkeypatch.setattr("app.modules.telegram.telegram.time.sleep", advance)
client._api.edit_message_text.return_value = {"message_id": 5}
assert client.edit_msg(chat_id="chat", message_id=5, text="首帧", rich_message="# 首帧") is True
assert client.edit_msg(
chat_id="chat", message_id=5, text="完整正文", rich_message="# 完整正文",
wait_for_interval=True,
) is True
assert client._api.edit_message_text.call_count == 2
assert now[0] == 100.0 + TELEGRAM_EDIT_MIN_INTERVAL_SECONDS
assert "完整正文" in client._api.edit_message_text.call_args.kwargs["rich_message"]["html"]
class TestFallbackDisablesMarkdownV2:
"""
回归防护:降级路径不得回落到客户端默认的 MarkdownV2。
``TelegramBotApi._parse_mode`` 在参数为 ``None`` 时使用 ``_default_parse_mode``,
而该值初始化为 MarkdownV2。若降级调用漏传 parse_mode,纯文本会被再次按
MarkdownV2 解析,重演 Rich Message 已经失败过的报错,降级等于无效。
"""
def test_send_fallback_passes_empty_parse_mode(self):
"""
降级发送显式清空解析模式,避免正文再次被 MarkdownV2 拒绝。
"""
client = _make_client()
client._api.send_rich_message.side_effect = TelegramApiError(
"sendRichMessage", "Bad Request", 400
)
client._api.send_message.return_value = {"message_id": 1, "chat": {"id": "chat"}}
with patch("time.sleep"):
client.send_msg(title="结果", text="正文", rich_message="# 结果")
kwargs = client._api.send_message.call_args.kwargs
assert kwargs["parse_mode"] == "", "降级发送必须显式禁用 MarkdownV2"
def test_edit_fallback_passes_empty_parse_mode(self):
"""
降级编辑显式清空解析模式,避免重复触发富文本格式错误。
"""
client = _make_client()
client._api.edit_message_text.side_effect = [
TelegramApiError("editMessageText", "Bad Request", 400),
{"message_id": 5, "chat": {"id": "chat"}},
]
client.edit_msg(chat_id="chat", message_id=5, text="正文", rich_message="# 结果")
kwargs = client._api.edit_message_text.call_args.kwargs
assert kwargs["parse_mode"] == "", "降级编辑必须显式禁用 MarkdownV2"