# 异步任务 (https://hypit.ai/zh/api-reference/jobs/)

> 图片、视频、音乐生成的真实流程——提交、轮询、取产物，以及失败时额度怎么退。

图片、视频和音乐都是**异步**的。创建端点不会等产物：它校验请求、预扣额度、写入一条持久化的
Job，然后立刻返回 `202 Accepted`。之后由你轮询任务状态，成功后再去要带签名的下载链接。

这是整套 API 里最容易让接入方意外的一块，动手写代码之前值得先读完。

```text
POST /v1/images/generations        ─┐
POST /v1/images/edits               │
POST /v1/videos                     ├─► 202 { "id": "job_…", "status": "queued" }
POST /v1/audio/music               ─┘
                                        │
                     GET /v1/jobs/{job_id}          ← 轮询到终态
                                        │
                     GET /v1/jobs/{job_id}/assets   ← 签名 URL，15 分钟有效
```

Chat completions、语音合成和转写**不是** Job，结果直接在响应体里返回。只有上面这三类生成式媒体走
这条链路。

## Job 信封 [#job-信封]

每个创建端点和每次状态查询返回的都是同一个对象：

```json
{
  "id": "job_2f81c0a4d3b57e9016ab24cf",
  "status": "queued",
  "kind": "video",
  "model": "bytedance/seedance-2",
  "progress": 0,
  "created_at": 1787824589,
  "updated_at": 1787824589,
  "finished_at": 0,
  "dispatch_deadline_at": 1787825189,
  "error_code": "",
  "error": ""
}
```

| 字段                     | 说明                              |
| ---------------------- | ------------------------------- |
| `id`                   | 不透明任务 id，前缀 `job_`。你只需要拿住这一个句柄  |
| `status`               | 见下面的状态表                         |
| `kind`                 | `image`、`video` 或 `audio`       |
| `model`                | 你请求的模型名，不是我们实际路由到的上游模型名         |
| `progress`             | `0`–`100`。仅供参考：很多上游只会报 0 然后 100 |
| `created_at`           | Unix 秒                          |
| `updated_at`           | 未设置时省略                          |
| `finished_at`          | Unix 秒，进入终态后出现                  |
| `dispatch_deadline_at` | 任务必须在此之前被投递到上游                  |
| `error_code`、`error`   | 非成功终态时出现，其余情况省略                 |

## 状态机 [#状态机]

```text
queued ──► running ──► succeeded
   │           └─────► failed
   └─► queue_expired
```

| 状态              | 终态    | 含义                                   |
| --------------- | ----- | ------------------------------------ |
| `queued`        | 否     | 已受理、额度已预扣，正在等上游产能                    |
| `running`       | 否     | 上游已接收，正在生成                           |
| `succeeded`     | **是** | 产物已落盘，可以下载                           |
| `failed`        | **是** | 生成失败，或上游成功但没有任何我们能交付的产物              |
| `queue_expired` | **是** | 在 `dispatch_deadline_at` 之前始终没能投递到上游 |

`canceled` 只为解码历史数据保留，新任务不会进入这个状态。

<Callout type="info" title="没有取消接口">
  Job 一旦创建就会跑到终态。关掉客户端、断开连接或停止轮询只会让**你不再观察它**，既不会停止任务，
  也不会因此退款。你在旧资料里看到的任何 cancel 形状的 URL，都只会返回一个普通的 `404`。
</Callout>

## 投递期限 [#投递期限]

`dispatch_deadline_at` 是创建后十分钟。它只约束**排队**这一段：我们必须在这段时间内找到有空闲
产能的上游并把任务交出去。生成本身不受它约束——第 599 秒才进入上游也没问题，长视频在 `running`
状态下跑过这个时间点是常态。

如果期限到了任务还停在 `queued`，它会以 `queue_expired` 终结，并且**预扣的额度全额返还**。这是从
外部唯一能看到的超时。

## 轮询 [#轮询]

面向客户没有 webhook，请轮询 `GET /v1/jobs/{job_id}`：

