Files
MoviePilot/tests/test_telegram_rich_message_fallback.py
@huluohu 76a4e33e44 fix(telegram): Rich Message 失败时保留原始错误并降级纯文本 (#6993)
* fix(telegram): Rich Message 失败时保留原始错误并降级纯文本

Telegram Rich Message 发送失败时存在三个叠加问题:

1. `__send_rich_message` 用固定文本 `RetryException("发送 Telegram Rich
   Message 失败")` 包装异常,`str(e)` 里没有 Telegram 返回的 description,
   日志只剩“发送失败”四个字,无法判断是内容非法、方法不存在还是格式问题。

2. 内容非法这类确定性失败(400)同样走满3 次退避重试(3s、6s),
   期间同一条坏消息反复阻塞,且最终报错仍无有效信息。

3. 失败即丢失通知:Rich Message 发不出去时没有任何降级路径,
   整条通知直接消失。

改动:

- 异常文本改为 f"...失败:{err}",保留 Telegram 原始 description 与错误码。
- 新增 `NonRetryableTelegramError`(继承 `ImmediateException`)与
  `_is_non_retryable_api_error`,400 立即抛出以跳过 retry 退避;
  5xx 与网络异常保持原有重试策略。
- `send_msg` / `edit_msg` 增加纯文本降级出口,Rich Message 失败时
  用不带格式解析的纯文本重发,保证通知内容不丢失。
- 降级路径显式传 `parse_mode=""`:`TelegramBotApi._parse_mode` 在参数为
  `None` 时会回落到客户端默认的 MarkdownV2,那正是 Rich Message 已经
  失败过的解析路径,漏传会让降级重演同一个错误。

新增 tests/test_telegram_rich_message_fallback.py(14 项)覆盖错误信息保留、
400 免重试、5xx 仍重试、发送与编辑两条降级路径,以及 parse_mode 回落陷阱的
回归防护。

* fix(telegram): 修正 Rich Message 降级路径的 mypy 与架构基线

上一提交引入的两处门禁失败:

1. `__send_plain_fallback` 用 `**self._topic_kwargs(chat_id)` 展开参数,mypy
   把 `**` 匹配到 `send_message` 第 5 个形参 `disable_web_page_preview`
   (Optional[bool]),判定 `dict[str, int]` 不兼容,arg-type 由 1 增至 2。
   改为显式传 `message_thread_id`,`_topic_kwargs` 只可能产出该键。

2. 新增 `app.schemas.exception` 导入带来 2 条依赖边,宿主基线由 9542 变为
   9544。已用 `baseline.py --write-host` 固化,其余 3 个基线文件字节不变。

   连带影响:docs/architecture-overview.md 与 docs/refactor/
   optimization-checklist.md 硬编码了边数,`test_architecture_documents_
   match_generated_quality_metrics` 断言两处必须与基线一致,同步为 9,544。

门禁与测试验证:

- scripts/architecture/mypy_ratchet.py          exit 0(9163 个类型错误)
- scripts/architecture/baseline.py --check-host exit 0
- pytest(fallback + contract + telegram)      75 passed

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-15 07:32:16 +08:00

211 lines
7.9 KiB
Python
Raw 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 (
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_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
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
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):
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"