fix(retry): 降低重试重复并强化 OneBot 假超时拦截

- `onebot_adapter.py#L547-L609` 的路径发送失败逻辑改为:遇到 `timeout/1200/网络错误` 不再立即 Base64 回退,而是继续抛错,由上层进入 10+20+30 秒的多轮观察;每轮都通过 `was_image_sent_recently` 确认后台是否真的已发成功,确认后立刻拦截重试,避免 “假超时” 导致连发。
- `retry.py#L20-L210` 中去重注释已全部换成中文,并补全了 `_cleanup_expired_task_keys`、`_build_task_key`、`_mark_task_finished` 等说明,说明当前去重键由 caption TraceToken 或 html hash 构成,维护两个集合 `_active_task_keys`/`_recent_task_key_ts` 防止同一报告短时间再次入队,构造 task_key 后再 enqueue 并在完成后刷新冷却;`max_retries` 也降到 2 次,并且同群任务会 `sleep(5)` 重新排队,链路上最多留存一份 retry 任务。
This commit is contained in:
SXP-Simon
2026-03-28 22:33:29 +08:00
parent a3d72e43ab
commit d3eb9b51f5
2 changed files with 99 additions and 21 deletions
@@ -547,6 +547,17 @@ class OneBotAdapter(PlatformAdapter):
if image_path.startswith(("http://", "https://", "base64://")):
raise e
error_str = str(e).lower()
is_potential_success = (
"timeout" in error_str
or "1200" in error_str
or "网络错误" in error_str
)
# 关键修复:对疑似成功的超时错误,不做“立即 Base64 补发”。
# 直接抛到外层统一进入多轮观察,避免同一份报告短时间内连发。
if is_potential_success:
raise e
logger.warning(f"路径发送图片失败 ({e}),尝试 Base64 回退模式...")
b64_str = await self._get_base64_from_file(image_path)
if not b64_str:
@@ -574,20 +585,21 @@ class OneBotAdapter(PlatformAdapter):
if is_potential_success:
logger.warning(
f"OneBot 发送群 {group_id} 图片出现疑似超时 ({e})。 "
"进入 10s 贴身观察期,尝试通过历史回显核实..."
"进入多轮观察期,尝试通过历史回显核实..."
)
# 等待 10s (NTQQ 后台上传可能在此时完成)
await asyncio.sleep(10)
# [真相检查] 尝试从历史记录中找回失踪的消息
if await self.was_image_sent_recently(
group_id, seconds=300, token=caption
):
logger.info(
f"[OneBot] [真相拦截] 确认群 {group_id} 的超时图片已在后台成功送达。拦截重试。"
)
return True
# Multi-stage observation. Some OneBot implementations commit
# history with delay after timeout-like errors.
observe_windows = (10, 20, 30)
for wait_seconds in observe_windows:
await asyncio.sleep(wait_seconds)
if await self.was_image_sent_recently(
group_id, seconds=420, token=caption
):
logger.info(
f"[OneBot] [真相拦截] 群 {group_id}{wait_seconds}s 观察后确认已送达,拦截重试。"
)
return True
return False # 没找回,返回 False,由上层 RetryManager 接管(带 20s 延迟观察期)
+75 -9
View File
@@ -1,5 +1,6 @@
import asyncio
import base64
import hashlib
import random
import time
from collections.abc import Callable
@@ -7,6 +8,7 @@ from dataclasses import dataclass
import aiohttp
from ...shared.trace_context import REPORT_CAPTION_PATTERN
from ...utils.logger import logger
@@ -20,8 +22,9 @@ class RetryTask:
platform_id: str # 需要保存 platform_id 以便找回 Bot
caption: str = "" # 保存原始消息提示词
retry_count: int = 0
max_retries: int = 3
max_retries: int = 2
created_at: float = 0.0
task_key: str = ""
def __post_init__(self):
if self.created_at == 0.0:
@@ -48,6 +51,9 @@ class RetryManager:
self.worker_task = None
self._dlq = [] # 死信队列 (Failures)
self._active_groups = set() # 正在处理中的群,防止重试地狱
self._active_task_keys = set() # 任务级去重锁,防止同一报告重复入队
self._recent_task_key_ts: dict[str, float] = {} # 最近完成任务用于短时去重
self._dedupe_ttl_seconds = 15 * 60
async def start(self):
"""启动重试工作进程"""
@@ -73,6 +79,8 @@ class RetryManager:
logger.warning(
f"[RetryManager] 停止时仍有 {pending_count} 个任务在队列中 pending"
)
self._active_groups.clear()
self._active_task_keys.clear()
logger.info("[RetryManager] 图片重试管理器已停止")
@@ -91,9 +99,18 @@ class RetryManager:
)
await self.start()
# 核心去重:如果该群已经在重试流程中,不再重复加入队列
if group_id in self._active_groups:
logger.debug(f"[RetryManager] 群 {group_id} 已在重试观察期,跳过任务添加")
self._cleanup_expired_task_keys()
task_key = self._build_task_key(group_id, platform_id, caption, html_content)
# 任务级去重:同一份报告在观察窗口内只保留一个任务
if task_key in self._active_task_keys:
logger.debug(f"[RetryManager] 任务 {task_key} 已在重试流程中,跳过重复入队")
return
if task_key in self._recent_task_key_ts:
logger.debug(
f"[RetryManager] 任务 {task_key} 在去重窗口内已处理过,跳过重复入队"
)
return
task = RetryTask(
@@ -103,9 +120,12 @@ class RetryManager:
platform_id=platform_id,
caption=caption,
created_at=time.time(),
task_key=task_key,
)
self._active_task_keys.add(task_key)
self._recent_task_key_ts[task_key] = time.time()
await self.queue.put(task)
logger.info(f"[RetryManager] 已添加群 {group_id} 的重试任务")
logger.info(f"[RetryManager] 已添加群 {group_id} 的重试任务 (key={task_key})")
async def _worker(self):
"""工作进程主循环:仅负责分发任务到协程,不阻塞"""
@@ -126,11 +146,54 @@ class RetryManager:
logger.error(f"[RetryManager] Worker 调度异常: {e}")
await asyncio.sleep(1)
def _cleanup_expired_task_keys(self):
"""清理过期的去重记录。"""
now = time.time()
expired_keys = [
key
for key, ts in self._recent_task_key_ts.items()
if now - ts > self._dedupe_ttl_seconds
]
for key in expired_keys:
self._recent_task_key_ts.pop(key, None)
def _build_task_key(
self, group_id: str, platform_id: str, caption: str, html_content: str
) -> str:
"""
构造用于去重的稳定任务 key。
优先级:
1)优先使用 caption 中的 TraceID 时间戳
2)若无则取 HTML 内容的哈希前缀
"""
token = None
if caption:
match = REPORT_CAPTION_PATTERN.search(caption)
if match:
token = match.group(0)
if not token:
digest = hashlib.sha1(html_content[:2048].encode("utf-8")).hexdigest()[:16]
token = f"html:{digest}"
return f"{platform_id}:{group_id}:{token}"
def _mark_task_finished(self, task: RetryTask):
"""释放任务级去重锁并刷新冷却时间。"""
if task.task_key:
self._active_task_keys.discard(task.task_key)
self._recent_task_key_ts[task.task_key] = time.time()
async def _run_task_with_delay(self, task: RetryTask):
"""异步执行带延迟的单体重试任务"""
# 锁定该群,防止其他“新”重试任务进入。
# 如果是重试任务(retry_count > 0),它已经在队列循环中,之前已经释放过锁。
if task.group_id in self._active_groups and task.retry_count == 0:
# 群正在被其它重试任务占用,重新排队避免任务被静默丢弃
await asyncio.sleep(5)
if self.running:
await self.queue.put(task)
return
self._active_groups.add(task.group_id)
@@ -170,6 +233,7 @@ class RetryManager:
if success:
logger.info(f"[RetryManager] 群 {task.group_id} 重试流程圆满完成")
self._mark_task_finished(task)
else:
# 3. 失败后续处理
task.retry_count += 1
@@ -185,8 +249,10 @@ class RetryManager:
f"[RetryManager] 群 {task.group_id} 已达最大重试次数,执行文本回退"
)
await self._send_fallback_text(task)
self._mark_task_finished(task)
except Exception as e:
logger.error(f"[RetryManager] 重试协程发生意外: {e}", exc_info=True)
self._mark_task_finished(task)
finally:
# 释放群锁
if task.group_id in self._active_groups:
@@ -208,16 +274,16 @@ class RetryManager:
}
logger.debug(f"[RetryManager] 正在重新渲染群 {task.group_id} 的图片...")
# 修改:return_url=False 获取二进制数据而不是URL
# 这对于解决 NTQQ "Timeout" 错误至关重要,因为它避免了 QQ 客户端下载本地/内网 URL 的网络问题
# 修改:return_url=False 获取二进制数据而不是 URL
# 这可以规避 NTQQ/NT 的“Timeout”假失败,因为下载本地/内网 URL 造成的网络等待会被跳过
image_data = await self.html_render_func(
task.html_content,
{},
False, # return_url=False, 获取 bytes
False, # return_url=False获取 bytes
image_options,
)
# Fix: html_render might return URL (str) even if return_url=False in some implementations
# 修复:某些实现即使 return_url=False 也仍返回 URL 字符串
if isinstance(image_data, str):
if image_data.startswith(("http://", "https://")):
logger.warning(