Skip to content

视频已生成、费用已产生,却因下载读 body 断一次就整单丢弃(无重试 / 不校验长度) #129

Description

@johnnyzhang-eng

现象

动作生成任务在视频已经生成完毕之后失败,整单丢弃,费用照付。

2026-08-05 实测,同一角色连续两单都死在同一处,各烧掉一次生成费用:

peer closed connection without sending complete message body
(received 720450 bytes, expected 929531)

第三单一次成功——两次复现对一次成功,说明这不是稳定失败,是概率性的连接中断。

根因

取视频成品那一步是单次读取、无重试、不校验长度:

return client.get(url).raise_for_status().content

代码位置:backend/packages/framework/src/windup_framework/providers/sufy.py
SufyVideoProvider.i2v 的最后一行;轮询到 completed、拿到 task_result.videos[0].url 之后)。

这一步的位置很特殊:它在"钱已经花完"之后。前面的提交任务、轮询、等待都成功了,
视频在供应商那边已经生成好,只差把 bytes 取回来。此时读 body 断一次,整单就废,
用户看到的是任务 FAILED,而账单上那次生成照常计费。

两个具体缺陷:

  1. 不重试。 这是对成品 URL 的 GET,幂等、不再计费。重试的代价是一次重下(几百 KB),
    不重试的代价是一次重新生成。代价不对等。
  2. 不校验长度。 截断不一定抛异常——服务端提前关流而客户端已收到部分 body 时,
    .content 可能直接返回短 bytes。那样坏视频会一路流到出帧环节才暴露,
    在那里表现为"解码失败",很难回溯到下载这一步。

修复

_download(client, url, tries=3):指数退避重试 3 次;有 Content-Length 时校验实收字节数,
不符按失败重试;分块传输无该头时跳过校验;三次都失败抛 RuntimeError 并带出最后一次的真实原因。

回归测试用 httpx.MockTransport,不联网。已用旧实现做控制样本验证
三条断言(重试成功 / 拒绝截断 / 包装最终原因)在修复前确实失败,两条护栏断言两边都过。

影响面与不在本次范围内的同类点

同一份代码里还有两处 resp.raise_for_status(); return resp.content
server/orchestrator/executor.py_download_master_download),
但它们取的是母版 / 参考图,即输入,失败发生在付费生成之前,损失只有一次任务而没有账单。
严重性不同一档,故不并进本 PR;如需一并加固可另开。

SufyImageProvider.gen_image.json() 取内联 base64 图,截断会抛 JSONDecodeError
而不是静默短读,同样在付费之后,但目前没有实测复现,暂不动。

验收

  • 首次读 body 断连、第二次成功时,任务应完成而不是 FAILED;
  • 服务端声明长度与实收不符时,不得把短 bytes 交给下游;
  • 三次都失败时报错信息要带出最后一次的网络层原因,而不是笼统的"生成失败"。

Metadata

Metadata

Labels

P1优先级 P1(次级)bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions