错误处理与避坑
最难缠的不是报错,是不报错。先讲五个静默坑,再讲哪些错该重试。
这个接口最难缠的地方在哪?
它不报错:状态码正常、图也出来、钱照常扣,只是结果不对。
200响应体结构完整参考图被丢了
只有人眼看图才发现得了 —— 只能写代码时提前避开。
五个"不报错但结果是错的"坑
坑一 · size 只能用像素串,而且永远要传
"size": "16:9"不报错,静默给你一张 2048x2048 方图。省略 size 同理:1536×864 的源图会塌成 1024×1024。
"size": "1536x864"只有像素串 "1536x864" 会被精确遵循。图生图也要传。
接口没有 match_input_image 这类"跟随原图"的值,比例得自己算:读源图真实像素 → 规整成合法值 → 显式传,算法见接口参考。
坑二 · 多参考图可能只有第一张生效
中转层没透传 image[] 时,第二张之后被悄悄丢掉。照常 200、照常计费,没有任何信号。
拿纯红方块 + 纯蓝圆形,提示词要求两个都清晰可见,看结果里是不是都在。
退路:改用单个 image 发底图,其余信息写进提示词;或本地拼成一张再发。image[] 的第一张(接口参考写作 images[0])就是底图 —— 顺序有语义,最重要的排最前。
坑三 · n 不存在,多张图就是多次请求
"n": 4要 4 张,只回来 1 张。n 这个参数根本不存在。
每次单独计费。并发 3–5 个,开多了撞限流。部分失败如实暴露给调用方,别吞掉当成功。
abort 之后上游那次可能已跑完并计费,重试等于第二次付钱。重试上限别设大,超时宁可设长(300 秒)。坑四 · moderation 基本不可控
中转层可能接受 moderation 却忽略它。你以为放宽了继续发边界内容,每次被拦、每次计费。
同一句被拦过的提示词,一次带 moderation: 'low'、一次不带。结果一样就是被忽略了 —— 生产代码别传它。
坑五 · 返回的图片 url 是临时的
url 存活以小时计,域名也不固定。今天入库,明天用户点开是破图。
转存到自己的存储再入库,也别直接发给终端用户。概览页示例代码就是这么写的。
错误处理矩阵
分错类别的代价不对称:故障判成永久失败 = 用户手动重跑;审核当成故障重试 = 每试一次付一次钱,一次都不会成功。
| 上游返回 | 能重试吗 | 怎么办 |
|---|---|---|
451 | 不能 | 内容被安全审核拦截。重跑无用,改提示词或换参考图 |
400 + 审核关键词 | 不能 | 同上,只是换了个状态码穿出来 |
400 + 超时 / 内部错关键词 | 能,要有上限 | 这是故障伪装成 400,见下面的顺序规则一 |
400 参数错(尺寸 / 模型名 / 提示词太短) | 不能 | 修请求本身。原样重发必然再错一次 |
400 源图问题(格式不支持 / 无法解码) | 不能 | 换一张参考图,或按真实字节重新定 MIME 和后缀 |
401 / 403 | 不能 | key 错了或没权限,找管理员 |
402 | 不能 | 余额不足,找管理员 |
429 | 能 | 指数退避 + 随机抖动,别用定长间隔硬撞 |
5xx | 能,要有上限 | 上游临时故障 |
| 超时 / 连接被断 | 能,要有上限 | 同上。前提是你的 fetch 真的设了 AbortSignal.timeout |
两条顺序规则
规则一 · 先匹"故障黑名单",再匹审核关键词
可重试
顺序反了,超时就会被永久判死。
上游超时会伪装成 400 穿出来,正文形如 Timeout when executing ... with timeout 54s。
规则二 · 短码关键词必须用词边界正则
includes('e005')真实踩过:参数错的正文 不合法的size(traceid: 7b3e005af91c4d) 里,traceid 恰好含 e005,被误判成内容审核、提示用户"请调整提示词"。
/\be005\b/带词边界,只匹独立出现的短码,不会被 traceid 里的片段勾中。
可直接用的分类函数
把响应变成带 kind 和 retryable 的 Error,上层只看这两个字段。两条顺序规则靠 if 的次序生效,挪分支前先回上一节。
// 审核关键词(小写匹配)。同一个逻辑错误会有多种文案变体,中英都要覆盖
const MODERATION_KEYWORDS = [
'violate our guardrails', 'may violate our content polic', 'violated our content polic',
'content policy', 'content_policy_violation', 'safety polic', 'blocked by safety',
'appear to be unsafe', 'appears to be unsafe', 'flagged as sensitive', 'nsfw',
'moderation_blocked', 'result of our safety system', "i can't help with", 'i cannot help with',
'安全政策', '内容政策', '防护限制', '色情', '裸露', '不能帮助', '无法用于生成', '违反了我们的',
];
const MODERATION_REGEXES = [/\be005\b/]; // ← 词边界,别改成 includes(见规则二)
// ★ 必须先跑:真故障穿着 400 的皮
const TRANSIENT_400 = [/timeout|timed out|超时/, /unknown internal error|internal server error/];
const BAD_REQUEST = [/不合法的size/, /size must be/, /invalid size/, /model .{0,64} not found/, /提示词太模糊/];
const SOURCE_IMAGE = [/invalid image file/, /unsupported image format/, /image format .* not supported/, /无法解码/];
export async function buildUpstreamError(resp) {
let raw = '';
try { raw = await resp.text(); } catch { /* body 读不出就只按状态码判 */ }
// ★ 分类要跑完整 body,截断只用于打日志
const body = raw.toLowerCase();
const s = resp.status;
const mk = (kind, retryable) =>
Object.assign(new Error(`[${kind}] ${s}: ${raw.slice(0, 300)}`), { kind, status: s, retryable });
if (s === 401 || s === 403) return mk('AUTH', false);
if (s === 402) return mk('INSUFFICIENT_BALANCE', false);
if (s === 429) return mk('RATE_LIMITED', true);
if (s >= 500) return mk('UPSTREAM_5XX', true);
if (s === 451) return mk('CONTENT_MODERATED', false); // 状态码级判定,不看正文
if (s === 400) {
if (TRANSIENT_400.some((re) => re.test(body))) return mk('UPSTREAM_TRANSIENT', true); // ← 必须第一个
if (SOURCE_IMAGE.some((re) => re.test(body))) return mk('BAD_SOURCE_IMAGE', false);
if (MODERATION_KEYWORDS.some((k) => body.includes(k))
|| MODERATION_REGEXES.some((re) => re.test(body)))
return mk('CONTENT_MODERATED', false);
if (BAD_REQUEST.some((re) => re.test(body))) return mk('INVALID_REQUEST', false);
return mk('UNKNOWN_4XX', true); // 认不出的 4xx 允许重试,理由见下文
}
return mk('UNKNOWN', false);
}
别先 slice(0, 256) 再匹关键词。上游随时可能在正文前加一段前言,关键词被切到截断点外,整类审核识别静默失效。
最后那行 UNKNOWN_4XX 可重试,不与矩阵冲突:认得出的参数错走前面分支,落到这里的是还认不出的新错因。宁可多试一次,也别把一整类没见过的错误永久判死。
重试封装(指数退避 + 抖动)
有了 retryable,重试逻辑很薄。两点:限流的退避基数要明显大于普通故障;退避必须加抖动,否则并发请求会踩同一节拍一起重试、一起再被限流。
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function withRetry(fn, { max = 3 } = {}) {
let last;
for (let attempt = 1; attempt <= max; attempt++) {
try { return await fn(); }
catch (err) {
last = err;
const retryable = err?.retryable === true
|| err?.name === 'TimeoutError' || err?.name === 'AbortError'; // fetch 超时
if (!retryable || attempt === max) throw err;
const base = err?.kind === 'RATE_LIMITED' ? 15_000 : 3_000;
await sleep(base * 2 ** (attempt - 1) + Math.random() * 2_000); // 指数退避 + 抖动
}
}
throw last;
}
max = 3 是刻意保守:调大上限不提高成功率,只放大账单。
给终端用户看的文案
| 错误类别 | 该说什么 | 不要说什么 |
|---|---|---|
CONTENT_MODERATED |
内容被安全审核拦截,请调整描述或更换参考图 | "重试一下"(重试无用)、"系统故障"(这不是故障) |
INVALID_REQUEST |
请求参数不对,把具体哪一项说出来 | 笼统的"生成失败",用户无从下手 |
RATE_LIMITED / UPSTREAM_5XX |
服务繁忙,正在自动重试,请稍候 | 让用户自己手动重试 —— 程序已经在重试了 |
INSUFFICIENT_BALANCE / AUTH |
提示联系管理员 | 暴露 key、余额数字这类内部信息 |
同一句提示词有时能过、有时被拦,手动改一改重来偶尔能过。文案可照实说"调整描述后再试一次",但别承诺"重跑一定没用";也别自动重试 —— 纯浪费。
排查速查表
| 现象 | 原因 | 处理 |
|---|---|---|
| 出图永远是方图 | 传了 "16:9" 这类比例别名,或者压根没传 size |
改用像素串,而且恒传 |
| 图生图输出比例不对 | 没传 size,或者没处理 EXIF 旋转 |
见接口参考的尺寸规整 |
| 竖拍手机照片被当成横图处理 | 没做 EXIF orientation 5–8 的换轴 | 同上 |
| 多参考图只有第一张生效 | 中转层没有透传 image[] |
见本页坑二 |
| 请求永远不返回 | Node 的 fetch 没有默认超时 |
每个 fetch 都必须加 AbortSignal.timeout |
调了 quality: 'low' 却没变化 |
该档位可能没被支持、被悄悄按默认处理了 | 直接传 high |
| 图片链接过一阵就 404 | 返回的是临时 URL | 拿到当下就下载转存,见本页坑五 |
| 一张违规图导致后面正常图也失败 | 把审核错误当成渠道故障、疯狂重试 | 按分类函数走,审核类不重试 |
| 偶发 400 说超时,手动重跑就好了 | 故障伪装成 400 | 故障黑名单必须先于审核关键词匹配,见规则一 |
| 参数错却被提示"请调整提示词" | 用 includes('e005') 子串误判 |
改成词边界正则 /\be005\b/,见规则二 |
415 或提示图片不合法 |
文件名后缀和真实格式不一致 | 按真实字节定 MIME 和后缀,别一律写 .png |
一、每个 fetch 都有超时,含下载生成图那次。二、非 2xx 时把响应正文带出来。三、多参考图的透传人工看图确认过一次,别赌。