API 返回 202 却没有结果?异步任务状态、轮询与重复提交排查
区分任务受理、状态查询成功与执行终态,保存原任务身份,设置查询预算并处理 404、结果过期和提交结果未知;用本地状态样本验证,避免轮询失败后重复创建任务。
API 已返回 HTTP 202,但正文只有任务编号,没有生成结果;接着查询接口一直返回 200,页面却仍然转圈。先不要再次提交原任务:提交成功、状态查询成功和任务执行成功,是三个不同事实。
本文适用于明确提供异步任务协议的 API,包括文档处理、批量作业等场景,不假定任何模型或中转站都支持它。资料于 2026-11-09 核对;代码只在本机使用人工状态样本验证,没有真实平台或付费 API 调用。
1. 202 不是最终结果,也不是一条稍后会改成 200 的连接
RFC 9110 第 15.3.3 节说明,202 表示请求已被接受处理,但处理尚未完成,最终甚至可能不执行。[1] 原先那条 HTTP 响应结束后,不会自动重新发送一个“最终状态码”。客户端需要按接口协议查询状态、读取结果,或等待已配置的通知。
先从官方文档找全四项:提交入口和方法、任务标识、状态查询入口、终态与结果获取方式。Microsoft 的异步请求—应答设计使用 Location 指向状态资源,并用 Retry-After 提示后续查询间隔;这是设计模式,不是所有 202 响应都必须包含这两个头。[2]
| 收到的东西 | 可以确认 | 还不能确认 |
|---|---|---|
| 提交返回 202 和任务 ID | 此接口声明已经受理 | 已开始运行、必然成功、不会收费 |
| 状态查询返回 200 | 这次查询成功取得表示 | 表示中的任务已完成 |
| 状态字段为成功终态 | 服务端声明任务完成 | 下载地址仍有效、结果满足业务要求 |
| 客户端等待预算用完 | 当前观察过程结束 | 远端任务失败或被取消 |
如果文档只有同步响应,没有任务资源,不要自行拼出 /jobs 或 /status 路径。只有请求编号也不代表它可以作为任务 ID 查询;先确认各编号用途。
2. 保存一次提交的身份,再查同一任务
把业务操作编号、实际提交尝试编号、平台请求编号和任务 ID 分开保存。任务 ID 还应关联服务商、账户或项目与环境;同样的字符串不一定在不同环境里指向同一个任务。需要恢复页面时,从自己的受控存储恢复这份关联,不能只保留加载动画。
例如,一个教学协议可能返回任务 job_demo,后续快照依次为 queued、running、succeeded。queued 与 running 属于非终态;failed 与 canceled 也属于终态,但不是成功。字段名及大小写全部由具体协议决定,有的平台还提供过期或部分成功状态,不能把未认识的状态都当作“继续等”。
进入查询前还要核对地址:
- 优先采用已核实协议给出的状态地址,确认 HTTPS、主机、端口、路径以及凭据适用范围。
- Location 可以指向不同主机。只有它属于已批准的协议目标时才访问;不能把原 API Key 自动转发给任意地址或每次重定向的目标。
- 带签名参数的结果链接可能本身就是访问凭据,不应写入公开日志、工单或浏览器截图。
- 状态地址与结果下载地址不一定相同。只按协议执行获取步骤,不把下载重定向当成再次提交 POST 的指令。
状态查询和原始提交应走不同代码路径。查询超时、查询限流或刷新页面,都不应默认调用“创建新任务”。
3. 本地示例:先检查身份和状态,再接收结果
下面仅演示一个自定义协议的快照解释器:id 必须匹配;status 只接受列出的五个值;成功时 result.text 必须是字符串,允许空字符串。空字符串在此只是协议合法,业务是否接受仍需另验。真实平台的适配层必须按其官方字段和终态规则转换,不能直接假定兼容。
class JobProtocolError(ValueError):
pass
def interpret_status(snapshot, expected_id):
if not isinstance(expected_id, str) or not expected_id.strip():
raise ValueError("expected_id must be a non-empty string")
if not isinstance(snapshot, dict):
raise JobProtocolError("NOT_AN_OBJECT")
if snapshot.get("id") != expected_id:
raise JobProtocolError("JOB_ID_MISMATCH")
state = snapshot.get("status")
if not isinstance(state, str):
raise JobProtocolError("INVALID_STATUS")
if state in ("queued", "running"):
return "wait", None
if state in ("failed", "canceled"):
return "stop", state
if state == "succeeded":
result = snapshot.get("result")
if not isinstance(result, dict) or not isinstance(result.get("text"), str):
raise JobProtocolError("INVALID_RESULT")
return "done", result["text"]
raise JobProtocolError("UNKNOWN_STATUS")
函数接收已经解析的字典,不发 HTTP 请求、不读取密钥、不等待,也不创建或取消任务。调用方要先限制响应体大小、检查 HTTP 状态与媒体类型,再解析正文;不要把 HTML 错误页或 HTTP 401 包装成正常任务快照。
wait 表示这份快照仍未完成,并不是授权无限轮询。stop 表示该任务的失败或取消终态,保留状态与脱敏错误信息后结束;不能用另一次提交偷偷替换它。done 只在任务 ID、成功状态和示例结果结构都符合条件时返回。
UNKNOWN_STATUS 应进入协议核查分支。INVALID_RESULT 表示“成功声明与所需结构不一致”,不是模型自动重跑的依据。函数不保存历史状态,不能处理并发更新、乱序响应或多进程恢复;应用仍需按任务身份和本地版本原子提交结果。为避免轮询响应乱序,可让同一任务至多存在一个在途查询。
4. 为查询设置三个独立预算
首先设置单次查询超时,其次设置整个观察过程的总时限,最后设置最大查询次数。等待间隔、网络耗时和错误重试都计入总预算;单次读取超时不能替代整个任务观察期限。
进程内测量经过时间可以使用 time.monotonic():Python 文档说明它不受系统时钟调整影响,但参考点没有业务含义,只能使用读数之差。[3] 跨进程恢复应存储带时区的业务截止时间并重新计算预算,不把旧进程的计时读数当成可移植时间戳。
若协议提供 Retry-After,按其含义安排下一次状态查询。该头可能是非负整秒数,也可能是 HTTP 日期;它不是任务一定在这个时间完成的承诺。[1] 遇到无效值按明确的客户端策略处理,不能解析失败后立即紧密循环。没有该头时使用协议推荐间隔,配合有上限的退避及抖动;不要对大量任务同时每毫秒查询。
假设本地观察预算还剩 20 秒,而服务要求至少等 60 秒再查:应保存任务 ID 和“待后续查询”的状态,结束本轮观察,不能把等待压缩到 20 秒后提前请求。这里的数字只是算例,不是推荐配置。下次恢复继续查原任务,不重建任务。
轮询次数、查询本身是否计费、状态资源保存多久,都要按当前服务条款核实。停止查询仅停止客户端观察;若用户确实要取消远端任务,应走明确支持的取消流程,参见主动取消与后台任务边界。
5. 按故障阶段处理,不把未知写成失败
| 现象 | 建议动作 | 避免的动作 |
|---|---|---|
| 提交时连接断开,未拿到任务 ID | 保留尝试编号,通过已文档化的关联查询、幂等机制或支持渠道确认 | 认为“没有 ID 就一定没创建”并立即重发 |
| 查询返回 401 或 403 | 检查当前账户、项目、环境与状态资源权限 | 换账户后沿用旧任务 ID 猜测结果 |
| 查询返回 404 | 核对地址、标识、可见性与保留期;仅在协议明确允许时等待资源可见 | 一律当成排队,或一律当成从未受理 |
| 查询返回 429 或暂时性 5xx | 在预算内按查询重试策略等待,保留原任务身份 | 将查询重试变成新的提交 |
| 状态长期不变 | 比对提交时间、状态更新时间、服务公告与约定处理时限 | 仅凭一次 running 就无限等候 |
| 成功后结果地址失效 | 查询同一任务是否能重新取得下载入口,核对保留期 | 因下载失败直接重跑整个收费任务 |
Microsoft 的模式文档指出,一些实现会在结果资源创建前返回 404,而专用状态资源可以避免这种歧义。[2] 这不是“所有 404 都可重试”的依据。缺少平台记录也不能证明任务不存在,日志和索引可能有延迟。
只有服务明确支持幂等提交时,才依其键范围、有效期和同键同载荷约束使用该功能。单方面加一个 Idempotency-Key 头不能让服务自动去重。原任务结果未知时,应保留“提交结果待确认”,不能把它改成确定失败以便重新提交。
异步接口还可能同时推送 Webhook。通知和轮询可能报告同一任务的完成,业务落库仍需去重;本文不实现通知验签,相关内容见Webhook 验签与重复处理。任务状态也不等于账单状态,费用核对可参考请求编号与账单对账。
6. 上线前先用假任务验收恢复路径
本次在 Python 3.12.10 上完成 21 项离线检查:排队无结果、运行时含部分结果、成功的中俄文本与空文本、失败和取消终态、错误任务 ID、非法快照与状态、未知状态、成功但结果缺失或类型错误,以及有界的状态序列。
序列 queued → running → succeeded 应只在终态交付结果;达到查询次数上限时仍为 running,应记录 pending,而不是 failed;拿到 failed 后,即使测试队列里还有另一份成功快照,也不继续自动查询。这里验证的是解释器和离线测试驱动,不是网络轮询实现。
部署到自己的客户端前,还应模拟页面刷新、观察预算耗尽、状态资源过期、下载失败、重复通知及多个观察者同时完成。检查恢复后是否继续同一任务、是否只保存一次业务结果。HTTP 超时、重定向凭据处理、持久化竞争和真实计费均未在本例中测试,不应把这些项写成已验收。
需要平台协助时,提供带时区的提交时间、脱敏任务与请求编号、每次查询的状态码、最后的任务状态、是否存在自动重新提交。不要附密钥或带签名的完整 URL;可使用最小复现与脱敏工单模板。
7. 一手资料
- [1] RFC 9110:HTTP Semantics:15.3.3 的 202 Accepted 语义与 10.2.3 的 Retry-After 格式。
- [2] Microsoft Azure Architecture:Asynchronous Request-Reply:状态端点、轮询、终态、结果保留期及提交去重的设计模式,不代表任何第三方平台已实现全部能力。
- [3] Python 3.12:time.monotonic:经过时间测量及参考点限制。
资料核对日期为 2026-11-09。本文自定义的状态字段与示例预算不是产品承诺;接入时以目标接口的当前协议为准。