1212from ..config import live
1313from ..db .crypto import CredentialDecryptError
1414from ..db .repo import CredentialRepository
15+ from ..error_text import error_text
1516from ..request_context import credential_scope
1617from . import TaskReport
1718from .pacer import Pacer
2425# 与 `engine/limits.day_start`、`tasks/activity` 同一口径。
2526_CN_TZ = ZoneInfo ("Asia/Shanghai" )
2627
28+ #: 失败重试退避:首轮失败后 10 分钟再试,连续失败翻倍,封顶 1 小时。
29+ #: 上游对「今日积分尚未发放」会一直回同一句(实测 2026-11-05 两个号从 00:29
30+ #: 到 17:54 每 10 分钟锤一次 → 145 条 WARNING + 几千次无效上游调用)。
31+ #: 退避后一天最多 ~7 次/账号,仍能在当天补签;`trigger="manual"` 不受限制。
32+ _FAIL_BACKOFF_BASE = 10 * 60
33+ _FAIL_BACKOFF_MAX = 60 * 60
34+
2735
2836def _cn_now (now : int | None = None ) -> time .struct_time :
2937 """当前时刻的北京时间 struct_time(测试可注入 epoch 秒)。
@@ -101,6 +109,9 @@ def __init__(self, credentials: CredentialRepository, providers: dict,
101109 # 注意与下面的「时点放行」是两套键:这里是每个凭证/G 作用域的封账,
102110 # 那边是「今天这个时点已经触发过一轮」,语义不能合并。
103111 self ._done_scopes : dict [str , int ] = {}
112+ # done_key → (连续失败次数, 下次允许重试的 epoch 秒)。失败退避,成功清零;
113+ # 与 `_done_scopes` 同键同生命周期,跨天 prune 时一起清。
114+ self ._failures : dict [str , tuple [int , int ]] = {}
104115 # 时点放行:键固定为「本地日期 + 时点分钟数」,抖动只改窗口、不改归属,
105116 # 所以执行时刻落在窗口里哪个分钟都不会让同一时点跑两次。
106117 self ._slot_offsets : dict [str , int ] = {} # 当日该时点抽到的抖动偏移(分钟)
@@ -178,26 +189,52 @@ def _prune(self, now: time.struct_time) -> None:
178189 """丢掉非当天的封账记录:done_key 以日期开头,跨天后不可能再命中,
179190 长驻进程否则每天每账号各攒一条(无上限)。"""
180191 day = self ._day_key (now )
181- stale = [key for key in self ._done_scopes if not key .startswith (f"{ day } :" )]
192+ prefix = f"{ day } :"
193+ stale = [key for key in self ._done_scopes if not key .startswith (prefix )]
182194 for key in stale :
183195 del self ._done_scopes [key ]
196+ for key in [key for key in self ._failures if not key .startswith (prefix )]:
197+ del self ._failures [key ]
184198
185199 def _sealed (self , done_key : str , now : time .struct_time ) -> bool :
186200 """该作用域是否已封账且封账未到期。"""
187201 until = self ._done_scopes .get (done_key )
188202 return until is not None and until > _cn_epoch (now )
189203
204+ def _backed_off (self , done_key : str , now : time .struct_time ,
205+ * , manual : bool ) -> bool :
206+ """失败退避未到期 → 本轮跳过;管理台「立即运行」不受退避限制。"""
207+ if manual :
208+ return False
209+ state = self ._failures .get (done_key )
210+ return state is not None and state [1 ] > _cn_epoch (now )
211+
212+ def _note_failure (self , done_key : str , now : time .struct_time ) -> None :
213+ """记一次失败并按连续失败次数翻倍退避(10m → 20m → 40m → 封顶 1h)。"""
214+ streak = self ._failures .get (done_key , (0 , 0 ))[0 ] + 1
215+ delay = min (_FAIL_BACKOFF_BASE * 2 ** min (streak - 1 , 6 ), _FAIL_BACKOFF_MAX )
216+ self ._failures [done_key ] = (streak , _cn_epoch (now ) + delay )
217+
218+ def _clear_failure (self , done_key : str ) -> None :
219+ """成功即清零:下次再失败从 10 分钟重新起算,不继承旧的退避档位。"""
220+ self ._failures .pop (done_key , None )
221+
190222 async def run_once (self , * , now : time .struct_time | int | None = None ,
191223 trigger : str = "auto" ) -> TaskReport :
192224 """签到一轮:已成功(当日封账)的作用域直接跳过;失败的留到下轮重试。
193225
194- `trigger` 只进审计日志(auto=定时 / manual=管理台「立即运行」),不参与
195- 任何判定。
226+ 失败按连续次数做指数退避(10m → 20m → 40m → 封顶 1h),避免上游
227+ 一直回「积分尚未发放」时每 10 分钟空锤一次;`trigger="manual"`
228+ (管理台立即运行)绕过退避。失败不封账,成功才封账。
229+
230+ `trigger` 只进审计日志(auto=定时 / manual=管理台「立即运行」),
231+ 除绕过失败退避外不参与任何判定。
196232 """
197233 current = now if isinstance (now , time .struct_time ) else _cn_now (now )
198234 day = self ._day_key (current )
199235 report = TaskReport ()
200236 seen : set [str ] = set ()
237+ manual = trigger == "manual"
201238 self ._prune (current )
202239 for candidate in self ._credentials .candidates ():
203240 # `enabled=0`(用户软暂停)同样跳过,口径与调度器 is_selectable 一致。
@@ -232,6 +269,9 @@ async def run_once(self, *, now: time.struct_time | int | None = None,
232269 report .skipped += 1 # 同一轮同上游账号只签一次
233270 continue
234271 seen .add (scope )
272+ if self ._backed_off (done_key , current , manual = manual ):
273+ report .skipped += 1 # 退避未到期,不调上游
274+ continue
235275 report .attempted += 1
236276 # 签到一轮含 status+claim+回查三次上游调用,与 quota_probe/growth
237277 # 共用同一 Pacer,避免绕开全局频率风控对策
@@ -241,11 +281,14 @@ async def run_once(self, *, now: time.struct_time | int | None = None,
241281 with credential_scope (candidate .credential_id ):
242282 result = await provider .checkin (data )
243283 except Exception as error : # noqa: BLE001
244- logger .warning ("checkin failed for %s: %s" , candidate .credential_id , error )
284+ logger .warning ("checkin failed for %s: %s" , candidate .credential_id ,
285+ error_text (error ))
286+ self ._note_failure (done_key , current )
245287 report .failed += 1
246288 continue
247289 if result .ok :
248290 report .succeeded += 1
291+ self ._clear_failure (done_key )
249292 # 成功即封账该作用域:封账期内不再重试,失败凭证不受影响可继续
250293 # 重试。渠道给了 `seal_until` 就按它封(签到周期 ≠ 自然日,
251294 # 见 `CheckinResult.seal_until`),否则封到本地当日结束。
@@ -254,6 +297,7 @@ async def run_once(self, *, now: time.struct_time | int | None = None,
254297 self ._on_success (candidate .credential_id )
255298 else :
256299 report .failed += 1
300+ self ._note_failure (done_key , current )
257301 logger .warning ("checkin failed for %s: %s" ,
258302 candidate .credential_id , result .message or "未知原因" )
259303 # 审计日志:成功/失败/已签都记一行(上游异常没有 result 时不记)。
0 commit comments