2026/05/09

PDF生成ステータス追跡:ポーリングとWebhookの使い分け完全ガイド

PDF APIwebhook非同期ポーリングAPI連携

同期的なPDF生成(HTMLをPOSTして待ち、PDFバイトを受け取る)は単純なドキュメントには十分です。大量バッチ・複雑なテンプレート・高並行ワークロードでは非同期モデルの方が適しています。ジョブを投入してからステータスを別途追跡するアーキテクチャです。

ステータス追跡には2つの戦略があります。ポーリング(完了するまで繰り返しAPIを確認する)とWebhook(ジョブ完了時にAPIがあなたのエンドポイントに通知する)です。それぞれ異なるトレードオフがあります。

この記事では両方の戦略を動作するコードとともに解説します。SlackとPHP/Python/Node.jsエンドポイントを含むWebhookの完全なセットアップはPDF API Webhook連携ガイドをご覧ください。大量バッチのパターンはPDF APIバッチ処理ガイドをご覧ください。

非同期PDF生成が必要な場面

同期生成で十分な場合:

  • リクエストごとに1つのPDFを生成する
  • 生成時間が5秒未満
  • クライアント(ブラウザやモバイルアプリ)がレスポンスを待てる

非同期生成が必要になる場合:

  • テンプレートが複雑でレンダリングに10秒以上かかる
  • 一度に数百〜数千枚のPDFを生成する
  • PDF生成をHTTPリクエストのライフサイクルから切り離したい
  • リクエスト全体を再送せずに失敗したジョブをリトライしたい

バッチジョブの並行処理制御とキューパターンはPDF APIバッチ処理ガイドで解説しています。

戦略1: ポーリング

ポーリングの仕組み

1. POST /pdf/generate  →  { job_id: "pdf_abc123", status: "queued" }
2. GET /pdf/status/pdf_abc123  →  { status: "processing" }
3. GET /pdf/status/pdf_abc123  →  { status: "processing" }
4. GET /pdf/status/pdf_abc123  →  { status: "done", url: "https://..." }

コードは一定間隔でステータスエンドポイントを確認し、ジョブが完了または失敗するまで繰り返します。

基本的なポーリング(Node.js)

async function pollUntilDone(jobId, intervalMs = 2000, maxAttempts = 30) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const response = await fetch(
      `https://pdf.funbrew.cloud/api/v1/pdf/status/${jobId}`,
      {
        headers: { 'X-API-Key': process.env.FUNBREW_PDF_API_KEY },
      }
    );

    if (!response.ok) throw new Error(`Status check failed: ${response.status}`);

    const { status, url, error } = await response.json();

    if (status === 'done') return url;
    if (status === 'failed') throw new Error(`PDF generation failed: ${error}`);

    // まだ処理中 — 次のチェックまで待機
    if (attempt < maxAttempts) {
      await new Promise(r => setTimeout(r, intervalMs));
    }
  }

  throw new Error(`PDF job ${jobId} did not complete after ${maxAttempts} attempts`);
}

// 使用例
async function generateAndWait(html) {
  // Step 1: ジョブを投入
  const submitResp = await fetch('https://pdf.funbrew.cloud/api/v1/pdf/generate-async', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.FUNBREW_PDF_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ html, options: { format: 'A4' } }),
  });
  const { job_id } = await submitResp.json();

  // Step 2: 完了まてポーリング
  const pdfUrl = await pollUntilDone(job_id);
  return pdfUrl;
}

指数バックオフポーリング

固定インターバルは長時間のジョブに対してリクエストを無駄にし、短時間のジョブにはレイテンシを加えます。指数バックオフは待機時間を動的に調整します。

async function pollWithBackoff(jobId, {
  initialIntervalMs = 500,
  maxIntervalMs = 10000,
  maxAttempts = 20,
} = {}) {
  let intervalMs = initialIntervalMs;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const response = await fetch(
      `https://pdf.funbrew.cloud/api/v1/pdf/status/${jobId}`,
      { headers: { 'X-API-Key': process.env.FUNBREW_PDF_API_KEY } }
    );

    const { status, url, error } = await response.json();

    if (status === 'done') return url;
    if (status === 'failed') throw new Error(error);

    // ジッターを加えた指数バックオフ
    const jitter = Math.random() * 200;
    await new Promise(r => setTimeout(r, intervalMs + jitter));

    // インターバルを上限まで2倍にする
    intervalMs = Math.min(intervalMs * 2, maxIntervalMs);
  }

  throw new Error(`Polling timed out after ${maxAttempts} attempts`);
}

ジッター(ランダムオフセット)は複数のクライアントが同じ瞬間にチェックしないようにします。複数のワーカーからポーリングする場合に重要です。

Pythonでのポーリング

import os
import time
import requests

