From Shot to Footage: Image-to-Video, Polling Async Tasks, and Retrying Failures
Video generation is the slowest, most expensive, and most failure-prone step in the whole pipeline; today turn it into a safely retryable async task, and learn to write prompts in shot language the model actually understands.
今日目标
- 能用首帧图加提示词生成单个镜头,并说清文生视频与图生视频的取舍
- 能写出一个正确的异步任务轮询循环,处理排队、超时、退避与取消
- 能对视频接口的失败做分类,区分该重试的和重试也没用的
昨天你把角色和场景都锁死了,手里有一批稳定的静态图。今天让它们动起来——但今天真正难的不是画面,是「一次调用要等好几分钟」这件事带来的全部工程后果。读完回到页面顶部把三条目标勾掉。
小白版讲解
一镜多久,怎么拍:把镜头语言写进提示词
先解决一个新手最容易忽略的问题:你到底要模型拍什么。
在摄影棚里,导演不会只说「拍男主在楼下」。他会说:中景,机器缓慢推近,六秒,男主蹲在电动车旁,手里握着一部亮着屏的手机。这四件事——景别、运镜、时长、画面——缺一件,摄影师就得自己猜,而猜出来的东西一定不是你要的。
视频接口也一样,它只是没法反问你。所以本课的分镜数据里,这四件事各占一个字段:shotSize 是景别(只有远景、中景、特写三档,不要引入第四档)、camera 是运镜、durationSec 是时长、visual 是画面描述。拼提示词时按先拍什么、再怎么拍、最后什么风格的顺序串起来——这个顺序不是随意的:提示词有 2000 字符上限,超了会被截断,而截断总是从尾巴开始,所以最不重要的风格描述放最后,被砍掉损失最小。
时长这一项有个反直觉的取舍。接口默认 6 秒,很多人第一反应是「那我一次生成 30 秒不就省事了」。别这么做,理由有三条:越长的片段越容易在中段崩坏,人物走着走着脸就变了;失败代价线性放大,30 秒失败一次等于白烧 5 个 6 秒的钱;最重要的是长片段没法局部重做——发现第 18 秒有问题只能整条重来,切成 6 秒一镜则只重做那一镜。短剧的分镜节奏本来就是 3 到 8 秒一切,工程上的最优解和内容上的最优解在这里恰好重合。
再说文生视频与图生视频的取舍。文生视频(一句话直接出片段)省掉一次图像调用,看起来更便宜;但它把画面的全部控制权交给了模型,你昨天辛苦锁下来的那张脸完全用不上。图生视频(先有首帧图,再让模型接着往下演)多花一张图的钱,换来的是构图、光线、人物形象全部由你昨天已经确认过的那张图定死。
这笔账值不值,看两边的量级差多少。图像官方直接标价 ¥0.025 一张;视频这边,官方按量付费页给出的档位是 768P 每秒 ¥0.50、2K 每秒 ¥0.80、480P 每秒 ¥0.33。也就是说,一条六秒镜头的成本是一张图的上百倍——用两三分钱的图去降低它的失败率,是稳赚的买卖。(这几档按秒价属于按量付费路径;今天代码里用的模型是走套餐点数扣减的,两套账不通用,D12 会把这件事摊开讲。)
这就引出今天的第一条口径:把 D3 挑好的定妆图当作视频的第一帧。 具体怎么用、传什么字段,以及为什么这一步做完之后,真正的麻烦才刚开始——因为这个接口不会当场把视频还给你。
首帧:让昨天的成果真的被用上
图生视频的字段叫 first_frame_image,收公网 URL 或 Base64 Data URL,格式支持 JPG、PNG、WebP,大小要小于 20MB。你昨天存进资产库的那张定妆图,只要能变成一个接口取得到的地址,就能直接放进去。
它带来的确定性比想象中大。首帧一旦给定,这个片段的第一帧就是它——构图、光线、服装、人脸全部落定,模型只需要在这个起点上往后推六秒。昨天讲的参考图管的是「不同图之间像不像同一个人」,今天的首帧管的是「这一镜从哪儿开始演」,两者是接力关系,不是重复;角色一致性的原理昨天已经讲完了,今天不再展开。
配合首帧写提示词时有个实践:画面描述要写「接下来发生什么」,而不是重复首帧已有的信息。 首帧里已经有雨夜、电动车、蓝工服,再描述一遍只是浪费字符;应该写「他缓缓站起身,抬头看向楼上亮着的窗」。
还有一个开关必须提前关掉:prompt_optimizer,它在视频接口里默认是 true(图像接口那边默认是 false,两边正好相反,别搞混)。开着的话服务端会替你改写提示词而你看不到改写结果,同一段输入两次跑出来的东西可能完全不同。要可复现就把它设成 false。
const SHOT_SIZE_ZH = { wide: '远景', medium: '中景', close: '特写' }
// 顺序:画面 → 景别 → 运镜 → 风格。截断从尾巴开始,所以风格放最后
function shotPrompt(shot) {
return `${shot.visual}。${SHOT_SIZE_ZH[shot.shotSize]},${shot.camera}。${STYLE}`
}
async function submit(shot, firstFrameUrl) {
const res = await fetch(`${process.env.MINIMAX_BASE_URL}/v1/video_generation`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MINIMAX_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'MiniMax-Hailuo-2.3',
prompt: shotPrompt(shot),
first_frame_image: firstFrameUrl, // 昨天挑好的定妆图
duration: Math.round(shot.durationSec),
resolution: '768P',
prompt_optimizer: false, // 视频接口里它默认是 true,会悄悄改写你的提示词
}),
})
const json = await res.json()
if (json.base_resp?.status_code) throw new Error(`提交失败 ${json.base_resp.status_code}`)
return json.task_id // 只拿回一个标识,此时一帧都还没生成
}import os
import httpx
SHOT_SIZE_ZH = {"wide": "远景", "medium": "中景", "close": "特写"}
# 顺序:画面 → 景别 → 运镜 → 风格。截断从尾巴开始,所以风格放最后
def shot_prompt(shot: dict) -> str:
return f"{shot['visual']}。{SHOT_SIZE_ZH[shot['shotSize']]},{shot['camera']}。{STYLE}"
def submit(shot: dict, first_frame_url: str) -> str:
res = httpx.post(
f"{os.environ['MINIMAX_BASE_URL']}/v1/video_generation",
headers={"Authorization": f"Bearer {os.environ['MINIMAX_API_KEY']}"},
json={
"model": "MiniMax-Hailuo-2.3",
"prompt": shot_prompt(shot),
"first_frame_image": first_frame_url, # 昨天挑好的定妆图
"duration": round(shot["durationSec"]),
"resolution": "768P",
"prompt_optimizer": False, # 视频接口里它默认是 True,会悄悄改写你的提示词
},
timeout=60,
)
data = res.json()
if data.get("base_resp", {}).get("status_code"):
raise RuntimeError(f"提交失败 {data['base_resp']['status_code']}")
return data["task_id"] # 只拿回一个标识,此时一帧都还没生成异步任务的形状:四步,每一步都会出错
注意上面那段代码的最后一行:拿回来的是 task_id,不是视频。
这是本章真正的主题。图像接口是同步的——发请求、等几秒、拿结果。视频不行,它要跑好几分钟,没有哪个 HTTP 连接愿意挂那么久。所以接口的形状变成了四步:
- 提交:
POST /v1/video_generation,拿回task_id。 - 轮询:
GET /v1/query/video_generation?task_id=,反复查状态。注意task_id是 query 参数,不是路径参数。状态枚举是首字母大写的Preparing、Queueing、Processing、Success、Fail。 - 取件:成功后从查询结果里拿到
file_id,再调GET /v1/files/retrieve?file_id=换一个下载地址。 - 下载:立刻把文件存到本地。
用剧组打比方:提交是把通告单交给制片,task_id 就是那张单子的编号;轮询是隔一会儿去问一句「我那条拍完没有」;取件是拿着编号去仓库领带子。四步各有各的出岔方式——单子递丢了、拍砸了、仓库说没这盘带子、搬回来的路上断网。
这里有个坑要提前说:同一家厂商可能同时挂着两套形态不同的视频接口。 MiniMax 除了本课用的这套 v1,还并存着一套 v2,两者的差别不在能力而在形状——查询是路径参数不是 query 参数、状态枚举是小写的、而且成功后直接返回结果地址,根本没有取件这一步。照着一套的文档去解另一套的响应,你会拿到一堆空值,还会怀疑是自己网络的问题。本课统一只用 v1,你自己的项目里也要在代码和文档里把用的是哪一套写死。
D1 定下的 VideoProvider.generate() 把这四步包成了一次 await,调用起来很舒服。但今天要把包装拆开,因为出问题的永远是包装里面那一层:你在生产环境看到的是「这一镜没出来」,要修就得知道它卡在哪一步。
拆开之后还有个额外好处:这四步各自是可注入的,每一种失败都能在离线状态下演一遍——今天的实验就是这么做的。
轮询的礼貌与代价
现在写那个「隔一会儿去问一句」的循环。它看着简单,但有三个地方写错了会在生产上出事。
第一,间隔不能固定。 一个排队五分钟的任务用一秒的固定间隔去查,就是三百次无效请求。查询接口本身也有速率限制,你很容易自己把自己打到限流,然后在日志里看到「视频生成失败:限流」,还以为是生成接口的问题。正确做法是指数退避:从五秒起步,每轮乘一个系数(比如 1.5),让间隔一次比一次长。
第二,退避必须有上限。 只乘不封顶的话,退到第十轮已经是几分钟一次——任务早就好了你还在睡。上限设成 20 秒左右比较合适。
第三,超时判断要放在睡觉之前。 常见的写法是先睡再判断有没有超过预算,结果总会在预算之外多睡整整一轮——退避到 20 秒间隔时就是白等 20 秒。正确的判据是:睡下去会不会越过截止时间?会就现在放弃。
还有一条顺序问题:先判终态,再判超时。 如果一个任务恰好在最后一次查询里返回了成功,你却因为「已经到预算了」把它当超时扔掉,就白白付了一次钱还丢了产物。
export async function pollUntilDone(taskId, opts) {
const startedAt = Date.now()
const deadline = startedAt + opts.timeoutMs
let waitMs = opts.initialWaitMs // 5000
for (let attempt = 1; ; attempt += 1) {
const { status, fileId } = await query(taskId)
// 先判终态:任务恰好在最后一次查询里成功时不能被误杀
if (status === 'Success') {
if (!fileId) throw new Error('任务成功但没有 file_id')
return fileId
}
if (status === 'Fail') throw new TaskFailedError(taskId)
// 判据是「睡完会不会越过截止线」,不是「睡完再看有没有超时」
if (Date.now() + waitMs > deadline) {
throw new TaskTimeoutError(taskId, Date.now() - startedAt)
}
await sleep(waitMs)
waitMs = Math.min(Math.round(waitMs * opts.factor), opts.maxWaitMs) // 退避要封顶
}
}import time
def poll_until_done(task_id: str, timeout_ms: int, initial_wait_ms: int = 5000,
factor: float = 1.5, max_wait_ms: int = 20000) -> str:
started_at = time.monotonic() * 1000
deadline = started_at + timeout_ms
wait_ms = initial_wait_ms
while True:
status, file_id = query(task_id)
# 先判终态:任务恰好在最后一次查询里成功时不能被误杀
if status == "Success":
if not file_id:
raise RuntimeError("任务成功但没有 file_id")
return file_id
if status == "Fail":
raise TaskFailedError(task_id)
now = time.monotonic() * 1000
# 判据是「睡完会不会越过截止线」,不是「睡完再看有没有超时」
if now + wait_ms > deadline:
raise TaskTimeoutError(task_id, int(now - started_at))
time.sleep(wait_ms / 1000)
wait_ms = min(round(wait_ms * factor), max_wait_ms) # 退避要封顶上面这段只管一个任务。真实一集有四十镜,四十个任务同时压过去会撞上厂商的并发与配额限制,那就需要一道闸门来控制同时在跑几个——闸门怎么做、配额怎么算,是 D9 的内容,今天先把单个任务写对。
结果文件是临时的
任务成功之后,你拿到的不是视频,是一个 file_id;用它换来的也不是视频,是一个临时下载地址。
官方没有写明这个地址能活多久,所以唯一安全的假设是:它随时会失效。 正确做法只有一条——拿到就立刻下载落盘,数据库和索引里存本地路径,绝不存那个 URL。
它被违反的方式很隐蔽:流程一直跑得好好的,因为下载总在拿到地址后几秒内发生;直到某天你加了一个「先收集全部地址、再统一下载」的优化,事故就来了。把下载写进取件那一步的同一个函数里,让它在结构上没法被拆开,比写一条注释提醒自己有用得多。
落盘之后还要记两件事:这一镜对应哪个 task_id、用的什么提示词。视频是这条线上最贵的产物,答不出「这一条是怎么生成出来的」,就只能重新花一次钱。
失败分类表
最后一节讲失败,它是这一章面试里被问得最多的部分。
分类的判据不是状态码的首位数字,而是三个问题:等一等会不会好?改输入会不会好?还是必须叫人来?
| 状态码 | 含义 | 类别 | 处置 |
|---|---|---|---|
| 1002 | 触发限流 | 等一等会好 | 退避后重试,程序自己扛 |
| 服务端错误 | 对方故障 | 等一等会好 | 退避后重试,程序自己扛 |
| 2013 | 参数无效 | 改输入才会好 | 修请求体,重试一万次都是同一个错 |
| 1026、1027 | 命中内容审核 | 改输入才会好 | 改提示词或首帧,不是加重试次数 |
| 1004 | 鉴权失败 | 必须叫人 | 立刻告警,程序解决不了 |
| 1008 | 余额不足 | 必须叫人 | 立刻告警,程序解决不了 |
这张表最重要的价值是把「白烧钱」的那一类挑出来。参数无效和内容审核这两类,每一次重试都在重复同一个错误,还挤占限流额度让真正该重试的排不上号。把它们判对,比把重试写得多聪明更省钱。内容审核为什么会拦、提示词怎么改,是 D11 的合规话题,今天只做到分对类。
把这张表写成一个函数,重试逻辑就只剩一行判断:
// 只对判成 retry 的错误退避重试;其余立刻抛出,一次预算都不浪费
export async function withRetry(fn, { attempts, initialWaitMs, maxWaitMs }) {
let waitMs = initialWaitMs
for (let attempt = 1; ; attempt += 1) {
try {
return await fn()
} catch (err) {
const verdict = classify(err)
if (verdict.action !== 'retry' || attempt >= attempts) throw err
console.log(`第 ${attempt} 次失败(${verdict.kind}),${waitMs}ms 后重试`)
await sleep(waitMs)
waitMs = Math.min(waitMs * 2, maxWaitMs)
}
}
}import time
# 只对判成 retry 的错误退避重试;其余立刻抛出,一次预算都不浪费
def with_retry(fn, attempts: int, initial_wait_ms: int, max_wait_ms: int):
wait_ms = initial_wait_ms
for attempt in range(1, attempts + 1):
try:
return fn()
except Exception as err:
verdict = classify(err)
if verdict["action"] != "retry" or attempt >= attempts:
raise
print(f"第 {attempt} 次失败({verdict['kind']}),{wait_ms}ms 后重试")
time.sleep(wait_ms / 1000)
wait_ms = min(wait_ms * 2, max_wait_ms)还有一类不在这张表里,但更需要小心:超时。超时不是失败,是「不知道成没成」——对方队列里那个任务可能还在跑,甚至可能已经成了。所以超时之后直接重新提交,你很可能为同一个镜头付两次钱。正确的顺序是:先按幂等键查一遍有没有已完成的产物,没有再重提。幂等键怎么设计、断点续跑怎么做,是 D8 的工作流引擎要解决的问题;今天你要做的是把这句话写进超时那条日志里,让未来的自己知道这里有个坑。
最后一条批量场景的口径,和昨天一致:单镜失败不要中断整批。 四十镜里第七镜被审核拦了,记下这一镜、继续跑剩下的,最后把失败清单一次性报出来。中断整批意味着前面六镜的钱白花——而一条镜头的成本是一张图的上百倍,这笔账比昨天严重得多。
源码导读
动手实验
故障注入全课统一用 INJECT 环境变量,今天支持五个值:timeout 让任务永远排队、ratelimit 让第一次提交撞限流、blocked 撞内容审核、badrequest 撞参数无效、taskfail 让任务走到失败终态。注入只作用在网络出口那一层,轮询状态机与失败分类照常执行——所以你验的是自己的代码,不是桩。
starter/ 里挖了四个练习点:两个在轮询循环(超时预算、指数退避),一个在分类表,一个在提示词拼装。MOCK=1 下完全离线跑通——离线的任务队列会真的走完排队到成功的状态链,也能被切换成「永远排队」,所以你的轮询代码在离线状态下一行都不会被绕过。先原样跑一次,你会看到轮询间隔恒定不变、超时早退了一秒、分类表整列都是重试,这三个现象就是你的待办清单。
- 把提交、轮询、取件、下载四步各写成一个函数,跑一次正常路径,确认日志里能看到状态从排队走到成功。
- 用 D3 的定妆图作首帧再跑一次,对比不带首帧时的构图差异。
- 把轮询改成指数退避加超时上限,注入一次永远排队的任务,确认程序在预算用完的那一刻放弃、而不是早退或死等。
- 写出错误分类函数,让分类表里六个状态码分别落到重试、改输入、报警三类,并注入一次限流验证退避重试真的发生了。
- 注入一次内容审核失败,确认程序没有重试、直接给出可读的处置建议。
面试题
今天 3 道题在下方题库区,侧重异步任务客户端的正确写法、轮询退避与超时取消、生成类接口的错误分类。展开后先看「分析过程」再看要点——第 1 题几乎是所有涉及生成类接口岗位的必问题,把失败情况列全比答得漂亮更重要。标注「国内高频 / 海外高频」方便按目标市场取舍。
检查清单与明日预告
- 能用首帧图加提示词生成单个镜头,并说清文生视频与图生视频的取舍
- 能写出一个正确的异步任务轮询循环,处理排队、超时、退避与取消
- 能对视频接口的失败做分类,区分该重试的和重试也没用的
- 能说清为什么超时不等于失败,以及重试之前必须先做什么
- 能解释为什么下载落盘必须写在取件的同一步里
- 实验的 5 条验收标准全部通过
- 3 道面试题不看要点也能答出至少 2 道
明天(D5)我们给这些镜头配上声音。每个角色分配自己的音色,把台词批量合成,然后做一件顺序上很关键的事:用量出来的真实语音时长,反过来校正镜头时长。今天的镜头是按计划时长生成的,可台词到底要说几秒,只有合成完才知道——这个「先有画面还是先有声音」的循环,决定了整条流水线的排列顺序。明天还会由这条时间轴产出字幕文件,它就是后天剪辑台的唯一输入。
Interview questions
You are asked to implement a client for an asynchronous generation task. Which failure cases would you cover?让你实现一个异步生成任务的客户端,你会考虑哪些失败情况?
Common in ChinaCommon overseasIntermediate#async-task#error-handlingHow to reason about it · think before answering
- The differentiator here is coverage, not code. Answering 'wrap it in try/catch and retry' usually means you have never run this kind of API in production.
- Describe the shape first so the failures have somewhere to hang: submit and get an id, poll for status, retrieve a URL, download to disk — four steps, four families of failure.
- Then enumerate: at submit, rate limiting, auth failure, invalid parameters, content moderation; at poll, the query endpoint rate limiting you, a status that never advances, or a terminal failure; at retrieve, a valid id that yields no URL; at download, an expired link, a stream cut halfway, a disk write error.
- Then the two that span the whole flow: timeout and process restart. A timeout is not a failure, it is 'I don't know' — you must look up the idempotency key before resubmitting. A restart means in-memory task ids are gone, so the id has to be persisted before or immediately after the request, or you will have paid-for tasks you can never reclaim.
- Close with a line that shows judgment: of the four steps, only the download is safely retryable on its own; a retry at any other step can create a new billable job.
- Expect the follow-up: if the vendor offers callbacks, do you still poll? Yes. Callbacks get lost to restarts, network blips and unreachable endpoints, so the standard is callback-first with a low-frequency sweep for tasks stuck without a terminal state.
分析过程 · 先想清楚再作答
- 这题的区分度不在代码,在你能列出多少种失败。只答「加个 try catch 和重试」的人,通常没在生产上跑过这类接口。
- 先把任务的形状说清楚,失败点才有地方挂:提交拿标识、轮询查状态、取件换地址、下载落盘,四步是四类不同的失败。
- 然后逐步列:提交阶段有限流、鉴权、参数无效、内容审核;轮询阶段有查询接口自己限流、状态一直不前进、任务返回失败终态;取件阶段有标识存在但取不到地址;下载阶段有地址过期、下到一半断流、写盘失败。
- 接着说横跨全程的两类:超时与进程重启。超时的关键在于它不是失败而是「不知道成没成」,必须先按幂等键查一遍再决定要不要重提;进程重启意味着内存里的任务标识没了,所以标识必须先落盘再发请求,否则你会有一批花了钱却找不回来的任务。
- 最后给一句能体现工程判断的话:这四步里只有下载是可以无脑重试的,其余每一步的重试都可能产生一次新的计费。
- 可以预期的追问:厂商提供回调了还需要轮询吗?需要。回调会因为服务重启、网络抖动、地址不可达而丢失,生产上的标准做法是回调为主、低频轮询兜底扫描长时间没有终态的任务。
Key points
- Break failures down by the four steps: submit (rate limit, auth, invalid params, moderation), poll (query rate limit, stalled status, terminal failure), retrieve (no URL), download (expired link, cut stream, disk error)
- A timeout means unknown, not failed: look up the idempotency key for an existing artifact before resubmitting, or you pay twice
- Persist the task id promptly so in-flight tasks survive a process restart
- Only the download is safely retryable on its own; retries at the other steps can create new billable jobs
- Keep a low-frequency polling sweep even when callbacks exist, because callbacks get lost
答题要点
- 按四步拆失败:提交(限流、鉴权、参数无效、内容审核)、轮询(查询限流、状态停滞、终态失败)、取件(拿不到地址)、下载(地址过期、断流、写盘失败)
- 超时不是失败而是状态未知,重试前必须先按幂等键查一遍已有产物,否则会为同一个任务付两次钱
- 任务标识要及时落盘,进程重启后才能把在途任务认回来
- 四步里只有下载可以无脑重试,其余每一步的重试都可能产生新的计费
- 有回调也要保留低频兜底轮询,回调会丢
How do you choose a polling interval, and why is a fixed interval a bad default?轮询间隔怎么定?为什么不能一直用固定间隔死等?
Common in ChinaCommon overseasBasic#async-task#backoffHow to reason about it · think before answering
- This looks like a giveaway, but it has three layers and only the first one is obvious. They want to know whether you have actually written this loop.
- Layer one is cost: a task queued for five minutes polled every second is three hundred wasted requests. The query endpoint has its own rate limit, so you can throttle yourself and then misread 'rate limited' in the logs as a generation problem.
- Layer two is capping the backoff: multiply without a cap and you end up polling every few minutes, sleeping long after the task finished. Pick the cap from what extra wait a user tolerates — usually in the ten-to-twenty-second range.
- Layer three is where people actually get it wrong: the timeout check belongs before the sleep, and the test is whether sleeping would cross the deadline. Sleeping first means overshooting the budget by a full interval, which at a twenty-second backoff is twenty wasted seconds.
- Mention ordering too: check terminal states before the timeout. Discarding a task that just succeeded on the final poll means paying for an artifact you then throw away.
- Expect the follow-up: how do you pick the initial interval? From the typical duration of this class of task, a bit above a tenth of it; and the very first poll can be delayed slightly, since a just-submitted task is almost never done.
分析过程 · 先想清楚再作答
- 这是一道送分题,但它有三个层次,只答出第一层拿不到高分。面试官想看的是你有没有真的写过这个循环。
- 第一层是成本:一个排队五分钟的任务,用一秒的固定间隔就是三百次无效请求。查询接口自己也有速率限制,你很可能自己把自己打到限流,然后在日志里看到「生成失败:限流」,还以为是生成接口的问题。
- 第二层是退避要封顶:只乘不封顶的话,退到后面已经是几分钟查一次,任务早就好了你还在睡。上限的选法是「用户能忍受的额外等待」,一般十几到二十秒。
- 第三层最容易写错,也是这题真正的区分点:超时判断必须放在睡觉之前,判据是「睡下去会不会越过截止时间」。先睡再判会让你在预算之外多睡整整一轮,退避到二十秒时就是白等二十秒。
- 另外提一条顺序:先判终态再判超时。任务恰好在最后一次查询里成功却被当成超时扔掉,等于付了钱还丢了产物。
- 可以预期的追问:起步间隔怎么定?按这类任务的典型耗时定,比典型耗时的十分之一略大即可;再往细说就是首次查询可以稍微延后一点,因为刚提交的任务几乎不可能立刻完成。
Key points
- A fixed interval is either too tight, wasting requests and throttling yourself, or too loose, adding dead time after completion
- Use exponential backoff starting near a tenth of the task's typical duration
- Cap the backoff, choosing the cap from the extra wait a user will tolerate
- Check the timeout before sleeping, testing whether the sleep would cross the deadline, or you overshoot the budget by a full interval
- Check terminal states before the timeout so a task that just succeeded is not discarded
答题要点
- 固定间隔要么太密造成大量无效请求并把自己打到限流,要么太疏让完成后的等待过长
- 用指数退避:从接近典型耗时十分之一的间隔起步,每轮乘一个系数
- 退避必须封顶,上限按用户能忍受的额外等待来定
- 超时判断放在 sleep 之前,判据是「睡完会不会越过截止时间」,否则会在预算之外多睡一轮
- 先判终态再判超时,避免把最后一次查询里刚成功的任务误杀
When a generation API returns a failure, how do you decide whether to retry, and what happens after the retries run out?生成类接口返回失败,你怎么判断该不该重试?重试几次之后该做什么?
Common in ChinaCommon overseasDeep dive#error-handling#retry#costHow to reason about it · think before answering
- The hinge is 'decide'. Bucketing by the leading digit of the HTTP status is the classic wrong answer, because generation APIs often return HTTP 200 with a business error code in the body.
- Give a reusable test instead of reciting a code table: ask three questions — will waiting help, will changing the input help, or does a human have to step in? They map onto three dispositions: back off and retry, fix the request, alert immediately.
- Concretely: rate limits and server errors are the first bucket and the program handles them; invalid parameters and content moderation are the second, where retrying repeats the same error and burns rate-limit budget that genuinely retryable tasks needed; auth failure and insufficient balance are the third, where retrying only delays the alert.
- Handle timeout separately — this is the line that signals experience. A timeout is unknown, not failed: the job may still be running, or may have finished. So never resubmit blindly; look up the idempotency key for an existing artifact first.
- When retries are exhausted, do three things: mark the item failed with the last error code and the exact request parameters, keep processing the rest of the batch instead of aborting it, and aggregate the failures into one readable alert rather than one per item.
- Expect the follow-up: how many retries? Scale it by unit price. The more expensive the call, the fewer automatic retries, and expensive failures should go to a human for review before being redone.
分析过程 · 先想清楚再作答
- 这题的题眼是「判断」。按状态码首位数字一刀切是最常见的错误答案,因为生成类接口的业务错误码往往和 HTTP 状态码不在一个层面上——很多厂商的失败是 HTTP 200 加一个响应体里的业务码。
- 给一条可复用的判据,比背错误码表有用:问三个问题——等一等会不会好、改输入会不会好、还是必须叫人来。三个问题对应三种处置:退避重试、修请求、立刻告警。
- 落到具体:限流和服务端故障属于第一类,程序自己扛;参数无效与内容审核属于第二类,重试一万次都是同一个错,而且会挤占限流额度让真正该重试的排不上号;鉴权失败与余额不足属于第三类,重试只会延迟告警。
- 然后单独处理超时,这是最能体现经验的一条:超时不是失败,是状态未知,对方队列里那个任务可能还在跑甚至已经成了。所以超时之后不能直接重提,要先按幂等键查一遍已有产物。
- 重试用尽之后要做三件事,缺一不可:把这一条标成失败并记下最后一次的错误码与请求参数、继续跑批次里剩下的任务不要中断、把失败清单汇总成一次可读的告警而不是每条发一次。
- 可以预期的追问:重试次数怎么定?按单价定。单价越高,允许的重试次数越少,而且高单价的失败更应该先送人复核再决定要不要重做。
Key points
- Do not bucket by the leading HTTP digit; generation APIs often hide the business error code inside an HTTP 200 body
- Use three questions — will waiting help, will changing the input help, or is a human required — mapping to back off, fix the request, alert
- Rate limits and server errors are retryable; invalid parameters and moderation blocks are not and waste rate-limit budget; auth and balance failures need an alert
- A timeout is unknown rather than failed: check the idempotency key for an existing artifact before resubmitting, or you pay twice
- When retries run out, mark the item failed with its error code and request parameters, keep the batch running, and aggregate failures into one alert; scale retry counts by unit price
答题要点
- 不要按状态码首位一刀切,生成类接口的业务错误码常常藏在 HTTP 200 的响应体里
- 判据是三个问题:等一等会不会好、改输入会不会好、还是必须叫人来,分别对应退避重试、修请求、立刻告警
- 限流与服务端故障可重试;参数无效与内容审核重试无用且会挤占限流额度;鉴权失败与余额不足必须告警
- 超时是状态未知不是失败,重试前先按幂等键查一遍已有产物,否则会重复计费
- 重试用尽后:标记失败并留下错误码与请求参数、不中断整批、把失败汇总成一次可读告警;重试次数按单价定
Comments
Sign in to join the discussion
No comments yet — be the first.