Skip to content

fix(wechat): 视频号瞬态失败有限重试 + attempts 带脱敏失败原因(#34 #33,取代 #37) - #38

Merged
zj1123581321 merged 9 commits into
masterfrom
card/MediaResolverAPI-sph-retry-r3
Sep 23, 2026
Merged

zj1123581321 merged 9 commits into
masterfrom
card/MediaResolverAPI-sph-retry-r3

Conversation

@zj1123581321

@zj1123581321 zj1123581321 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

本 PR 取代 #37:内容包含 #37 的全部提交(base 是它的 head 187b223),并逐轮收口了 gate primary 在本卡上先后报出的 finding。合并本 PR 后 #37 作为被取代者关闭。

为什么

2026-09-23 12:23 生产一次瞬时的上游空结果把视频号解析打成终态失败(All providers failed ... attempts=[{'endpoint': 'fetch_video_detail', 'decision': 'retryable'}]),调用方按终态处理、用户任务失败并收告警;同链接 15:59 重放完全正常(issue #34)。事后又答不出「12:23 到底哪一种失败」,因为 retryable 只记了一个词(issue #33)。

根因

retryable 的语义是「可能瞬时,值得再来一次」,但引擎对它的唯一补救动作是「换下一个端点」——「重试」和「换源」是两个正交的轴,被合并成了一个动作。多头链上这条错配被兜底掩盖,视频号(TikHub 单源、单端点、无 Cobalt)上直接退化成终态。佐证:下载侧 fetch_wechat_channels_media 自己手搓了 3 次循环,解析侧一次都没有——同一行为两套实现,行为必然漂移。

归因契约的边界(完备性声明,审查按此定 scope)

本 PR 只承诺有界归因,不承诺对任意上游响应形态完备——「对所有形态都能回答为什么」是无限域上的全称命题,既不可被有限测试证明,也不该由本 PR 承担:

  • 输入域:tests/fixtures/wechat_channels/*.json 记录的形态,以及 TikHub 文档化的 4xx/5xx JSON 错误包(body 无 data 节点,或带 code/message)。
  • 域内承诺:retryable 与 parse_failed 两类 attempts 条目都带归因字段——reason ∈ {data_missing, object_type_mismatch, error_body},其中 error_body 另带 int http_status 与上游 message 截断片段;object_type 只带 int,或长度 ≤32 且匹配 [A-Za-z0-9_.:-]+ 的 str。
  • 域外(畸形形态:容器、超长或无界文本、bool/float 充当 object_type 等)只承诺三件事:① 凭据不泄漏;② 不崩溃;③ 分类仍只出 ok/retryable 两态。不承诺归因完备,域外形态的归因精度按需增补(需真实样本,见 backlog issue backlog: 视频号归因的域外形态(畸形上游响应)不承诺完备,待真实样本再增补 #39)。
  • 依据:本仓 risk-tier: internal,收敛判据数的是本仓判定的 P1(数据丢失/静默出错/崩溃/越权/损坏他人数据),不是外部工具的 severity 标签。本 PR 历轮 finding 的「工具定级 / 本仓判定 / 两问答案」见下方备注。

改法

  1. 重试轴进引擎:_run_chain(max_attempts_per_endpoint=..., retry_backoff=...),默认 1 = 现状(其余 8 平台逐字节不变);视频号链传 3 / 0.3s。只对 decision=retryable 重试;http_error(含超时)与 parse_failed 各只算一次尝试。
  2. 删掉下载侧手搓循环:fetch_wechat_channels_media 改为单次调用(_fetch_wechat_channels 内部即 3 次尝试),其后的 media 缺失/不完整/file_size 非法等终态转换原样保留。不下移会叠加成 3×3=9 次上游请求。
  3. attempts 可归因:每次尝试一条记录(新增 attempt,既有 endpoint/decision 键名与取值不变),归因字段见上节边界声明。
    classify 契约向后兼容扩展为 str(等价 (decision, None))或 (decision, reason),由引擎归一化;_call_endpoint 非 2xx 且 body 为 dict 时一律抛 EndpointHttpError(name, status, body)(与 HTTP verb 无关),链上仍以该 body 跑 classify + has_playable,保持既有终态短路与命中语义。
  4. object_type 判定收严:合法视频节点的判据是 type(ot) is int and ot == 0——False / 0.0 / "0" / 缺失一律走「非视频」分支(带归因 + 有限重试),不再因 False == 0 被当成视频节点。
  5. 失败语义边界(多轮收敛出来的口径,两条互不冒充):
    • 链级总预算超时(asyncio.timeout(total_budget) 在某次尝试中途触发)保持既有语义 ProviderError("... timed out"),不改判成「内容找不到」——上游可用性故障与内容侧失败,调用方的重试与告警语义不同(video_resolver.py:275 把 VideoNotFoundError 与 ProviderError 放进同一个 handler,改判没有调用方收益,只有语义污染)。用例:test_chain_budget_timeout_raises_provider_error_not_not_found。
    • attempt 级失败(_call_endpoint 自身抛 ProviderError)按既有设计记 decision: http_error、链走完后终态为 VideoNotFoundError,且异常串不含链级标记 endpoint chain timed out。用例:test_chain_attempt_provider_error_is_not_chain_timeout。
    • _should_retry 要求剩余预算 > per_timeout + retry_backoff:预算装不下的下一次尝试根本不会启动,因此「retryable 反复失败」这条路径走不到总预算超时,仍是 VideoNotFoundError(P2: 视频号解析侧单端点链无瞬态重试,TikHub 瞬时空结果直接终态失败 #34 验收 1),由 test_chain_budget_does_not_start_attempt_that_does_not_fit 锁住。

实测

项 结果
全量 uv run --extra dev python -m pytest tests/ -q → 见本次 head 的报告输出(基线 432 passed,本 PR 末轮 +2 用例)
回归锁(抖音/快手/TikTok/IG/YouTube/Twitter/xhs/terminal 八个文件) 零改动且全绿;_call_endpoint 内部契约变更只改写抖音/小红书各一条对应用例(断言异常 + .body/.status,保留「body 交分类器」的 codex #4 本意)
三态互斥 len({data_missing, object_type_mismatch, error_body}) == 3
脱敏(跨边界) fixture 植入真凭据(full_url / decode_key / url_token)后:str(exc) 与 POST /api/resolve 响应文本中三值零出现;同时断言上游 message 片段确有出现(防恒真)。另覆盖 data.object_type 为容器(内含凭据)那一形态:不进 attempts,只留类型名
object_type 归因 主脑实测:1 → 带值;"1" → 带值;40 字符 str / 含空白的疑似凭据 str / True / dict → 只带类型名(收严后 False/0.0 也不再算合法视频节点)
预算不足不启动下一次尝试 主脑实测(sleep 0.2 / 预算 0.7 / 单端超时 0.6):VideoNotFoundError,_call_endpoint 调用数 1、消息不含 timed out
链级总预算超时 主脑实测(sleep 0.4 / 预算 0.7 / 单端超时 0.05):ProviderError 且含 timed out,调用数 2
attempt 级 ProviderError 记 decision: http_error;终态 VideoNotFoundError;不含 endpoint chain timed out
重试耗尽 _call_endpoint 调用数 == 3、异常类型 is VideoNotFoundError(非 timed out)
非 2xx + 不可解析 body parse_failed 条目带 reason: error_body + int http_status(末轮收口)
红验 逐轮最小注入、逐处单行还原:尝试上限 3→1;raise EndpointHttpError→return body;超时兜底条件 > 1→> 99;isinstance(ot,int)→isinstance(ot,object);_should_retry 预算判据→> 0;把 R2 的超时改判注入回去(链级用例转红、attempt 级用例仍绿,证明两条路径互不冒充);末轮两处(删 parse_failed 归因合并、object_type 判据改回 != 0)。转红原文均贴在各轮报告

影响范围

只影响视频号链(重试 + attempts 字段)与 _call_endpoint 的内部异常形状(链上行为等价,见上表回归证据)。不新增配置项、不新增端点、不改响应结构与缓存。

备注

  • 提交 a3bdc81 是主脑的 CHANGELOG 记账(运维备忘:生产 PUBLIC_BASE_URL 已设为内网直连地址(2026-09-02) #15 的启动告警条目,PR fix(startup): PUBLIC_BASE_URL 留空时启动打 WARNING 提示 (#15) #35 合并时声明由持有 CHANGELOG.md 写权的卡补齐),非实现改动。
  • 历轮 finding 的本仓定级(P1 两问:①真实使用方式下会被触发吗 ②触发了后果能否接受):
    轮 finding 工具定级 本仓判定 两问
    1 预算截断致超时语义错 major P1 等价(改判了调用方错误分类) ①会(重试与慢上游同时出现)②不能接受 → 已修
    1 object_type 容器可带凭据进日志/响应 major P1 等价(凭据泄漏) ①会(上游畸形即可)②不能接受 → 已修
    2 超时被改判成 VideoNotFoundError major P1 等价(同上,分类语义) ①会 ②不能接受 → 已修
    2 非 int object_type 归因丢失 minor P3 ①真实形态未观测 ②仅少一条日志 → 已按「放宽到有界安全标量」修
    3 测试名与实际覆盖不符 minor P3 ①不影响运行时 ②仅误导读者 → 已修
    4 parse_failed 丢 error_body 归因 major P2 ①需「4xx 包长得像 200 且缺 media」,仓内两个真实 4xx 样本均非此形态 ②仅少一条归因,不静默出错 → 仍已修(改动 2 行)
    4 bool 被当 0 minor P3 ①需上游畸形 ②走非视频分支,无静默出错 → 已修(判据收严 1 行)
  • diff 量(base..head)超出被取代卡 fix(wechat): 视频号瞬态失败有限重试 + attempts 带脱敏失败原因(#34 #33) #37 的 hard 预算约 8%,执行器在该卡报告里逐项披露过构成;本 PR 未压缩测试断言凑数。
  • Refs #34、Refs #33(关单由主脑在合入后带证据执行)。

zj1123581321 and others added 7 commits September 23, 2026 16:27
…easons

_run_chain retries retryable per endpoint (wechat: 3 attempts + 0.3s
backoff, others: 1 = unchanged). classify may return (decision, reason);
_call_endpoint raises EndpointHttpError(status, body) on wechat POST
non-2xx dict bodies so attempts records error_body + http_status.
fetch_wechat_channels_media drops its hand-rolled loop (was 3x3=9).

Task-Id:
Fixes-Issue: #34, #33
Agent-Executor: ocgo-muse-pi
Agent-Model: muse-spark-1.3-contributor
Agent-Effort: unknown
Dispatch-Id: dlg-20260923-082148-152a8c
Task-Id: MediaResolverAPI-20260923-01
Classify now returns tuples; empty-envelope chain asserts 3 attempts.
New: transient-then-hit, exhausted VideoNotFoundError with attempts,
tri-state distinctness, cross-boundary desensitization (exc + resolve
API), no-retry on ProviderError. Stream media tests stub _call_endpoint.

Task-Id:
Fixes-Issue: #34, #33
Agent-Executor: ocgo-muse-pi
Agent-Model: muse-spark-1.3-contributor
Agent-Effort: unknown
Dispatch-Id: dlg-20260923-082148-152a8c
Task-Id: MediaResolverAPI-20260923-01
Task-Id:
Fixes-Issue: #34, #33
Agent-Executor: ocgo-muse-pi
Agent-Model: muse-spark-1.3-contributor
Agent-Effort: unknown
Dispatch-Id: dlg-20260923-082148-152a8c
Task-Id: MediaResolverAPI-20260923-01
…POST

Error shape no longer forks on HTTP verb: _call_endpoint raises
EndpointHttpError(name, status, body) for every non-2xx dict body so
_run_chain always runs classify + has_playable and records http_status
(single-endpoint chains like Twitter included). Rewrite the two
_call_endpoint contract tests to assert the raise (+ body/status +
classify conclusion); add a chain-level case proving the body still
reaches the classifier (DouyinTerminalError short-circuit).

Task-Id:
Fixes-Issue: #34, #33
Agent-Executor: ocgo-muse-pi
Agent-Model: muse-spark-1.3-contributor
Agent-Effort: unknown
Dispatch-Id: dlg-20260923-083824-a438d8
Task-Id: MediaResolverAPI-20260923-01
Agent-Executor: pi-lead
Agent-Session: pi-lead-1790151094413-2777230
F1: _should_retry reserves per_timeout + backoff; TimeoutError with
retryable attempts below the cap converts to VideoNotFoundError
(first-hop hangs and single-endpoint chains unchanged). F2:
describe_failure only carries int object_type scalars, non-int yields
a fixed type name; _error_body_reason pins int status + str message.
Extend desensitization test with dict-object_type chain coverage;
add budget-truncation test; append progress paragraph (F3).

Task-Id:
Fixes-Issue: #34, #33
Agent-Executor: ocgo-muse-pi
Agent-Model: muse-spark-1.3-contributor
Agent-Effort: unknown
Dispatch-Id: dlg-20260923-090551-d1d4f8
Task-Id: MediaResolverAPI-20260923-01
Agent-Executor: codex
Agent-Model: gpt-5.6-luna
Agent-Effort: xhigh
Dispatch-Id: dlg-20260923-093403-18aac2
Task-Id: MediaResolverAPI-20260923-04
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Required Gate v2 — 状态面板

当前状态:pass · 可合并

当前裁决:code_pass / primary_pass

Gate 历史(v1;来源为持久化 gate_terminal 制品)

Run Attempt Head 状态 收件人动作
35844762288 1 1a03965 fail 要修代码
35846095955 1 dacff4f fail 要修代码
35848613996 1 0230311 pass 可合并

历史行按 run_id + run_attempt 去重并只增不删;删除本评论后可由 gate_terminal 制品重建。

Agent-Executor: codex
Agent-Model: gpt-5.6-luna
Agent-Effort: xhigh
Dispatch-Id: dlg-20260923-095531-c5d386
Task-Id: MediaResolverAPI-20260923-04
Agent-Executor: codex
Agent-Model: gpt-5.6-luna
Agent-Effort: xhigh
Dispatch-Id: dlg-20260923-101724-798c9e
Task-Id: MediaResolverAPI-20260923-05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant