## 现象 2026-09-23 12:23:08,生产(fordeal `media-resolver-api`)解析微信视频号分享链接失败,一次瞬时的上游空结果直接把整个解析打成终态失败: ``` 2026-09-23 12:23:08 | ERROR | app.services.video_resolver:resolve:318 | All providers failed to resolve video 2026-09-23 12:23:08 | ERROR | app.api.resolve:resolve_url:312 | Video resolution failed: All providers failed for platform 'wechat_channels', video_id ''. Errors: [tikhub: WechatChannels all endpoints failed for 'https://weixin.qq.com/sph/Aqf4lZmY93' [attempts=[{'endpoint': 'fetch_video_detail', 'decision': 'retryable'}]]] ``` 调用方(VideoTranscriptAPI)把该错误归为终态「解析失败,稍后再试」,用户侧任务失败并收到告警通知。 ## 复现与归因 - **链接本身有效**:2026-09-23 15:59 在生产容器内用同一 `share_url` 直调 `POST /api/v1/wechat_channels/v2/fetch_video_detail`(`raw=false`):`code=200`、`data` 节点齐全、`object_type=0`,`_classify_wechat_channels` 判 `ok`。即 12:23 的失败是上游(TikHub / 微信侧)瞬时空结果,不是链路或入参问题。 - **孤发性**:fordeal `logs/error_*.log` 统计,2026-09-02~09-22 视频号相关错误 0 次,09-23 仅此 1 次。低频瞬态,但一发生就是用户可见的任务失败。 - **代码不对称**:下载侧 `TikHubProvider.fetch_wechat_channels_media`(`app/services/providers/tikhub.py`)对同一 `VideoNotFoundError` 已有 3 次有限重试;而**解析侧** `resolve` → `_fetch_wechat_channels` → `_run_chain` 是单端点单次调用,`decision=retryable` 直接 `VideoNotFoundError` 收场。视频号是 TikHub 单源、无 Cobalt 兜底,瞬态没有第二次机会。 关键代码位置: - `app/services/providers/tikhub.py` `_run_chain`:`classify` 判 `retryable` → 记 `attempts` → 链上没有下一端点 → 抛 `VideoNotFoundError`,全程无重试。 - `app/services/providers/tikhub.py` `_fetch_wechat_channels` / `WECHAT_CHANNELS_CHAIN`:单端点链。 - `app/services/providers/tikhub.py` `fetch_wechat_channels_media`:同错误已有 `attempts = 3` 的重试样板。 ## 建议 解析侧对 `retryable` 判定做与下载侧对齐的有限重试(3 次 + 短退避),重试仍按 `_run_chain` 的 `attempts` 累计记录;耗尽后仍抛终态错误,不引入无限重试。可直接复用 `fetch_wechat_channels_media` 的重试样板,保持两侧行为一致。 ## 验收条件 1. 视频号解析路径对 `decision=retryable` 的响应做有限重试(3 次 + 退避),耗尽后仍以 `VideoNotFoundError` 终态收场; 2. `attempts` 记录体现每次重试(能看出「重试了几次、每次判定」); 3. 测试锁定:mock TikHub 返回瞬时空 `data`(前 2 次空、第 3 次正常)断言最终成功;全程空则断言终态失败且 `attempts` 含全部尝试次数; 4. 不改变终态/非视频类错误的现有分类(`_classify_wechat_channels` 两态语义不变,除非与 #33 一并处理)。
Activity
fix(wechat): EndpointHttpError for all non-2xx dict bodies, not just …
fix(wechat): R2 budget-hard guarantee + scalar-only attempt reasons
Merge pull request #38 from zlxlabs/card/MediaResolverAPI-sph-retry-r3
zj1123581321 commentedon Sep 23, 2026
已修复并合入 master(PR #38,合并提交
ffea47a)验收条件对照
decision=retryable做有限重试(3 次 + 退避),耗尽后仍以VideoNotFoundError终态收场_run_chain(max_attempts_per_endpoint=3, retry_backoff=0.3)(其余 8 平台默认 1 = 现状不变)。耗尽仍抛VideoNotFoundError,且不会因总预算被截断成ProviderError(timed out):重试前要求「剩余预算 > 单端超时 + 退避」,装不下的下一次尝试根本不启动attempts体现每次重试(几次、每次判定)endpoint/decision/attempt与归因字段test_chain_transient_then_hit_records_three_attempts、test_chain_exhausted_raises_not_found_with_attempts、test_chain_budget_does_not_start_attempt_that_does_not_fit_call_endpoint内部契约变更只改写抖音/小红书各一条用例(链上行为等价)附带修掉的同源问题
下载侧
fetch_wechat_channels_media里手搓的同形 3 次循环已删除,解析侧与下载侧从此共用同一实现——不下移会叠加成 3×3=9 次上游请求。实测
uv run --extra dev python -m pytest tests/ -q→ 439 passed。VideoNotFoundError且只调 1 次(预算装不下不再启动);重试后挂起 →ProviderError含timed out;预算装不下下一次 →VideoNotFoundError调 1 次;重试耗尽 → 3 次尝试 +VideoNotFoundError。边界说明
归因(
attempts的reason字段)采用有界承诺:域内(fixtures 记录的形态 + 文档化的 4xx/5xx JSON 错误包)保证三类原因可辨;域外畸形形态只承诺「不泄漏凭据 / 不崩溃 / 分类只出 ok|retryable」,不承诺归因完备(域外形态待真实样本再增补,见 #39)。这条边界是本次 5 轮 gate 循环的直接结论,写进了 PR #38 描述。