Adobe 把 Firefly API 的限流写在一页用法说明里:4 requests per minute (RPM)9,000 requests per day (RPD),单位标注为 per organization——按整个组织算,不按 API key,也不按端点。处置方案只有两条:读 retry-after 响应头,或者做指数退避。

数字是最容易的部分,每分钟 4 次只是原型阶段的余量。批量流水线得围着它设计,而不是想办法绕过去。Firefly 视频接口笔记讲接口契约,本文讲另一半:官方公布了什么、429 长什么样、退避怎么写才不会白等、哪九个尺寸合法,以及哪些数字官方从未公布。

Adobe 公布的限流数字

两个数字,以及它们的计量单位

用法说明写得很直接:限流按组织计算,4 RPM 与 9,000 RPD。每日限额后面那句括号说明它何时才真正起作用——客户经理已经把每分钟上限提上去了,而每日上限没动。

「按组织计算」带来两个后果。四个 worker 共用同一个 client ID,分的是这四次,不是各拿四次;官方给的是整套 API 的总量,没有按端点拆分,所以文档里不存在「留给轮询的额外额度」。提额只能找客户经理,请求头和订阅档位都改变不了它。

429 响应与 retry-after 怎么读

官方文档明确承诺了什么

超过任何一条限额,返回 HTTP 429 Too Many Requests。官方处置写得很明确:「Implement retry logic via a retry-after HTTP header or an exponential backoff strategy.」staging 版本更进一步:用那个头「to determine the number of seconds you should wait before trying again」——等多久由响应头说了算,不由你猜。

429 也是 Generate video 列出的八个状态码之一,和 408415 并排出现。但三者不该同样处理:415 说明 Content-Type 写错了,重试一百次还是错的;429 说明请求本身没问题,只是时机不对。

轮询状态地址时两个凭据都要带上:Authorizationx-api-key。漏掉后者,本来只是慢的任务会直接变成鉴权错误。

处理 429:retry-after 与退避

一段可以直接抄进 shell 的重试代码

第一段提交一个任务,同时把响应头和响应体留在磁盘上,方便判断到底撞上了什么。

export FIREFLY_SERVICES_ACCESS_TOKEN='paste-access-token-here'
export FIREFLY_SERVICES_CLIENT_ID='paste-client-id-here'

curl -sS -D /tmp/headers.txt -o /tmp/body.json \
  -X POST 'https://firefly-api.adobe.io/v3/videos/generate' \
  -H "Authorization: Bearer $FIREFLY_SERVICES_ACCESS_TOKEN" \
  -H "x-api-key: $FIREFLY_SERVICES_CLIENT_ID" \
  -H 'x-model-version: video1_standard' \
  -H 'Content-Type: application/json' \
  -d '{"prompt":"A slow aerial pass over a desert at sunrise","sizes":[{"height":1080,"width":1920}]}'

# 状态行说明这一枪有没有被限流,retry-after 说明要等多久。
grep -i -E '^(HTTP|retry-after)' /tmp/headers.txt

第二段轮询任务状态,并在那个头出现时优先听它的。Adobe 官方异步示例的循环条件写成 status 到达 succeededfailed 才停,所以这段把 429 当暂停,而不是当失败。

STATUS_URL=$(jq -r .statusUrl /tmp/body.json)

attempt=0
until [ "$attempt" -ge 5 ]; do
  attempt=$((attempt + 1))
  curl -sS -D /tmp/poll-headers.txt -o /tmp/poll.json "$STATUS_URL" \
    -H "Authorization: Bearer $FIREFLY_SERVICES_ACCESS_TOKEN" \
    -H "x-api-key: $FIREFLY_SERVICES_CLIENT_ID"

  CODE=$(head -n 1 /tmp/poll-headers.txt | awk '{print $2}')
  if [ "$CODE" = "429" ]; then
    WAIT=$(awk 'BEGIN{IGNORECASE=1} /^retry-after:/ {gsub(/\r/,""); print $2}' /tmp/poll-headers.txt)
    sleep "${WAIT:-$((2 ** attempt))}"
    continue
  fi

  [ "$(jq -r .status /tmp/poll.json)" = "running" ] || break
  sleep 5
done
jq . /tmp/poll.json

第三段值得留在生产环境:它按官方上限给每次调用排间隔,只有在响应头缺失时才退回带抖动的指数退避。

import os
import random
import time

import requests