```bash
curl https://hypit.ai/v1/jobs/job_2f81c0a4d3b57e9016ab24cf \
  -H "Authorization: Bearer $HYPIT_API_KEY"
```

合理的节奏：首次约 5 秒后查，之后每 3–10 秒一次，视频再拉长退避。图片通常几十秒出结果，视频以
分钟计。

```python
import os, time, httpx

BASE = os.environ.get("HYPIT_BASE_URL", "https://hypit.ai/v1")
AUTH = {"Authorization": f"Bearer {os.environ['HYPIT_API_KEY']}"}
TERMINAL = {"succeeded", "failed", "queue_expired"}

def wait_for(job_id: str, timeout: float = 900.0) -> dict:
    deadline = time.monotonic() + timeout
    delay = 3.0
    while True:
        r = httpx.get(f"{BASE}/jobs/{job_id}", headers=AUTH, timeout=30)
        r.raise_for_status()
        job = r.json()
        if job["status"] in TERMINAL:
            return job
        if time.monotonic() > deadline:
            raise TimeoutError(f"{job_id} 仍处于 {job['status']}")
        time.sleep(delay)
        delay = min(delay * 1.4, 15.0)
```

```js
const BASE = process.env.HYPIT_BASE_URL ?? "https://hypit.ai/v1";
const AUTH = { Authorization: `Bearer ${process.env.HYPIT_API_KEY}` };
const TERMINAL = new Set(["succeeded", "failed", "queue_expired"]);

export async function waitFor(jobId, timeoutMs = 900_000) {
  const deadline = Date.now() + timeoutMs;
  let delay = 3_000;
  for (;;) {
    const r = await fetch(`${BASE}/jobs/${jobId}`, { headers: AUTH });
    if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
    const job = await r.json();
    if (TERMINAL.has(job.status)) return job;
    if (Date.now() > deadline) throw new Error(`${jobId} 仍处于 ${job.status}`);
    await new Promise((res) => setTimeout(res, delay));
    delay = Math.min(delay * 1.4, 15_000);
  }
}
```

## 取产物 [#取产物]

`succeeded` 的任务从 assets 路由取产物。产物一律不公开：我们按需为你在**当次请求里**对确切的存储
对象签一个短期 URL。

```bash
curl https://hypit.ai/v1/jobs/job_2f81c0a4d3b57e9016ab24cf/assets \
  -H "Authorization: Bearer $HYPIT_API_KEY"
```

```json
{
  "items": [
    {
      "id": "ast_9c3d1e7a44b0",
      "job_id": "job_2f81c0a4d3b57e9016ab24cf",
      "ordinal": 0,
      "kind": "video",
      "storage": "s3",
      "mime_type": "video/mp4",
      "bytes": 4718592,
      "width": 1920,
      "height": 1080,
      "seconds": 6,
      "url": "https://…?X-Amz-Signature=…",
      "expires_at": 1787825489,
      "created_at": 1787825189
    }
  ]
}
```

| 字段                         | 说明                                    |
| -------------------------- | ------------------------------------- |
| `id`                       | 稳定的产物 id                              |
| `ordinal`                  | 该任务内的序号；图片任务 `n: 3` 会得到序号 0、1、2       |
| `kind`                     | `image`、`video`、`audio` 或 `thumbnail` |
| `storage`                  | 恒为 `s3`                               |
| `width`、`height`、`seconds` | 该媒介已知时出现                              |
| `url`                      | 现签的链接，**有效期 15 分钟**                   |
| `expires_at`               | `url` 失效的 Unix 秒                      |

响应带 `Cache-Control: private, no-store`。&#x2A;*不要持久化 `url`**，请持久化 `job_id`，需要时重新调用
本路由重签。签名从存储的 bucket 与 object key 现场生成，只要产物还在保留期内，重签总是成功。

成功产物从任务进入终态起保留 **30 天**。需要长期保存的请自行下载或转存。

