「202 Acceptedで非同期APIを設計する|job resource・状態遷移・冪等性」の内容を表す技術イラスト
UNIT 00LAB NOTES FIG.00 · EYECATCH

202 Acceptedで非同期APIを設計する|job resource・状態遷移・冪等性

長時間処理をHTTP接続から分離する202 Accepted設計を解説。job resource、状態遷移、polling、冪等性、SQLite・Python実装で再送と失敗を安全に扱います。

DOC NO.EKP-00-0985
DATE
REVB
READ約 12 分

APIでCAD変換、帳票生成、大量データ取込などを実行すると、処理が数十秒以上かかることがあります。HTTP接続を完了まで維持すると、clientやproxyのtimeout、利用者による再送、同じ処理の二重実行が起こりやすくなります。

このような処理では、requestを受け付けた時点でjobを作成し、202 Acceptedを返す設計が有効です。ただし、202を返すだけでは連携は完成しません。clientが確認できるjob resource、状態遷移、失敗情報、polling間隔、冪等性を一つの契約として設計する必要があります。

目次

202 Acceptedは処理完了を表さない

RFC 9110では、202を「処理のために受理したが、処理は完了していない」status codeと定義しています。後の処理で拒否される可能性もあり、202自体は最終成功を保証しません。

使い分けの基本は次のとおりです。

response意味
200 OK処理が完了し、結果をresponseで返す
201 Created処理が完了し、新しいresourceを作成した
202 Accepted受付は完了したが、最終結果は未確定
4xxrequestを受付段階で拒否した
5xxserver側の問題で正常に受け付けられなかった

入力形式や認可に問題があるrequestまで202にしてはいけません。同期的に判定できる構文、必須field、型、権限、payload sizeは受付前に検証し、不正なら4xxを返します。

受付結果をjob resourceとして返す

たとえばCAD fileの変換要求を次のように送ります。Idempotency-Keyはこの記事のAPIが定める独自headerであり、HTTP標準のfieldではありません。

POST /conversions HTTP/1.1
Content-Type: application/json
Prefer: respond-async
Idempotency-Key: req-20261012-000184

{
  "source_file_id": "file-184",
  "output_format": "step",
  "schema_version": 1
}

RFC 7240のPrefer: respond-asyncは、clientが非同期responseを希望することを表します。serverがこの選好に従ったことを示す場合はPreference-Appliedを返せます。

HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /jobs/job-01K7B4M8
Retry-After: 5
Preference-Applied: respond-async

{
  "job_id": "job-01K7B4M8",
  "status": "queued",
  "submitted_at": "2026-10-12T01:30:00Z",
  "status_url": "/jobs/job-01K7B4M8",
  "result": null,
  "error": null,
  "schema_version": 1
}

RFC 9110は、202 responseのrepresentationが現在状態を説明し、status monitorを示すことを推奨しています。RFC 7240には202とLocationを組み合わせる例がありますが、202におけるLocationの用途はAPI契約で明記します。Retry-Afterをpolling間隔の目安として使う場合も、clientが意味を推測しなくてよいよう仕様へ記載します。

jobのfieldと業務データを分ける

job resourceには、処理の追跡に必要なfieldを保持します。

fieldtype役割
job_idstringjobの不変ID
statusstringqueued、running、succeededなどの状態
submitted_atstring受付日時。UTCのRFC 3339形式
started_atstringまたはnullworkerが処理を開始した日時
finished_atstringまたはnull終了日時
progressintegerまたはnull定義できる場合だけ進捗率
resultobjectまたはnull成功時の出力参照
errorobjectまたはnull失敗時の機械可読情報
schema_versionintegerjob representationの版

job_idは処理の実行単位、source_file_idは業務対象、idempotency keyは同一requestの識別子です。役割の異なるIDを一つにまとめません。

progressを返すなら、0から100の意味と更新粒度を決めます。処理量を測れないのに時間だけで増加させると、99で長時間停止するなど誤解を生むため、nullまたは段階名を返す方が適切な場合があります。

状態遷移を閉じた集合で定義する

最小構成では、次のような状態を使えます。

queued → running → succeeded
                 → failed
queued → cancelled
running → cancelled

succeeded、failed、cancelledは終端状態です。完了後に同じjobをrunningへ戻さず、再実行は新しいjobとして作成し、retry_of_job_idで元jobと関連付けます。

workerはqueuedのjobだけを原子的にrunningへ変更します。複数workerが同じjobを取得しても、条件付きUPDATEで成功するworkerを一つに限定します。

UPDATE jobs
SET status = 'running', started_at = ?
WHERE job_id = ? AND status = 'queued';

更新row数が1なら取得成功、0なら別workerが処理済みです。検索してから無条件UPDATEする方法では、同時実行の競合を防げません。

pollingと最終結果を分ける

clientはstatus_urlをGETし、終端状態まで確認します。

{
  "job_id": "job-01K7B4M8",
  "status": "succeeded",
  "submitted_at": "2026-10-12T01:30:00Z",
  "started_at": "2026-10-12T01:30:02Z",
  "finished_at": "2026-10-12T01:31:18Z",
  "result": {
    "file_id": "file-906",
    "media_type": "model/step",
    "download_url": "/files/file-906"
  },
  "error": null,
  "schema_version": 1
}

処理中は一定間隔で無制限にpollingせず、serverの案内とclient側のbackoffを使います。通信障害、429、503は一時失敗として再試行できますが、最大待機時間と最大回数を決めます。

