# 异步任务

图片和视频怎么提交、轮询、接收推送和下载，每个状态和字段代表什么，失败时怎么退款。

> https://nezhagate.com/docs/guide/async-jobs

## 哪些接口是异步的

图片和视频生成都以异步任务运行：提交后立刻返回任务号，生成在后台进行。对话接口是同步的，不走任务。

| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/v1/images/generations` | 文生图。立刻返回任务号（HTTP 202）。 |
| POST | `/v1/images/edits` | 图生图。也可以在上一个接口里带 `image` 参数，两者等价。 |
| GET | `/v1/images/jobs/{id}` | 查询图片任务的状态与结果。 |
| POST | `/v1/videos/generations` | 生成视频。立刻返回任务号（HTTP 202）。 |
| GET | `/v1/videos/jobs/{id}` | 查询视频任务的状态与结果。 |

## 流程

1. 提交任务：调用生成接口，立刻得到 HTTP 202 和任务号 `id`（形如 `img_...`，视频任务也用这个前缀）。

2. 等待：用任务号轮询查询接口，或者在提交时带上 `callback_url`，等结果推送过来。

3. 完成：`status` 变成 `succeeded`，结果链接在 `data[0].url`；变成 `failed` 时原因在 `error`，预扣的积分已经退回。

4. 下载：结果链接保留 60 天，需要长期使用就下载到自己的存储。

```
curl https://nezhagate.com/v1/videos/generations \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "seedance-2.0", "prompt": "a paper boat drifting down a rainy street", "duration": 5, "size": "16:9"}'