```python
job = wait_for(job_id)
if job["status"] != "succeeded":
    raise RuntimeError(f"{job['status']}: {job.get('error_code')} {job.get('error')}")

assets = httpx.get(f"{BASE}/jobs/{job_id}/assets", headers=AUTH, timeout=30).json()["items"]
for a in assets:
    with httpx.stream("GET", a["url"], timeout=None, follow_redirects=True) as src:
        src.raise_for_status()
        with open(f"out-{a['ordinal']}.{a['mime_type'].split('/')[-1]}", "wb") as f:
            for chunk in src.iter_bytes():
                f.write(chunk)
```

### 模态别名 [#模态别名]

为了可读性，有三对别名指向完全相同的处理逻辑：

| 别名                                     | 等价于                     |
| -------------------------------------- | ----------------------- |
| `GET /v1/videos/{job_id}`              | `GET /v1/jobs/{job_id}` |
| `GET /v1/audio/music/{job_id}`         | `GET /v1/jobs/{job_id}` |
| `GET /v1/audio/music/{job_id}/content` | 同上                      |

音频 `/content` 是给 `curl -L` 用的便利路由。视频下载统一使用按归属校验的 assets 接口，
返回新的签名 URL；任务还没有可交付产物时返回 `409` `job_asset_not_ready`。

```bash
curl https://hypit.ai/v1/jobs/job_2f81c0a4d3b57e9016ab24cf/assets \
  -H "Authorization: Bearer $HYPIT_API_KEY"
```

## 幂等 [#幂等]

在任意创建请求上带 `Idempotency-Key`，重试就变安全了：

```bash
curl https://hypit.ai/v1/videos \
  -H "Authorization: Bearer $HYPIT_API_KEY" \
  -H "Idempotency-Key: order-4471-clip-1" \
  -H "Content-Type: application/json" \
  -d '{"model": "bytedance/seedance-2", "prompt": "水沟里漂着一只纸船", "seconds": 5}'
```

这个键的作用域是你的账号加当前凭据，最长 512 字节。

| 情况          | 结果                                 |
| ----------- | ---------------------------------- |
| 同键、同请求      | 返回**原来那个** Job，不会重复计费              |
| 同键、不同请求     | `409` `idempotency_conflict`       |
| 同键、上一次仍在准备中 | `425` `admission_in_progress`，稍后重试 |
| 不带键         | 即使请求体完全相同，每次调用也都创建新任务              |

是否重放由请求的持久化指纹判定，因此某个模型后来下线，也不会改变你用同一个键拿回的结果。

## 并发上限 [#并发上限]

一个账号同时最多持有 **64** 个 `queued` 或 `running` 的任务，跨所有 API Key、OAuth 授权和控制台
会话合并计算。超过就是 `429`：

```json
{
  "error": {
    "message": "too many queued or running media Jobs for this account; retry after one finishes",
    "type": "rate_limit_error",
    "code": "media_open_job_capacity",
    "param": null
  }
}
```

响应带 `Retry-After: 30`。这条限制与[错误与限流](/zh/api-reference/errors)里那条按 Key 的请求速率
限制是两回事。

## 额度怎么走 [#额度怎么走]

提交任务会按估算**预扣**额度。进入终态时：

* `succeeded`——按真实用量结算。低于估算的部分退回，高于估算的部分补扣。
* `failed`——已经实际扣掉的全额退回，退到原来那几笔额度上；任务的费用列归零。
* `queue_expired`——同样全额退回。

这也覆盖了那个尴尬的情况：上游确实生成了东西，但我们拿不到可交付的产物时，任务判为失败并退款，
你不会为一个下载不到的产物付钱。机制细节见[额度与计费](/zh/api-reference/billing)。

## 查询类错误 [#查询类错误]

| 状态    | `code`                    | 含义                                |
| ----- | ------------------------- | --------------------------------- |
| `404` | `job_not_found`           | id 不存在——**或者**这个任务属于别的账号。两者刻意不可区分 |
| `409` | `job_asset_not_ready`     | 仅 `/content`：还没有可交付的产物            |
| `503` | `job_service_unavailable` | 存储或签名短暂不可用，请重试                    |