def poll_until_done(
    job_id: str,
    initial_interval: float = 0.5,
    max_interval: float = 10.0,
    max_attempts: int = 20,
) -> str:
    interval = initial_interval
    api_key = os.environ["FUNBREW_PDF_API_KEY"]

    for attempt in range(1, max_attempts + 1):
        resp = requests.get(
            f"https://pdf.funbrew.cloud/api/v1/pdf/status/{job_id}",
            headers={"X-API-Key": api_key},
            timeout=10,
        )
        resp.raise_for_status()
        data = resp.json()

        if data["status"] == "done":
            return data["url"]
        if data["status"] == "failed":
            raise RuntimeError(f"PDF job failed: {data.get('error')}")

        if attempt < max_attempts:
            time.sleep(interval)
            interval = min(interval * 2, max_interval)

    raise TimeoutError(f"Job {job_id} did not complete in {max_attempts} attempts")

ポーリングのトレードオフ

ポーリングのPros:

  • 実装がシンプル — 公開エンドポイントが不要
  • あらゆる環境で動作(サーバーレス・CLIスクリプト・cronジョブ)
  • デバッグが容易 — すべてのステータスチェックをログで確認できる
  • Webhookインフラが不要

ポーリングのCons:

  • ジョブが予想より長くかかる場合にリクエストが無駄になる
  • ジョブ完了からポーリングインターバル分のレイテンシが加わる
  • 高並行時、多数の同時ポーラーがAPI負荷を増やす
  • ポーリング中にプロセスを生かし続けるとサーバーリソースが占有される

ポーリングは低〜中程度の並行ユースケース・CLIツール・スクリプト・インカミングHTTPリクエストを受信できない環境に適しています。

戦略2: Webhook

Webhookの仕組み

1. FUNBREW PDFダッシュボードでWebhook URLを設定
2. POST /pdf/generate  →  { job_id: "pdf_abc123" }(即時返却)
3. [APIがバックグラウンドでPDFを生成]
4. APIがあなたのエンドポイントを呼ぶ: POST /webhooks/pdf  →  { event: "pdf.generated", data: {...} }

コードは繰り返しチェックするのではなく、ジョブ完了時にプッシュ通知を受け取ります。

Webhookエンドポイント(Node.js / Express)

const express = require('express');
const crypto  = require('crypto');
const app     = express();

app.use(express.json());

app.post('/webhooks/pdf', (req, res) => {
  // Step 1: 署名を検証
  const signature = req.headers['x-funbrew-signature'];
  const expected  = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Step 2: 受信を即座に確認(処理前に)
  res.status(200).json({ received: true });

  // Step 3: イベントを非同期で処理
  const { event, data } = req.body;
  handlePdfEvent(event, data).catch(err => console.error('Webhook handler error:', err));
});

async function handlePdfEvent(event, data) {
  switch (event) {
    case 'pdf.generated':
      console.log(`PDF準備完了: ${data.filename} (${data.file_size} bytes)`);
      // ダウンロード、保存、またはユーザーに通知
      await notifyUser(data.job_id, data.url);
      break;

    case 'pdf.failed':
      console.error(`PDF失敗: ${data.job_id} — ${data.error}`);
      await recordFailure(data.job_id, data.error);
      break;

    case 'batch.completed':
      console.log(`バッチ完了: ${data.succeeded}/${data.total} 成功`);
      break;
  }
}

app.listen(3000);

Webhookエンドポイント(Python / FastAPI)

import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks

app = FastAPI()

