视频链路的失败大多不响。
接口返回 200,任务状态是「出片中」,进度条在动。用户看到的是「这次没出好,再来一次」——因为重试有时候真的就好了。这类问题最难的地方在于它不像故障,像不稳定。
这一轮把视频链路从头到尾过了一遍,翻出六处不报错的地方。
一、渠道名猜不出来,于是白提交一次
成本路由的第一步是知道当前任务跑在哪个上游渠道上。最初的实现是从任务 ID 猜:
/** providerTaskId 前缀反推提交时用的上游渠道模型(渠道网关的 task_id 形如 `{model}_xxx`)。 */
export function upstreamModelFromTaskId(providerTaskId: string): string | null {
const idx = providerTaskId.lastIndexOf("_");
return idx > 0 ? providerTaskId.slice(0, idx) : null;
}
依据来自上游文档的示例:videos-mini_xxx。
实测拿到的任务 ID 长这样:
task_O6hOdv…
vid_a11d01…
两个都没有渠道名。lastIndexOf("_") 要么取到一整个随机串,要么取不到下划线。判断恒为 false,于是「是否已在官方渠道」永远答错——官方渠道失败之后,会再白提交一次官方。
改法是别猜。提交时把实际用的上游模型显式回传并落库。
这条改动顺带解决了一个更隐蔽的问题:轮询每一轮会用返回的整个 payload 覆盖本地记录,把渠道字段冲掉。改成用局部变量持有,在覆盖时保留。
二、地址是相对的
某一天开始,某条渠道的出片任务全部停在「出片中」,而上游那边片子早就出好了。
那批任务的特征是任务 ID 以 job_ 开头。上游在这个渠道上把三个返回字段一并改成了相对路径:
{
"url": "/v1/videos/job_c47bb21f.../content",
"video_url": "/v1/videos/job_c47bb21f.../content",
"metadata": { "video_url": "/v1/videos/job_c47bb21f.../content" }
}
把相对路径交给 fetch 会抛 Failed to parse URL,轮询每次 502,而任务状态由上游的 status 字段驱动,那个字段是 completed。本地看到的是「拉取失败」,于是状态不推进。
修法简单:按状态端点的 origin 补成绝对地址。
// 按状态端点的 origin 补成绝对地址(函数名已改写,逻辑原样)
function absolutizeUpstreamUrl(url: string | null, baseUrl?: string): string | null {
if (!url) return null;
if (/^https?:\/\//i.test(url)) return url;
if (!baseUrl) return null;
try {
return new URL(url, baseUrl).toString();
} catch {
return null;
}
}
同一个字段还有一处更早的坑:状态刚翻 completed 的那一两秒里,中转端点的下载地址还没准备好,实测下载时刻比 completed_at 早 1.4 秒,抢着下载会吃 400。改成优先取元数据里的 CDN 直链。
这两处合起来说明一件事:上游返回的「完成」不等于「可以取货」。
三、参考图的签名,等不到排队结束
这一处是最难查的。
用户反馈:参考图有时候生效,有时候不生效。同一张图,同一段提示词,跑两次结果不一样。
根因在签名有效期。交给上游拉取的参考图用的是 15 分钟签名(900 秒),而这个时长是按「浏览器加载一张图」的场景定的——够用。
但上游不是立刻拉图。出片任务要排队,然后才轮到它去下载参考素材。实测:
出片任务要排队 + 生成,实测 10~25 分钟才轮到上游真去拉参考图,15 分钟的签名那时早就过期了。上游拉不到图不会报错,只会静默按纯文本生成——表现就是「参考图时灵时不灵」,极难查。
上游的行为是:拉不到就按没有参考图处理。不报错,不降级提示,直接出一版纯文本结果的片子。
修法是给「交给上游」这条路单独一档签名,6 小时:
/**
* 交给上游 fetch 的对象的有效期:6 小时。
* 出片任务要排队 + 生成,实测 10~25 分钟才轮到上游真去拉参考图……
* 不要拿它签要吐给浏览器的链接:那条不需要这么长,白白放大签名泄露窗口。
*/
export const UPSTREAM_READ_TTL_SECONDS = 6 * 60 * 60;
这段注释里的第二句是边界。签名 URL 是写在链接里的通行证,拿到就能读。有效期必须有天花板,但不能用同一个值套两种场景:浏览器那条 15 分钟够加载,上游那条要按排队时长算。
同一条链路上还有一处漏网的:画布视频节点的参考图仍在用 15 分钟档。修的时候加了注入点,让测试能断言「用的是长签、不是短签」:
it("video node: 参考 URL 用上游长签——15 分钟短签在上游排队后下载时已过期(线上事故)")
四、熔断会自愈,判死没有意义
线上出现过这样的错误:
status=503 code=all_channels_circuit_broken
模型 MiniMax-H3 的所有渠道当前不可用(熔断保护已触发)
网关侧的全渠道熔断。它和普通 5xx 的区别在于它会自愈:网关探到上游恢复就自动合闸。
原实现把这类错误当成永久失败,用户看到报错,干等着手点重试。
改成转后台自动重试,接口返回 202:
/**
* 全渠道熔断后的后台重试参数。
* 窗口 5 分钟:熔断通常几十秒到几分钟自愈,等太久不如让用户换模型;
* 间隔 20 秒:太密只会一直撞在熔断上白烧网关配额。
*/
const CIRCUIT_RETRY_WINDOW_MS = 5 * 60 * 1000;
const CIRCUIT_RETRY_INTERVAL_MS = 20 * 1000;
超窗才落失败,并给出下一步:提示用户可以在设置里换其它视频模型绕开。
计费安全靠的是每轮都是完整的一轮「预留 → 提交 →(失败则退款)」,每次新 operationId。重试多少轮都不会重复扣费或挂住点数。
遇到非熔断错误或余额不足立即停手,不做无意义重试。
五、上游欠费,报成了用户余额不足
上游渠道商返回的原话是「账号积分不足」。
这里说的是渠道商那边的账号。原实现直接透传给用户,用户看到「积分不足」就去充值页面,发现自己的余额好好的。
分成两句话:
if (/积分不足|余额不足|quota|insufficient/i.test(raw)) {
return "上游视频服务账号异常,各渠道均未能出片。这不是你的视频点问题,我们已记录,请稍后重试";
}
用户侧余额不足是另一条分支,文案单独维护。
这条错误的处理原则后来推广到了前端:余额不足的提示改用中文,并给出缺口,不透传后端消息——后端文案变动不该影响用户看到的提示。
六、便宜渠道先原地重试,再切官方
同档模型在不同渠道上价格差得多。以 seedance-2 的 1080p 为例,便宜渠道是官方渠道的三分之一价。
原实现遇到便宜渠道失败就立刻切官方,把成本优势白扔了。但便宜渠道的失败多是暂时的:
「账号积分不足」是渠道商侧欠费、充值后即恢复,风控也是一阵一阵的。
改成两级挽救,顺序不能反:
/** 便宜渠道生成失败后原地重试的次数与间隔——耗尽才允许切官方渠道。 */
const DEFAULT_CHEAP_RETRY_MAX = 10;
const DEFAULT_CHEAP_RETRY_DELAY_MS = 5_000;
已经在官方渠道上的任务,两级都不做——同一个账号换不出新结果,直接走失败退款。
后期这套退避改成了按成本升序逐级走,不再手工连链:
/**
* 退避顺序由成本决定而不是由 fallbackChannelId 手工连链:配置表里已经有全部价格,
* 再维护一份链表只会随调价腐烂。
*/
上限 3 跳。理由是阶梯本身可能有 4~5 条渠道,不设上限会让单个任务在提交或轮询期把整条链试穿,时间成本远超省下来的钱。三跳足以从最便宜走到官方兜底。
排序必须稳定——同价渠道的顺序如果会抖,首选渠道跟着抖,成本核算对不上账。
这类失败为什么值得单独归档
六处里没有一处是「功能坏了」。功能都在跑,只是结果比预期差一点:
- 渠道名猜错 → 多提交一次,多花一份钱。
- 地址是相对路径 → 状态卡住,片子其实已经出好。
- 签名过期 → 参考图不生效,出一版纯文本结果。
- 熔断被当成永久失败 → 用户手动重试,多半能成。
- 上游欠费 → 用户跑去充值,发现不是自己的问题。
- 便宜渠道立刻切官方 → 每次都贵三倍。
它们的共同点是失败被下游吸收掉了。上游按「没有参考图」处理,本地按「生成中」等着,用户按「重试一次」处理。每一层都做了合理的降级,降级叠起来就把根因盖住了。
排查这类问题的入口不是日志,是对照:同一张图跑两次结果不同、同一段提示词两个渠道表现不同、同一个任务的成本和预期差三倍。能对照的地方,才看得出哪一层在悄悄降级。
补完这六处之后,剩下一条通用规则:凡是把 URL 交给外部系统,签名的有效期按那个系统的实际取用时刻算,不按发起时刻算。上游什么时候来拉,不由我们决定。
■