AI API错误码规范
要点
- AI API 调用链路长,认证失败、参数不合法、模型超时、配额耗尽、上游崩溃,每种错误的处理方式不同
- 只靠 HTTP 状态码无法区分——一个
400可能是 Key 过期,也可能是 prompt 超长,前端不知道该跳登录页还是提示用户缩短输入 - 错误码在 HTTP 状态码之下加一层业务级标识,让前端能根据 code 走不同处理分支
- 错误码按出错位置分五类:认证(AUTH)、参数(PARAM)、模型(MODEL)、限流(RATE_LIMIT)、系统(SYSTEM)
- 字符串编码比数字编码可读性好,适合面向开发者的 API
- 错误码需要文档、国际化、版本管理三件事一起跟上,否则会变成没人敢改的遗留代码
1. 从一个真实的错误响应开始
调用 AI 接口时,前端收到的错误响应通常长这样:
// POST /api/ai/chat 返回 400
{ "error": "Bad Request" }前端只知道「请求出错了」。但原因可能是 API Key 没传、model 名称不存在、prompt 超出上下文限制、额度用完、模型服务不可用。这五种情况前端的处理方式完全不同。
一个 400 Bad Request 无法区分这些场景。HTTP 状态码描述的是协议层面的结果,不是业务语义。
加上错误码之后,同一个 400 可以拆成不同的业务含义:
// 场景一:API Key 缺失
{ "error": { "code": "AUTH_API_KEY_MISSING", "message": "缺少 API Key" } }
// 场景二:模型名称不合法
{ "error": { "code": "PARAM_INVALID_MODEL", "message": "model 参数不合法" } }
// 场景三:超过上下文长度,附带结构化数据
{ "error": { "code": "PARAM_PROMPT_TOO_LONG", "message": "prompt 超出上下文限制", "details": { "maxTokens": 128000, "actualTokens": 156000 } } }前端拿到 error.code 就能直接 switch 走不同分支,不需要解析 message 文本。
2. AI API 的错误分类
按出错位置分五类:
认证错误(AUTH) — 请求到达业务逻辑之前就出了问题:API Key 缺失/过期/被吊销、权限不足、签名校验失败。
参数错误(PARAM) — 请求到了业务层,但参数不合法:必填字段缺失、类型不匹配、值超出范围、prompt 超长、messages 格式错误。
模型错误(MODEL) — 参数没问题,模型调用出了问题:模型不存在或已下线、上游超时、输出触发安全审核、流式响应中途断开。
限流错误(RATE_LIMIT) — 频率或用量超配额:RPM 超限、TPM 超限、月度额度用完、并发数超限。
系统错误(SYSTEM) — 后端自身问题:数据库连接失败、内部服务超时、未捕获异常、配置缺失。
分类确定之后,下一步是设计编码格式。
3. 错误码格式:数字编码 vs 字符串编码
数字编码
{ "error": { "code": 40101, "message": "API Key 缺失" } }按位分段,第一位表示类别,后四位表示具体错误。1xxxx 是认证,2xxxx 是参数,3xxxx 是模型,4xxxx 是限流,5xxxx 是系统。优点是短,缺点是看到 30002 需要查表才知道含义。
字符串编码
{ "error": { "code": "MODEL_SERVICE_UNAVAILABLE", "message": "模型服务暂时不可用" } }直接用英文大写命名,前缀表示分类。看到 RATE_LIMIT_TPM_EXCEEDED 就知道是 Token 用量超限,日志排查时也能直接读懂。
怎么选
面向外部的公开 API 用字符串编码更友好,开发者看到错误码就能理解。内部高频微服务调用可以考虑数字编码的体积优势。大多数 Hono 项目选字符串编码更省心。
4. 错误码与 HTTP 状态码的配合
错误码不是替代 HTTP 状态码,而是在它之上补充业务语义。HTTP 状态码告诉客户端和基础设施请求的结果类别,错误码告诉应用层具体是哪种业务错误。
一个 400 下可以挂多种错误码,而不同分类的错误码也可能对应不同的 HTTP 状态码:
// src/errors/error-mapping.ts
// 错误码 -> HTTP 状态码映射
export const codeToHttpStatus: Record<string, number> = {
// 认证 -> 401/403
AUTH_API_KEY_MISSING: 401,
AUTH_API_KEY_INVALID: 401,
AUTH_API_KEY_EXPIRED: 401,
AUTH_PERMISSION_DENIED: 403,
// 参数 -> 400
PARAM_REQUIRED_FIELD_MISSING: 400,
PARAM_INVALID_TYPE: 400,
PARAM_VALUE_OUT_OF_RANGE: 400,
PARAM_PROMPT_TOO_LONG: 400,
// 模型 -> 视情况而定
MODEL_NOT_FOUND: 404,
MODEL_SERVICE_UNAVAILABLE: 503,
MODEL_CONTENT_FILTERED: 422,
// 限流 -> 429
RATE_LIMIT_RPM_EXCEEDED: 429,
RATE_LIMIT_TPM_EXCEEDED: 429,
RATE_LIMIT_QUOTA_EXHAUSTED: 429,
// 系统 -> 500/504
SYSTEM_INTERNAL_ERROR: 500,
SYSTEM_DATABASE_ERROR: 500,
SYSTEM_UPSTREAM_TIMEOUT: 504,
}MODEL_NOT_FOUND 对应 404,MODEL_SERVICE_UNAVAILABLE 对应 503——同样模型错误,HTTP 状态码不同。限流统一 429,网关和 CDN 能识别并做相应处理。
有了映射表,自定义错误类和统一处理函数可以这样写:
// src/errors/ai-error.ts
import type { ErrorCodes } from './ai-error-codes'
export class AIError extends Error {
constructor(
public code: keyof typeof ErrorCodes,
message: string,
public details?: Record<string, unknown>
) {
super(message)
this.name = 'AIError'
}
}// src/errors/error-handler.ts
import type { Context } from 'hono'
import { AIError } from './ai-error'
import { codeToHttpStatus } from './error-mapping'
export function handleError(err: unknown, c: Context) {
if (err instanceof AIError) {
const status = codeToHttpStatus[err.code] ?? 500
return c.json(
{ error: { code: err.code, message: err.message, ...(err.details ? { details: err.details } : {}) } },
status
)
}
// 未预期的异常:不暴露内部细节
console.error('[UnhandledError]', err)
return c.json({ error: { code: 'SYSTEM_INTERNAL_ERROR', message: '服务器内部错误' } }, 500)
}在 Hono 里用 app.onError() 注册:
// src/index.ts
import { Hono } from 'hono'
import { handleError } from './errors/error-handler'
const app = new Hono()
app.onError((err, c) => handleError(err, c))
export default app5. 在路由里使用错误码
路由里的错误抛出变得很直接,每个 throw new AIError(...) 携带错误码、可读描述和可选的上下文数据:
// src/routes/ai-chat.ts
import { Hono } from 'hono'
import { AIError } from '../errors/ai-error'
const chatApp = new Hono()
chatApp.post('/chat', async (c) => {
const body = await c.req.json()
const apiKey = c.req.header('X-API-Key')
if (!apiKey) {
throw new AIError('AUTH_API_KEY_MISSING', '缺少 API Key')
}
if (!body.model) {
throw new AIError('PARAM_REQUIRED_FIELD_MISSING', 'model 参数不能为空')
}
if (!body.messages || !Array.isArray(body.messages)) {
throw new AIError('PARAM_INVALID_MESSAGES_FORMAT', 'messages 必须是数组')
}
// 校验 prompt 长度,附带 details
const totalLength = body.messages.reduce((sum: number, m: { content: string }) => sum + m.content.length, 0)
if (totalLength > 128000) {
throw new AIError('PARAM_PROMPT_TOO_LONG', 'prompt 超出上下文限制', { maxTokens: 128000, actualLength: totalLength })
}
// 检查配额
const quota = await checkUserQuota(apiKey)
if (quota.remaining <= 0) {
throw new AIError('RATE_LIMIT_QUOTA_EXHAUSTED', '本月额度已用完', { plan: quota.plan, resetAt: quota.resetAt })
}
try {
return c.json(await callModel(body.model, body.messages))
} catch (err) {
if (err instanceof TimeoutError) {
throw new AIError('MODEL_SERVICE_UNAVAILABLE', '模型响应超时,请稍后重试')
}
throw err
}
})6. 错误码文档
错误码需要文档让调用方知道含义和处理方式。实用的做法是用 TypeScript JSDoc 注释作为文档的单一来源:
// src/errors/ai-error-codes.ts
export const ErrorCodes = {
/** API Key 未在请求头中传入 */
AUTH_API_KEY_MISSING: 'AUTH_API_KEY_MISSING',
/** API Key 格式不合法或已被吊销 */
AUTH_API_KEY_INVALID: 'AUTH_API_KEY_INVALID',
/** API Key 已过期 */
AUTH_API_KEY_EXPIRED: 'AUTH_API_KEY_EXPIRED',
/** 无权调用目标模型或接口 */
AUTH_PERMISSION_DENIED: 'AUTH_PERMISSION_DENIED',
/** 必填字段未传入 */
PARAM_REQUIRED_FIELD_MISSING: 'PARAM_REQUIRED_FIELD_MISSING',
/** 字段类型与预期不符 */
PARAM_INVALID_TYPE: 'PARAM_INVALID_TYPE',
/** 字段值超出合法范围 */
PARAM_VALUE_OUT_OF_RANGE: 'PARAM_VALUE_OUT_OF_RANGE',
/** prompt 长度超出模型上下文窗口 */
PARAM_PROMPT_TOO_LONG: 'PARAM_PROMPT_TOO_LONG',
/** messages 数组结构不合法 */
PARAM_INVALID_MESSAGES_FORMAT: 'PARAM_INVALID_MESSAGES_FORMAT',
/** 请求的模型不存在或已下线 */
MODEL_NOT_FOUND: 'MODEL_NOT_FOUND',
/** 上游模型服务不可用或超时 */
MODEL_SERVICE_UNAVAILABLE: 'MODEL_SERVICE_UNAVAILABLE',
/** 输出内容触发安全审核策略 */
MODEL_CONTENT_FILTERED: 'MODEL_CONTENT_FILTERED',
/** 流式响应中途断开 */
MODEL_STREAM_INTERRUPTED: 'MODEL_STREAM_INTERRUPTED',
/** 每分钟请求次数超限 */
RATE_LIMIT_RPM_EXCEEDED: 'RATE_LIMIT_RPM_EXCEEDED',
/** 每分钟 Token 消耗超限 */
RATE_LIMIT_TPM_EXCEEDED: 'RATE_LIMIT_TPM_EXCEEDED',
/** 月度调用额度用完 */
RATE_LIMIT_QUOTA_EXHAUSTED: 'RATE_LIMIT_QUOTA_EXHAUSTED',
/** 并发请求数超限 */
RATE_LIMIT_CONCURRENT_EXCEEDED: 'RATE_LIMIT_CONCURRENT_EXCEEDED',
/** 未预期的内部异常 */
SYSTEM_INTERNAL_ERROR: 'SYSTEM_INTERNAL_ERROR',
/** 数据库连接或查询失败 */
SYSTEM_DATABASE_ERROR: 'SYSTEM_DATABASE_ERROR',
/** 上游服务调用超时 */
SYSTEM_UPSTREAM_TIMEOUT: 'SYSTEM_UPSTREAM_TIMEOUT',
/** 环境变量或配置项缺失 */
SYSTEM_CONFIG_MISSING: 'SYSTEM_CONFIG_MISSING',
} as const
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]JSDoc 注释会被编辑器识别,悬停就能看到含义。对外发布的文档按分类整理成表格,每个错误码附带 HTTP 状态码和处理建议:
| 错误码 | HTTP | 含义 | 处理建议 |
|---|---|---|---|
AUTH_API_KEY_MISSING | 401 | Key 未传入 | 在请求头中传入 X-API-Key |
PARAM_PROMPT_TOO_LONG | 400 | prompt 超长 | 缩短输入或换更大上下文的模型 |
RATE_LIMIT_QUOTA_EXHAUSTED | 429 | 月度额度用完 | 升级套餐或等待下月重置 |
7. 错误码国际化
面向多语言用户的 API,message 字段需要做国际化。把翻译放在独立的 i18n 文件里,错误码本身不携带文案:
// src/i18n/error-messages.ts
export const errorMessages: Record<string, Record<string, string>> = {
zh: {
AUTH_API_KEY_MISSING: '缺少 API Key,请在请求头中传入 X-API-Key',
PARAM_PROMPT_TOO_LONG: 'prompt 长度超出模型上下文限制',
RATE_LIMIT_QUOTA_EXHAUSTED: '本月调用额度已用完',
MODEL_SERVICE_UNAVAILABLE: '模型服务暂时不可用,请稍后重试',
// ...
},
en: {
AUTH_API_KEY_MISSING: 'API Key is missing. Please provide it in the X-API-Key header.',
PARAM_PROMPT_TOO_LONG: 'Prompt length exceeds the model context limit.',
RATE_LIMIT_QUOTA_EXHAUSTED: 'Monthly quota exhausted.',
MODEL_SERVICE_UNAVAILABLE: 'Model service is temporarily unavailable.',
// ...
},
}错误处理函数根据 Accept-Language 选择文案:
// src/errors/error-handler.ts
import { errorMessages } from '../i18n/error-messages'
function getLocale(c: Context): string {
const lang = (c.req.header('Accept-Language') ?? 'en').split(',')[0]?.split('-')[0] ?? 'en'
return lang in errorMessages ? lang : 'en'
}
// 在 handleError 里:
const locale = getLocale(c)
const message = errorMessages[locale]?.[err.code] ?? errorMessages['en']?.[err.code] ?? err.message前端不需要做翻译,后端直接返回对应语言的 message。如果前端想自己做翻译,也可以通过 error.code 匹配前端 i18n 资源。details 字段是结构化数据,不参与翻译。
8. 错误码版本管理
错误码发布给外部调用方后,不能随便删除或修改含义。处理演进需要几条规则:
新增 — 随时可以加,不影响已有调用方。
废弃 — 标记 @deprecated,在下一个大版本前不删除:
// src/errors/ai-error-codes.ts
export const ErrorCodes = {
RATE_LIMIT_RPM_EXCEEDED: 'RATE_LIMIT_RPM_EXCEEDED',
RATE_LIMIT_TPM_EXCEEDED: 'RATE_LIMIT_TPM_EXCEEDED',
/** @deprecated 已拆分为 RPM/TPM 两个,将在 v2.0 移除 */
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
// ...
} as const修改含义 — 不允许。语义需要调整时新建错误码,旧的标记废弃。
版本传递 — 在响应头里带版本号,让调用方知道当前用的是哪套错误码:
// src/middleware/api-version.ts
import type { MiddlewareHandler } from 'hono'
export const apiVersion: MiddlewareHandler = async (c, next) => {
await next()
c.res.headers.set('X-API-Version', '1.2.0')
}错误码文档按版本维护,changelog 记录每次新增或废弃了哪些码。调用方升级时可以对照 changelog 知道改了什么。
总结
回顾一下这篇的要点:
- 错误码在 HTTP 状态码之下提供业务级标识,让前端按 code 走不同处理逻辑
- 五类错误:认证(AUTH)、参数(PARAM)、模型(MODEL)、限流(RATE_LIMIT)、系统(SYSTEM)
- 字符串编码可读性好,适合面向开发者的 API;数字编码适合内部高频微服务
- 错误码与 HTTP 状态码通过映射表关联,
app.onError()注册统一处理 - JSDoc 注释作为文档单一来源,对外整理成带处理建议的表格
- 国际化通过独立 i18n 文件实现,后端按
Accept-Language返回对应语言 message - 版本管理:可以新增、可以废弃、不能改含义,用
X-API-Version响应头和 changelog 同步变更
下一篇我们把这套错误码规范用到 AI Chat 接口的完整实现里。
所属分组
12-Hono开发AI API服务