# HTTP 202
{"id": "img_9c1e...7a", "object": "video.generation.job", "status": "queued", "created": 1791199400, "model": "seedance-2.0"}
```

```
curl https://nezhagate.com/v1/videos/jobs/img_9c1e...7a -H "Authorization: Bearer YOUR_API_KEY"
```

## 任务状态

| 状态 | 含义 |
| --- | --- |
| `queued` | 排队中。返回里带 `queue_position`（前面还有几个任务）和 `eta_seconds`（预计等待秒数）。 |
| `processing` | 生成中。 |
| `succeeded` | 已完成。结果在 `data[0].url`，计费明细在 `usage`。 |
| `failed` | 失败。原因在 `error`，预扣的积分已全额退回。 |

`succeeded` 和 `failed` 是最终状态，之后不会再变。

## 返回字段

| 字段 | 含义 |
| --- | --- |
| `id` | 任务号。查询和 Webhook 都用它。 |
| `object` | 图片任务是 `image.generation.job`，视频任务是 `video.generation.job`。 |
| `status` | `queued` / `processing` / `succeeded` / `failed`。 |
| `created` | 提交时间，Unix 秒。 |
| `model` | 提交时用的模型 ID。 |
| `queue_position` | 只在 `queued` 时出现：前面还有几个任务。 |
| `eta_seconds` | 只在 `queued` 时出现：预计还要等多少秒开始。 |
| `data[].url` | 只在 `succeeded` 时出现：结果文件的链接（图片或 mp4），保留 60 天。 |
| `usage` | 只在 `succeeded` 时出现：计费明细。图片是 `images`（实际交付张数）和 `resolution`，视频是 `clips`。 |
| `error` | 只在 `failed` 时出现：`message`、`type`、`code`、`param`，按 `code` 判断原因。 |

```
{
  "id": "img_9c1e...7a",
  "object": "video.generation.job",
  "status": "succeeded",
  "created": 1791199400,
  "model": "seedance-2.0",
  "data": [{"url": "https://img.nezhagate.com/i/5d41402abc....mp4"}],
  "usage": {"clips": 1, "model": "seedance-2.0"}
}
```

```
{
  "id": "img_9c1e...7a",
  "object": "video.generation.job",
  "status": "failed",
  "created": 1791199400,
  "model": "seedance-2.0",
  "error": {"message": "...", "type": "invalid_request_error", "code": "content_policy", "param": null}
}
```

## 轮询建议

- 图片每 2–3 秒查一次，视频每 5–10 秒查一次。查询不收费。

- 拿到 `succeeded` 或 `failed` 就停止轮询。

- 给自己的程序设一个总等待时间：图片一般几十秒到几分钟，视频从一两分钟到一个多小时不等（长视频、排队时更久），时长参考各模型文档。超过时间也别重新提交，任务还在，稍后接着查。

- 任务属于提交它的账号：同一账号下的任何一把 Key 都能查询，别的账号查询会返回 404 `job_not_found`。任务记录保留 3 天，过期后查询同样返回 404，但结果链接仍可使用到 60 天。

- 提交请求如果在你这边超时、没拿到任务号，先到控制台的调用日志里看任务是否已经创建，再决定要不要重新提交，避免重复扣费。

## 用 Webhook 代替轮询

提交时加上 `callback_url`，任务一结束（成功或失败），网关就把和查询接口完全一样的结果推送到这个地址，带签名，失败会自动重试。也可以在控制台「设置 → Webhook」里配一个账户级地址。

```
{"model": "seedance-2.0", "prompt": "...", "duration": 5, "callback_url": "https://example.com/webhooks/nezhagate"}
```

[Webhook 文档 →](https://nezhagate.com/docs/webhooks)

## 计费

- 提交时按价格预扣积分；余额不够会直接返回 402，任务不会创建。

- 成功时按实际交付结算：图片按交付的张数，视频按下单的时长或条数（和预扣一致），多预扣的部分自动退回；失败时预扣的积分全额退回。

- 查询任务、接收推送都不收费。

## 结果保存多久

`data[0].url` 是我们存储上的链接，保留 60 天后自动删除。需要长期使用，请下载后存到你自己的存储或 CDN。

## 任务为什么会失败

失败的任务都已全额退款。下面是任务结果里最常见的几个错误码：

| code | 什么时候出现 | 怎么办 |
| --- | --- | --- |
| `content_policy` | 视频：提示词、参考素材，或生成的画面 / 声音没通过内容审核（声音被拦时 `param` 为 `audio`）。 | 改写提示词或更换素材后再提交；原样重交还会被拒。 |
| `moderation_blocked` | 图片：模型方的安全审核拒绝了提示词或参考图（也可能是 `content_policy_violation`）。 | 改写提示词或更换参考图；原样重交通常还会被拒。 |
| `render_failed` | 视频：这次没有生成出成片，多为偶发。 | 原样重新提交通常就能成功。 |
| `invalid_material` | 视频：上游认为输入素材有问题。这是一个笼统的报错，也常是偶发。 | 先原样重交；反复出现再检查参考素材和提示词长度。 |
| `render_timeout` | 视频：排队或渲染时间过长，上游放弃了这一单。 | 重新提交。 |
| `wait_timeout` | 视频：等满了这条线路允许的最长时间，仍然没有出片。 | 重新提交。 |
| `upstream_unstable` | 视频：渲染过程中连接中断。 | 重新提交。 |
| `result_fetch_failed` | 视频已经生成，但我们反复下载或保存成片都没有成功。 | 重新提交。 |
| `invalid_request` | 视频：线路不接受这组参数（例如时长、比例、清晰度的组合）。 | 按 `message` 调整参数后再提交。 |
| `upstream_error` | 其他失败：所有线路都试过仍然失败、排队 15 分钟还没开始、上游拒绝但没有说明原因等，`message` 写明具体情况。 | 通常重新提交就能成功。 |

[完整错误码列表 →](https://nezhagate.com/docs/guide/errors)

## 完整示例

提交、轮询、下载一条龙，包含总等待时间和失败处理。把 YOUR_API_KEY 换成你的 Key 就能运行。

```
import time
import requests

BASE = "https://nezhagate.com/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}

def run_job(kind, payload, every=5, give_up=5400):
    """kind is "images" or "videos". Returns the finished job (succeeded or failed)."""
    job = requests.post(f"{BASE}/{kind}/generations", headers=HEADERS, json=payload, timeout=120)
    job.raise_for_status()                       # 4xx here: fix the request; nothing was billed
    job_id = job.json()["id"]
    deadline = time.time() + give_up
    while time.time() < deadline:
        time.sleep(every)
        r = requests.get(f"{BASE}/{kind}/jobs/{job_id}", headers=HEADERS, timeout=30).json()
        if r["status"] in ("succeeded", "failed"):
            return r
    raise TimeoutError(f"{job_id} still running; poll it again later, it is not lost")

job = run_job("videos", {"model": "seedance-2.0", "prompt": "a paper boat drifting down a rainy street",
                         "duration": 5, "size": "16:9"})
if job["status"] == "succeeded":
    url = job["data"][0]["url"]
    with open("clip.mp4", "wb") as f:            # keep your own copy: links expire after 60 days
        f.write(requests.get(url, timeout=300).content)
else:
    print(job["error"]["code"], job["error"]["message"])
```