@app.post("/webhooks/pdf")
async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
    body = await request.body()
    signature = request.headers.get("x-funbrew-signature", "")

    # 署名を検証
    expected = hmac.new(
        os.environ["WEBHOOK_SECRET"].encode(),
        body,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        raise HTTPException(status_code=401, detail="Invalid signature")

    # 即座に受信を確認
    payload = await request.json()
    background_tasks.add_task(process_event, payload["event"], payload["data"])

    return {"received": True}

async def process_event(event: str, data: dict):
    if event == "pdf.generated":
        print(f"PDF準備完了: {data['filename']}")
        await notify_user(data["job_id"], data["url"])
    elif event == "pdf.failed":
        print(f"PDF失敗: {data['job_id']} — {data['error']}")

署名検証

ペイロードを処理する前に必ずWebhookの署名を検証してください。検証なしでは、エンドポイントURLを知っている人なら誰でも偽のイベントを送信できます。

// 正しい: タイミングセーフな比較を使用
const isValid = crypto.timingSafeEqual(
  Buffer.from(signature, 'hex'),
  Buffer.from(expected, 'hex')
);

// 誤り: === を使用(タイミング攻撃に脆弱)
// const isValid = signature === expected;

timingSafeEqual(Node.js)またはhmac.compare_digest(Python)を使用することで、攻撃者がレスポンス時間を計測することで期待される署名を推測するタイミング攻撃を防ぎます。

Webhookの再配信処理

エンドポイントが2xx以外のステータスコードを返すかタイムアウトした場合、FUNBREW PDF APIはWebhookの配信を再試行します。エンドポイントは重複配信を適切に処理する必要があります。

// 冪等なハンドラー: ジョブが既に処理済みか確認
async function handlePdfEvent(event, data) {
  if (event !== 'pdf.generated') return;

  // 既に処理済みか確認
  const existing = await db.query(
    'SELECT id FROM processed_pdfs WHERE job_id = $1',
    [data.job_id]
  );
  if (existing.rows.length > 0) {
    console.log(`Job ${data.job_id} already processed — skipping`);
    return;
  }

  // 処理して完了としてマーク
  await downloadAndStorePdf(data.url, data.filename);
  await db.query(
    'INSERT INTO processed_pdfs (job_id, processed_at) VALUES ($1, NOW())',
    [data.job_id]
  );
}

処理済みのジョブIDを保存し、同じjob_idを2度見た場合は再処理をスキップします。これがWebhookコンシューマーの標準的な冪等性パターンです。

WebhookのトレードOff

WebhookのPros:

  • リアルタイム通知 — ポーリングの遅延なし
  • ステータス確認の無駄なリクエストがない
  • 高スケーラビリティ: 1,000件の並行ジョブが生成するのは1,000回の配信コールで、N × 1,000回のポーリングコールではない
  • PDF生成中、サーバーは他の処理に使える

WebhookのCons:

  • 公開アクセス可能なHTTPエンドポイントが必要
  • CLIスクリプトや単発ジョブには不向き
  • セットアップが複雑(エンドポイント・署名検証・リトライ処理)
  • ローカル開発にはトンネルが必要(ngrok等)
  • 冪等性(重複配信)の処理が必要

Webhookは多数のPDFを処理しており、最小限のAPI負荷でリアルタイムのステータス更新が必要な本番アプリケーションに適しています。

ポーリングとWebhookの選び方

状況 推奨
スクリプトやCLIツール ポーリング
リクエストごとにトリガーされるサーバーレス関数 ポーリング
多数のジョブを処理するバックグラウンドワーカー Webhook
リアルタイムのユーザー通知が必要 Webhook
ローカル開発またはテスト ポーリング(シンプル)
高並行(100件以上の同時ジョブ) Webhook
公開エンドポイントが利用不可 ポーリング

ほとんどの本番ウェブアプリケーションではWebhookが望ましい選択です。スクリプトやツールにはポーリングの方がシンプルです。両方の戦略はうまく組み合わせられます。開発環境ではポーリング、本番ではWebhookを使う方法が一般的です。

ハイブリッド: Webhookフォールバック付きポーリング

完了イベントを見逃せない重要なジョブでは、両方を組み合わせます。

async function generatePdfReliably(html) {
  const { job_id } = await submitJob(html);

  // 主要: Webhookの到着を待つ(別途処理)
  // フォールバック: Webhook配信が失敗した場合のポーリング
  const WEBHOOK_TIMEOUT_MS = 60_000; // 60秒
  const startTime = Date.now();

  while (Date.now() - startTime < WEBHOOK_TIMEOUT_MS) {
    const { status, url } = await checkStatus(job_id);

    if (status === 'done') return url;
    if (status === 'failed') throw new Error('PDF generation failed');

    await new Promise(r => setTimeout(r, 5_000)); // 5秒ポーリングインターバル
  }

  throw new Error(`Job ${job_id} did not complete within timeout`);
}

このパターンでは、Webhookハンドラーが発火すると共有ストア(データベースやキャッシュ)を更新します。ポーリングループはそのストアから読み取ります。先に到着した方が勝ち、もう一方はno-opになります。

エラーハンドリングとリトライ

ポーリングとWebhookハンドラーの両方で、一時的な障害に対するリトライロジックを実装してください。

async function downloadPdfWithRetry(url, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return Buffer.from(await response.arrayBuffer());
    } catch (err) {
      if (attempt === maxRetries) throw err;
      // 指数バックオフ: 1s, 2s, 4s
      await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1)));
    }
  }
}

レート制限処理と5xxリカバリーを含む包括的なエラーハンドリングパターンはPDF APIエラーハンドリング完全ガイドをご覧ください。

まとめ

ポーリング Webhook
セットアップの複雑さ
リアルタイム通知 なし(インターバル遅延) あり
公開エンドポイントが必要 不要 必要
無駄なAPIコール あり(ステータスチェック) なし
スクリプト/CLIに適す はい いいえ
本番アプリに適す 並行数次第 はい
冪等性処理 不要 必要

シンプルさのためにポーリングから始めましょう。並行ジョブが複数ある場合、本番アプリでリアルタイムステータスが必要な場合、不要なAPIコールを最小化したい場合はWebhookに移行します。

関連リンク

Powered by FUNBREW PDF