API リファレンス

二重課金を防ぐ

冪等性とは、再試行したリクエストが 1 回だけ実行されることです:再試行時に同じ Idempotency-Key を送れば、最初の試行が失われたように見えただけの場合でも、課金は 1 回だけです。

タイムアウトしたリクエストは失敗したリクエストではありません。生成はすでに実行中かもしれず、やみくもに再試行すると 2 つ目の生成が始まります — その分も課金されます。Idempotency-Key を送信すれば、ゲートウェイはリクエストを 1 回だけ実行し、24 時間は同じレスポンスをリプレイします。

#キーを送るべきとき

送るべき場合

  • すべての非同期送信 — 動画、画像タスク、音楽、3D。これらは高額な処理です。
  • ワーカーが自動で再試行する、ストリーミング以外のすべての呼び出し。
  • クラッシュ後にジョブを再配信しうるキューの後ろにある呼び出し。

不要な場合

  • ストリーミングリクエスト — このヘッダーは 400 で拒否されます。ストリームはリプレイできません。
  • GET によるポーリングとメタデータの読み取り — もともと繰り返しても無料で安全です。
  • 重複した回答のコストが記録管理の手間より小さい、対話型のチャット。
submit.sh
curl https://www.aisplice.store/v1/videos \
  -H "Authorization: Bearer sk-YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "model": "doubao-seedance-2.5",
    "prompt": "A drone shot of a lighthouse at dawn"
  }'

キーは自社のレコードから導出する

再試行ループの中で生成した UUID では意味がありません — 試行のたびに新しい識別子になってしまいます。処理対象の業務レコード(注文 ID、ジョブ ID)から導出し、そのジョブのすべての再試行で同じキーを送るようにしてください。

#キーの照合方法

オプトイン制で、自動では有効になりません

ヘッダーがなければ何も変わりません。キーなしの再試行は、別の課金対象リクエストになります。

書き込みメソッドのみ

GET、HEAD、OPTIONS はこのヘッダーを無視します — タスクのポーリングはもともと安全に繰り返せます。

スコープはユーザー + キー + メソッド + ルート

同じ値でも、別の API キーや別のエンドポイントで使えば別の操作になります。

ボディも識別の一部

パス、クエリ、ボディはまとめてハッシュされます。ペイロードを変えてキーを再利用すると、上書きではなく 409 が返ります。

ストリーミングとは併用不可

ストリーミングのレスポンスはリプレイ用に保存できないため、stream:true とキーの組み合わせは 400 で拒否されます。

フェイルクローズ

台帳が利用できない場合、保護なしで実行するのではなく 503 でリクエストを拒否します。

キーは最大 255 文字です。それより長いと、リクエストの実行前に 400 で拒否されます。

#結果とステータスコード

リプレイされたレスポンスには X-Idempotent-Replayed: true と元のステータスが付きます — 照合すべき重複ではなく、回答として扱ってください。拒否はすべてフラットなガードのエラー形式を使います。

HTTPコード意味対処法
400IDEMPOTENCY_STREAM_UNSUPPORTEDストリーミングリクエストに Idempotency-Key が送信されました。ストリーミング呼び出しではこのヘッダーを外すか、リプレイ保護が必要な場合は stream:false にしてください。
400IDEMPOTENCY_BODY_UNREADABLE冪等台帳用にボディのハッシュを計算できませんでした。通常の JSON または multipart ボディを送信してください — バッファできないチャンク形式のボディは拒否されます。
409IDEMPOTENCY_PAYLOAD_CONFLICTこのキーはすでに別のボディ、パス、またはクエリで使用されています。新しいキーを生成してください。1 つのキーが識別するのは特定の 1 件のリクエストであり、再試行ループ全体ではありません。
409IDEMPOTENCY_IN_PROGRESSこのキーを使った最初のリクエストがまだ実行中です。Retry-After(2 秒)待ってから同じキーで再試行してください — 元のレスポンスが返されます。
409IDEMPOTENCY_RESULT_UNREPLAYABLE元のリクエストは完了しましたが、レスポンスが封印されておりリプレイできません。やみくもに再送信しないでください。まずタスクのエンドポイントか自社の記録で照合してください。
503IDEMPOTENCY_UNAVAILABLE冪等台帳が利用できないため、二重課金のリスクを避けてガードはフェイルクローズします。バックオフして再試行してください — このリクエストは実行されていません。

#再試行ループ

ジョブごとに 1 つのキーをすべての試行で再利用し、処理中の 409 は「失敗」ではなく「待機」として扱います。

submit_once.py
import time, uuid, requests

def submit_once(job_id: str, body: dict, api_key: str, tries: int = 5):
    # Same key for every attempt of this job — that is what makes it safe.
    key = str(uuid.uuid5(uuid.NAMESPACE_URL, f"video-job:{job_id}"))

    for attempt in range(tries):
        response = requests.post(
            f"https://www.aisplice.store/v1/videos",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Idempotency-Key": key,
            },
            json=body,
            timeout=60,
        )

        if response.status_code == 409:
            code = response.json().get("code", "")
            if code == "IDEMPOTENCY_IN_PROGRESS":
                time.sleep(int(response.headers.get("Retry-After", 2)))
                continue
            # PAYLOAD_CONFLICT or RESULT_UNREPLAYABLE: a human decision,
            # never a silent resubmit.
            raise RuntimeError(response.text)

        if response.status_code >= 500:
            time.sleep(2 ** attempt)
            continue

        response.raise_for_status()
        replayed = response.headers.get("X-Idempotent-Replayed") == "true"
        return response.json(), replayed

    raise TimeoutError(f"submit for {job_id} did not settle")

何かを再試行する前に task_id を保存する

ジョブを送信済みにするのと同じトランザクションで、task_id をデータベースに書き込んでください。キーが守るのは送信です。応答が失われた後に頼れるのは自社の記録だけです。

非同期タスクのライフサイクル →

まだ解決しませんか?

リクエスト ID かエラーメッセージを送っていただければ、こちらで調査します。