結果fileが大きい場合はjob responseへbinaryを埋め込まず、権限確認できるfile resourceを返します。download URLに有効期限があるなら、期限切れ後に再発行する方法も契約へ含めます。

受付エラーと非同期処理エラーを分ける

受付前に検出した入力不正は4xxで返します。機械可読なerrorにはRFC 9457のProblem Detailsを利用できます。

受付後に変換engineが失敗した場合、job取得自体は成功しているため、GET /jobs/{id}へ200を返し、status: failedとerrorを含める設計が可能です。

{
  "job_id": "job-01K7B4M8",
  "status": "failed",
  "error": {
    "type": "https://api.example.com/problems/unsupported-cad-version",
    "title": "Unsupported CAD version",
    "detail": "The source file version is not supported.",
    "retryable": false
  }
}

detailの文章をprogramで解析せず、typeやretryableなどのfieldで分岐します。stack trace、内部path、secretを外部向けerrorへ含めません。

idempotency keyで二重jobを防ぐ

202 responseを受け取る前に接続が切れると、clientは同じrequestを再送します。毎回新しいjobを作ると、同じCAD変換や課金処理が二重に実行されます。

安全な受付では、認可主体とidempotency keyの組み合わせに一意制約を設けます。同じkeyと同じrequestなら既存jobを返し、同じkeyでbodyが異なる場合は409などの競合として拒否します。

CREATE TABLE jobs (
    job_id            TEXT PRIMARY KEY NOT NULL,
    tenant_id         TEXT NOT NULL,
    idempotency_key   TEXT NOT NULL,
    request_hash      TEXT NOT NULL,
    request_json      TEXT NOT NULL,
    status            TEXT NOT NULL
        CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
    result_json       TEXT,
    error_json        TEXT,
    submitted_at      TEXT NOT NULL,
    started_at        TEXT,
    finished_at       TEXT,
    UNIQUE (tenant_id, idempotency_key)
) STRICT;

keyの保存期間を短くしすぎると、遅い再送で重複jobが生まれます。jobと業務上の副作用を安全に再試行できる期間に合わせて保持します。

PythonとSQLiteで受付を実装する

次の関数は、JSONをcanonicalなbyte列へ変換してhashを計算し、同じkeyの再送では既存jobを返します。

import hashlibimport jsonimport sqlite3import uuidfrom datetime import datetime, timezonedef canonical_json(data: dict) -> str:    return json.dumps(        data,        ensure_ascii=False,        sort_keys=True,        separators=(",", ":"),        allow_nan=False,    )def accept_job(    conn: sqlite3.Connection,    tenant_id: str,    idempotency_key: str,    request: dict,) -> tuple[str, bool]:    request_json = canonical_json(request)    request_hash = hashlib.sha256(request_json.encode("utf-8")).hexdigest()    job_id = "job-" + uuid.uuid4().hex    submitted_at = datetime.now(timezone.utc).isoformat()    with conn:        cursor = conn.execute(            """            INSERT INTO jobs (                job_id, tenant_id, idempotency_key,                request_hash, request_json, status, submitted_at            ) VALUES (?, ?, ?, ?, ?, 'queued', ?)            ON CONFLICT (tenant_id, idempotency_key) DO NOTHING

戻り値のbooleanがTrueなら新規job、Falseなら既存jobです。request hashは重複判定用であり、秘密情報を隠す暗号化ではありません。request JSON自体の保存が不要なら、必要な業務fieldだけを正規化してhash対象にします。

セキュリティと運用上の注意

  • job取得、取消、結果downloadで所有者と権限を検証する
  • idempotency keyをtenantや利用者の範囲で一意にする
  • request・result・errorの保存期間を決める
  • payload、生成file、polling頻度へ上限を設ける
  • workerのleaseやheartbeatを設け、停止jobを検出する
  • timeout後に自動再実行してよい処理かを分類する
  • API keyやtokenをrequest JSON、job、通常logへ保存しない

workerが停止してrunningのまま残る場合に備え、lease_expires_atやheartbeatを記録します。ただし、外部systemへの副作用が完了したか不明なjobを無条件で再実行してはいけません。副作用側にも安定したoperation IDを渡し、結果照会または重複排除を可能にします。

CAD・DB・自動化へ再利用する

このjob resourceは、CAD形式変換、BOM展開、大量CSV取込、解析計算、帳票生成、外部APIへの一括同期へ共通化できます。業務ごとの差はrequestとresultのschemaへ閉じ込め、job ID、状態、日時、error、idempotencyは共通fieldとして扱います。

Webhookを併用する場合も、通知を最終結果そのものではなくjob状態変化の合図として扱えます。通知が失われてもstatus URLから回復でき、重複通知はjob IDとversionで除外できます。

まとめ

202 Acceptedは「成功した」ではなく、「処理対象として受け付けた」を表します。安全な非同期APIには、その後を追跡できるdata contractが必要です。

  • 受付時に同期検証を行い、不正requestは4xxで拒否する
  • job ID、状態、日時、result、errorを持つstatus resourceを返す
  • queuedから終端状態までの遷移を閉じた集合で定義する
  • polling間隔、backoff、最大待機時間を契約にする
  • 受付エラーと非同期処理エラーを分離する
  • idempotency keyとrequest hashで二重jobを防ぐ
  • worker取得は状態条件付きUPDATEで競合を防ぐ
  • result file、secret、内部errorをjob本体から分離する
  • job IDを軸にpolling、Webhook、監査logを接続する

202、job resource、冪等性を一体で設計すれば、長時間処理をHTTP接続から安全に分離し、通信切断やworker障害が起きても追跡・再開できる連携基盤になります。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次