幂等与重试
用 Idempotency-Key 保证同一个请求只生成和计费一次,并在网络中断、超时或结果未知时安全地取回结果。
模型调用会产生费用,而且生成结果每次都不同。网络在请求途中中断时,你无法知道平台是否已经收到请求。Idempotency-Key 让你可以放心地“再问一次”:同一个键在同一把密钥下只会执行一次,之后可以随时用它取回结果。
前提:已创建密钥并把它设置为环境变量 TOKENTOSEA_API_KEY。示例使用 Python requests 或 Node.js 18+ 内置 fetch。
工作方式#
- 在每个
POST请求上带Idempotency-Key请求头。 - 平台按「密钥 + 幂等键」识别请求。同一把密钥下,一个键只对应一次调用;不同密钥的键互不影响。
- 响应头
X-Request-Id与X-TokenToSea-Request-Id回显这个键。 - 不带这个请求头时,平台自动生成
tts-auto-<UUID>并通过上述响应头返回。但如果连接在响应头到达前就断开,你将无法得知这个键,所以生产环境请始终自己生成并保存。
格式#
| 规则 | 值 |
|---|---|
| 长度 | 16–128 个字符 |
| 允许的字符 | 字母、数字、_、.、:、- |
| 推荐 | 每个业务操作一个 UUID v4,例如 3f0c2a1e-8b7d-4c55-9a6e-2d1f0b9e7c44 |
| 不合规时 | 400 idempotency_key_invalid |
不要在幂等键中放入密钥、手机号、邮箱等敏感信息:它会出现在响应头和你的日志中。
重复请求时会发生什么#
| 场景 | 平台行为 |
|---|---|
| 同一个键、相同的请求,原调用已完成 | 不重新生成、不重复计费,直接返回原结果。原调用是流式的,会一次性返回完整的 SSE 文本;原调用失败的,返回 422 与结果信封(见下文)。 |
| 同一个键、相同的请求,原调用仍在处理 | 返回 202 与结果信封,按其中的 status 继续查询。 |
| 同一个键、相同的型号、不同的请求体 | 409(错误码 main_wallet_authorization_failed)。原调用不受影响。 |
同一个键、不同的 model | 502 execution_result_pending:这个键已绑定原调用的型号。原调用不受影响;此时用这个键查询到的是原调用的结果,不是这次请求的。 |
| 同一个键,但找不到可返回的结果 | 409 request_already_accepted。用 GET /v1/results/{键} 查询原调用状态。 |
| 不同的键 | 视为全新的调用,会再次生成并计费。 |
幂等键会被永久占用
一个键一旦被受理(产生了调用记录),就不能再用于另一个请求,即使原调用失败或结果已过期。修改请求后重新发起时,请生成新键。
什么时候重发,什么时候查询#
判断依据只有一个:平台是否已经给出明确结果。
| 你收到的 | 结果是否明确 | 正确做法 |
|---|---|---|
200 和响应体 | 明确:成功 | 使用结果 |
4xx 和错误体(下面两种除外) | 明确:这次没有执行,也没有计费 | 按错误码修正请求或等待条件满足,然后用新键发起 |
422 和结果信封(含 run_id,status 为 failed) | 明确:原调用已失败 | 按 billing_status 判断是否扣费,见下文 |
409 request_already_accepted / request_already_dispatched | 原调用已受理 | 用原键查询 |
202 | 未完成 | 用原键查询,直到终态 |
任何 5xx(包括 502 和 503),或流式响应以 event: error 结束 | 未知 | 用原键查询,不要换键重发 |
| 网络错误、连接重置、客户端超时 | 未知 | 用原键查询 /v1/results/{键} |
503 同样可能出现在厂商已经返回结果之后:例如结算服务暂时不可用时,平台会保存厂商结果、稍后在后台完成结算,但这次请求返回 503。换新键重发一个结果未知的请求,可能导致同一任务被生成并计费两次。
失败的结果信封#
422 响应体如果是结果信封(含 run_id),说明同一个键的原调用已经结束且失败,不是本次请求被拒绝:
billing_status | 含义 | 做法 |
|---|---|---|
failed | 未扣费,预留已释放 | 修正后用新键发起 |
succeeded | 已扣费,但结果无法交付(error.code 为 paid_result_unavailable) | 不要重发,带上 run_id 与幂等键联系支持 |
取回结果#
GET /v1/results/{Idempotency-Key}
Authorization: Bearer <创建该请求的同一把密钥>返回结果信封,status 为 succeeded 时 output 中是原始响应。查询规则:
- 必须使用发起请求的同一把密钥;换一把密钥查询返回
404 result_not_found。 - 结果在最后一次更新后保留约 31 天。
- 平台在把请求发往厂商之前就会建立结果记录。因此查询返回
404 result_not_found时,这个键对应的请求一定没有发往厂商:可能从未到达平台,也可能在受理前被拒绝。此时用原键原样重发是安全的;如果原请求其实正在受理,平台会识别出同一个键,不会重复执行。 - 原样重发后收到
409 request_already_accepted,而查询持续 1 分钟以上仍返回404:这个键已被占用,但请求从未发往厂商(例如受理后因价格更新被撤回)。此时改用新键发起。 status停留在planned超过 5 分钟:请求没有发往厂商,不会生成也不会计费,同样改用新键发起。
完整示例:可安全重试的调用#
下面的函数在结果未知时不重新生成,而是改为按幂等键查询;只有确认请求没有发往厂商时,才重发:平台没有记录时用原键,键已被占用时用新键。
import os
import time
import uuid
import requests
API = "https://api.tokentosea.com/v1"
AUTH = {"Authorization": f"Bearer {os.environ['TOKENTOSEA_API_KEY']}"}
FINAL = {"succeeded", "failed", "needs_review"}
ACCEPTED = {"request_already_accepted", "request_already_dispatched"}
def fetch_result(key, deadline, grace=0):
"""按幂等键查询直到终态,或 planned 超过 5 分钟;持续 grace 秒没有记录时返回 None。"""
missing_since = planned_since = None
while time.time() < deadline:
try:
r = requests.get(f"{API}/results/{key}", headers=AUTH, timeout=30)
except requests.RequestException:
r = None # 查询本身失败:稍后再查
if r is not None and r.status_code == 404:
missing_since = missing_since or time.time()
if time.time() - missing_since >= grace:
return None
elif r is not None and r.ok:
envelope = r.json()
if envelope["status"] in FINAL:
return envelope
if envelope["status"] != "planned":
planned_since = None
elif planned_since is None:
planned_since = time.time()
elif time.time() - planned_since > 300:
return envelope # 没有发往厂商
time.sleep(5)
raise TimeoutError(f"结果仍在处理,稍后用同一幂等键查询:{key}")
def call(path, body, attempts=3):
key = str(uuid.uuid4()) # 生产环境请先把 key 与业务记录一起持久化
deadline = time.time() + 30 * 60
for _ in range(attempts):
code = None
try:
r = requests.post(f"{API}{path}", json=body, headers={**AUTH, "Idempotency-Key": key}, timeout=(10, 1300))
except requests.RequestException:
r = None # 网络错误或超时:结果未知
if r is not None:
if r.status_code == 200:
return {"status": "succeeded", "output": r.json()}
data = r.json() if "json" in r.headers.get("content-type", "") else {}
if r.status_code == 422 and "run_id" in data:
return data # 原调用已失败;billing_status 为 succeeded 时已扣费
code = data.get("error", {}).get("code")
if r.status_code < 500 and r.status_code != 202 and code not in ACCEPTED:
r.raise_for_status() # 明确拒绝、没有执行:修正请求后用新键发起
# 202、5xx、原调用已受理、网络错误:结果未知,先按原键查询
envelope = fetch_result(key, deadline, grace=60 if code in ACCEPTED else 0)
if envelope is not None and envelope["status"] != "planned":
return envelope
if envelope is not None or code in ACCEPTED:
key = str(uuid.uuid4()) # 键已被占用,但请求从未发往厂商:换新键
# 否则平台没有这个键的记录:下一轮用原键原样重发
raise RuntimeError(f"多次尝试仍无结果,幂等键:{key}")
result = call("/chat/completions", {
"model": "qwen3.8-flash",
"messages": [{"role": "user", "content": "你好"}],
"max_tokens": 128,
})
print(result["status"], result.get("output"))import { randomUUID } from 'node:crypto';
const API = 'https://api.tokentosea.com/v1';
const AUTH = { Authorization: `Bearer ${process.env.TOKENTOSEA_API_KEY}` };
const FINAL = new Set(['succeeded', 'failed', 'needs_review']);
const ACCEPTED = new Set(['request_already_accepted', 'request_already_dispatched']);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** 按幂等键查询直到终态,或 planned 超过 5 分钟;持续 graceMs 没有记录时返回 null。 */
async function fetchResult(key, deadline, graceMs = 0) {
let missingSince = null;
let plannedSince = null;
while (Date.now() < deadline) {
let response = null;
try {
response = await fetch(`${API}/results/${key}`, { headers: AUTH, signal: AbortSignal.timeout(30_000) });
} catch {
// 查询本身失败:稍后再查
}
if (response?.status === 404) {
missingSince ??= Date.now();
if (Date.now() - missingSince >= graceMs) return null;
} else if (response?.ok) {
const envelope = await response.json();
if (FINAL.has(envelope.status)) return envelope;
plannedSince = envelope.status === 'planned' ? plannedSince ?? Date.now() : null;
if (plannedSince !== null && Date.now() - plannedSince > 300_000) return envelope; // 没有发往厂商
}
await sleep(5_000);
}
throw new Error(`结果仍在处理,稍后用同一幂等键查询:${key}`);
}
export async function call(path, body, attempts = 3) {
let key = randomUUID(); // 生产环境请先把 key 与业务记录一起持久化
const deadline = Date.now() + 30 * 60_000;
for (let i = 0; i < attempts; i += 1) {
let code = null;
let response = null;
try {
response = await fetch(`${API}${path}`, {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify(body),
signal: AbortSignal.timeout(1_300_000),
});
} catch {
// 网络错误或超时:结果未知
}
if (response) {
if (response.status === 200) return { status: 'succeeded', output: await response.json() };
const payload = await response.json().catch(() => ({}));
if (response.status === 422 && payload.run_id) return payload; // 原调用已失败;billing_status 为 succeeded 时已扣费
code = payload.error?.code ?? null;
if (response.status < 500 && response.status !== 202 && !ACCEPTED.has(code)) {
// 明确拒绝、没有执行:修正请求后用新键发起
throw new Error(`HTTP ${response.status} ${code ?? ''}`);
}
}
// 202、5xx、原调用已受理、网络错误:结果未知,先按原键查询
const envelope = await fetchResult(key, deadline, ACCEPTED.has(code) ? 60_000 : 0);
if (envelope && envelope.status !== 'planned') return envelope;
if (envelope || ACCEPTED.has(code)) key = randomUUID(); // 键已被占用,但请求从未发往厂商:换新键
// 否则平台没有这个键的记录:下一轮用原键原样重发
}
throw new Error(`多次尝试仍无结果,幂等键:${key}`);
}
const result = await call('/chat/completions', {
model: 'qwen3.8-flash',
messages: [{ role: 'user', content: '你好' }],
max_tokens: 128,
});
console.log(result.status, result.output);注意:示例针对返回 JSON 的接口。语音合成成功时返回音频二进制,流式请求成功时返回 SSE,读取方式见语音合成与流式输出。
特殊情况#
| 接口 | 说明 |
|---|---|
WS /v1/realtime | 在握手请求头中带 Idempotency-Key。同一个键的第二次连接会被拒绝。断线后查询 /v1/results/{键},不要用同一个键重连。 |
POST /v1/files、POST /v1/audio/voices | 同样按幂等键去重:同键同内容返回原文件或音色,同键不同内容返回 409 idempotency_conflict。这两个接口不产生 /v1/results 记录。 |
异步向量(text-embedding-async-*) | 同键不同内容返回 409 idempotency_conflict。 |
| 工具 API | 使用请求体中的 requestKey 字段实现同样的保证,见工具 API。 |
SDK 中的用法#
OpenAI 与 Anthropic SDK 都不会自动发送 Idempotency-Key。请通过 extra_headers(Python)或请求选项 headers(Node.js)传入,并关闭 SDK 自动重试:SDK 默认会在网络错误、408、409、429 和 5xx 时自动重发,不带幂等键时每次重发都是一次新的调用。SDK 还会把 202 当作成功响应解析,检测方法见用官方 SDK 接入。