BASE = "https://firefly-api.adobe.io/v3"
HEADERS = {
    "Authorization": f"Bearer {os.environ['FIREFLY_SERVICES_ACCESS_TOKEN']}",
    "x-api-key": os.environ["FIREFLY_SERVICES_CLIENT_ID"],
    "x-model-version": "video1_standard",
    "Content-Type": "application/json",
}
MIN_INTERVAL = 60 / 4  # 官方公布的 4 RPM,按组织计算
_last_call = 0.0


def pace():
    global _last_call
    wait = MIN_INTERVAL - (time.monotonic() - _last_call)
    if wait > 0:
        time.sleep(wait)
    _last_call = time.monotonic()


def call(method, url, **kwargs):
    for attempt in range(6):
        pace()
        response = requests.request(method, url, headers=HEADERS, timeout=60, **kwargs)
        if response.status_code != 429:
            response.raise_for_status()
            return response.json()
        header = response.headers.get("retry-after")
        wait = float(header) if header else (2**attempt) + random.random()
        print(f"429 from {url}; waiting {wait:.1f}s")
        time.sleep(wait)
    raise RuntimeError("still rate limited after six attempts")


job = call(
    "POST",
    f"{BASE}/videos/generate",
    json={
        "prompt": "A slow aerial pass over a desert at sunrise",
        "sizes": [{"height": 1080, "width": 1920}],
        "seeds": [1842533538],
        "bitRateFactor": 18,
    },
)
print("submitted", job["jobId"])

while True:
    payload = call("GET", job["statusUrl"])
    if payload.get("status") in {"succeeded", "failed"}:
        break
    time.sleep(5)

print(payload.get("status"))
一张时序示意图:一次 Firefly API 请求先收到 429 响应,接着按 retry-after 等待,再经过指数退避延长等待,最后重试成功

把批量任务压在每分钟 4 次以内

共享配额下的批次节流

每分钟 4 次是组织级的,于是逼出一个和代码关系不大的决定:哪些任务配得上一次请求。一条五秒视频的成本是一次提交加若干次轮询,四十条就是八十次请求起步,而下载还没开始。

三个习惯能让批次待住。提交集中在一个进程,不要每个 worker 各自提交;轮询间隔跟着任务状态走,不要钉死成一秒,官方那句「check once every second」是节奏举例,照抄只会把额度烧在空轮询上;先把每日算术做完再承诺吞吐量,按 4 RPM 全天不停也只有 5,760 次,9,000 要等每分钟上限提上去才生效。

官方支持的画幅与尺寸档位

九个写进文档的尺寸

用法说明列出三个画幅下的九个合法输出尺寸,此外什么都没写。表外的尺寸只能算猜,API 不保证接受。

画幅尺寸
16:91920w x 1080h
16:91280w x 720h
16:9960w x 540h
9:161080w x 1920h
9:16720w x 1280h
9:16540w x 960h
1:11080w x 1080h
1:1720w x 720h
1:1540w x 540h

三档不是装饰,请求哪个尺寸就等于付哪个价。Adobe 的 Operations 费率卡把视频消耗按每秒生成视频计算,沿分辨率分成三档:540p 每秒 0.4 个 Operation,720p 每秒 1 个,1080p 每秒 2 个。端点定义是五秒,请求体没有时长字段,同一次调用的账,最小方形是 2 个 Operation,最大宽屏是 10 个。

官方没有公布的部分

并发、耗时与价格

并发没有数字。用法说明承认 Adobe 对调用的 volume、frequency、concurrency 都设了限制,然后只公布前两项的数值。同一时刻允许多少任务在跑,官方未公布,也没有请求头能提它。

耗时与超时同样没有数字。「check once every second」是轮询节奏,不是对单条视频耗时的承诺。重试预算只能按次数写,按秒写没有依据。

价格是第三个空白。单次调用费用在参考页和用法说明里都没出现,别处引用的报价属于那个站自己。网页端一次生成花多少额度写在另一张表上,见额度与费用页,它和 Operations 费率卡不是同一张表。

常见问题

Firefly API 的限额是多少? 每分钟 4 次、每天 9,000 次,按组织计算,写在 Firefly API 用法说明页上。

怎么知道自己被限流了? 响应是 HTTP 429 Too Many Requests,处置是用 retry-after 响应头驱动重试,或者走指数退避。

Adobe 公布并发上限了吗? 文档承认 volume、frequency、concurrency 三项都有限制,随后只给出前两项数字,并发数值官方未公布。

一条视频要花多少次请求? 一次提交加若干次状态轮询。任务耗时官方未公布,轮询次数没法提前算准。

按公布的上限设间隔,在写批量循环之前先把 retry-after 接进重试逻辑,尺寸只从那九行里挑。接口参考是完整的请求契约,音视频 API 面讲的是一套共用凭据、但不共用这套作业模型的处理类接口。