NaN/NaN/NaN

PDF生成APIをプロダクションに組み込むと、開発中には気づかなかったエラーに直面します。ネットワーク瞬断によるタイムアウト、トラフィックスパイク時のレート制限、複雑なHTMLが引き起こすレンダリング失敗――これらを適切に処理しなければ、ユーザーに不完全なPDFが届いたり、バッチ処理が途中で止まったりします。

この記事では、FUNBREW PDFをはじめとするPDF APIで発生しやすいエラーパターンを整理し、指数バックオフを用いたリトライ戦略の実装方法を解説します。curl・Python・Node.js・PHPの実践的なコード例を通じて、堅牢なエラーハンドリング設計を身につけてください。

大量リクエストを扱うバッチ処理のエラーハンドリングについてはPDF一括生成ガイドも参照してください。APIキーの安全な管理はセキュリティガイドで解説しています。PDF APIの全体像を把握したい場合はHTML→PDF変換 完全ガイドから始めることをおすすめします。初めてAPIを触る方は言語別クイックスタートも参考にしてください。Markdown→PDF変換でのエラー対策はMarkdown→PDF APIガイドも参照してください。

よくあるエラーパターン

1. タイムアウト(408 / 504)

複雑なHTMLや画像の多いテンプレートは、レンダリングに時間がかかります。ネットワーク遅延も重なると、クライアント側またはサーバー側でタイムアウトが発生します。

原因 対策
外部リソース(画像・フォント)の読み込み遅延 Base64埋め込み or CDN使用
複雑なCSS(グラデーション・Shadow DOM) シンプルなレイアウトに最適化
クライアントの接続タイムアウト設定が短い タイムアウトを60〜120秒に設定

2. レート制限(429 Too Many Requests)

短時間に大量リクエストを送ると、APIが429を返します。レスポンスヘッダーにRetry-Afterが含まれている場合はその値を待機時間に使います。

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711900800

3. 無効なHTML(400 Bad Request)

不正なHTMLはレンダリングエンジンのクラッシュや出力品質の低下を招きます。

  • 未閉じのタグ(<div>に対応する</div>がない)
  • 不正なBase64エンコード画像
  • 存在しない外部CSSの参照

CSSのレンダリング問題についてはPDF出力向けCSSレイアウトのコツで詳しく解説しています。また、wkhtmltopdfとChromiumでHTMLの解釈が異なる点についてはwkhtmltopdf vs Chromium 比較記事が参考になります。出力が崩れる場合の原因特定はトラブルシューティングガイドもご確認ください。

4. 認証エラー(401 / 403)

APIキーが無効・期限切れ・スコープ不足の場合に発生します。このエラーはリトライしても解決しないため、即座にアラートを上げるべきです。

5. サーバーエラー(500 / 502 / 503)

サーバー側の一時的な問題です。リトライ対象として扱い、指数バックオフを適用します。

リトライ戦略:指数バックオフの基本

リトライ時に一定間隔で再試行すると、サーバーへの負荷が集中します。**指数バックオフ(Exponential Backoff)**はリトライ間隔を指数的に増やし、さらにランダムなジッターを加えることでリクエストを分散させます。

待機時間 = min(初期遅延 × 2^リトライ回数 + ランダムジッター, 最大待機時間)
リトライ 待機時間の例(初期1秒・最大60秒)
1回目 1〜3秒
2回目 2〜6秒
3回目 4〜12秒
4回目 8〜24秒
5回目 16〜48秒

リトライすべきエラーとすべきでないエラー

ステータスコード リトライ 理由
408, 429, 500, 502, 503, 504 する 一時的な問題
400 しない リクエスト自体が不正
401, 403 しない 認証問題はリトライで解決しない
404 しない リソースが存在しない

コード例

curl(シェルスクリプト)

#!/bin/bash

API_URL="https://pdf.funbrew.cloud/api/v1/generate"
API_KEY="your-api-key"
MAX_RETRIES=5
INITIAL_DELAY=1

generate_pdf_with_retry() {
  local payload="$1"
  local attempt=0
  local delay=$INITIAL_DELAY

  while [ $attempt -le $MAX_RETRIES ]; do
    response=$(curl -s -w "\n%{http_code}" \
      -X POST "$API_URL" \
      -H "Authorization: Bearer $API_KEY" \
      -H "Content-Type: application/json" \
      -d "$payload" \
      --max-time 120)

    http_code=$(echo "$response" | tail -1)
    body=$(echo "$response" | head -n -1)

    case $http_code in
      200)
        echo "$body"
        return 0
        ;;
      400|401|403|404)
        echo "リトライ不可能なエラー: $http_code" >&2
        echo "$body" >&2
        return 1
        ;;
      408|429|500|502|503|504)
        attempt=$((attempt + 1))
        if [ $attempt -gt $MAX_RETRIES ]; then
          echo "最大リトライ回数に達しました" >&2
          return 1
        fi
        # ジッターを加えた指数バックオフ
        jitter=$((RANDOM % 1000))
        wait_ms=$(( delay * 1000 + jitter ))
        wait_sec=$(echo "scale=3; $wait_ms / 1000" | bc)
        echo "リトライ $attempt/$MAX_RETRIES: ${wait_sec}秒後に再試行 (HTTP $http_code)" >&2
        sleep "$wait_sec"
        delay=$((delay * 2))
        [ $delay -gt 60 ] && delay=60
        ;;
    esac
  done
}

PAYLOAD='{"html": "<h1>請求書</h1>", "options": {"format": "A4"}}'
generate_pdf_with_retry "$PAYLOAD"

Python

import time
import random
import requests
from dataclasses import dataclass
from typing import Optional

@dataclass
class RetryConfig:
    max_retries: int = 5
    initial_delay: float = 1.0
    max_delay: float = 60.0
    backoff_multiplier: float = 2.0
    jitter_range: float = 1.0

RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}

def generate_pdf_with_retry(
    html: str,
    api_key: str,
    options: Optional[dict] = None,
    config: Optional[RetryConfig] = None,
) -> bytes:
    """指数バックオフ付きリトライでPDFを生成する"""
    if config is None:
        config = RetryConfig()

    url = "https://pdf.funbrew.cloud/api/v1/generate"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    payload = {"html": html, "options": options or {"format": "A4"}}

    last_exception = None
    delay = config.initial_delay

    for attempt in range(config.max_retries + 1):
        try:
            response = requests.post(
                url,
                json=payload,
                headers=headers,
                timeout=120,
            )

            if response.status_code == 200:
                return response.content

            if response.status_code not in RETRYABLE_STATUS_CODES:
                # リトライしない(400, 401, 403, 404)
                error_info = response.json() if response.content else {}
                raise ValueError(
                    f"リトライ不可能なエラー: HTTP {response.status_code} - {error_info}"
                )

            # レート制限の場合はRetry-Afterを優先
            if response.status_code == 429:
                retry_after = response.headers.get("Retry-After")
                if retry_after:
                    delay = float(retry_after)

        except requests.exceptions.Timeout as e:
            last_exception = e
        except requests.exceptions.ConnectionError as e:
            last_exception = e

        if attempt >= config.max_retries:
            break

        # 指数バックオフ + ジッター
        jitter = random.uniform(0, config.jitter_range)
        wait_time = min(delay + jitter, config.max_delay)
        print(f"リトライ {attempt + 1}/{config.max_retries}: {wait_time:.2f}秒後に再試行")
        time.sleep(wait_time)
        delay = min(delay * config.backoff_multiplier, config.max_delay)

    raise RuntimeError(
        f"最大リトライ回数({config.max_retries})に達しました"
    ) from last_exception


# 使用例
try:
    pdf_bytes = generate_pdf_with_retry(
        html="<h1>請求書</h1><p>合計: ¥10,000</p>",
        api_key="your-api-key",
        options={"format": "A4", "margin": {"top": "20mm"}},
    )
    with open("invoice.pdf", "wb") as f:
        f.write(pdf_bytes)
    print("PDF生成成功")
except ValueError as e:
    print(f"入力エラー(修正が必要): {e}")
except RuntimeError as e:
    print(f"サーバーエラー(後で再試行): {e}")

Node.js

const axios = require('axios');

const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);

/**
 * 指数バックオフ付きリトライでPDFを生成する
 */
async function generatePdfWithRetry(html, apiKey, options = {}, retryConfig = {}) {
  const {
    maxRetries = 5,
    initialDelay = 1000,   // ミリ秒
    maxDelay = 60000,
    backoffMultiplier = 2,
  } = retryConfig;

  let delay = initialDelay;
  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await axios.post(
        'https://pdf.funbrew.cloud/api/v1/generate',
        { html, options: { format: 'A4', ...options } },
        {
          headers: {
            Authorization: `Bearer ${apiKey}`,
            'Content-Type': 'application/json',
          },
          responseType: 'arraybuffer',
          timeout: 120000,
        }
      );
      return Buffer.from(response.data);

    } catch (error) {
      const status = error.response?.status;

      // リトライしないエラー
      if (status && !RETRYABLE_STATUS_CODES.has(status)) {
        const errorBody = error.response?.data
          ? JSON.parse(Buffer.from(error.response.data).toString())
          : {};
        throw new Error(`リトライ不可能なエラー: HTTP ${status} - ${JSON.stringify(errorBody)}`);
      }

      lastError = error;

      if (attempt >= maxRetries) break;

      // レート制限はRetry-Afterを優先
      let waitTime = delay;
      if (status === 429) {
        const retryAfter = error.response?.headers?.['retry-after'];
        if (retryAfter) {
          waitTime = parseFloat(retryAfter) * 1000;
        }
      }

      // ジッター追加
      const jitter = Math.random() * 1000;
      const actualWait = Math.min(waitTime + jitter, maxDelay);
      console.warn(`リトライ ${attempt + 1}/${maxRetries}: ${(actualWait / 1000).toFixed(2)}秒後に再試行`);

      await new Promise((resolve) => setTimeout(resolve, actualWait));
      delay = Math.min(delay * backoffMultiplier, maxDelay);
    }
  }

  throw new Error(`最大リトライ回数(${maxRetries})に達しました`, { cause: lastError });
}

// 使用例
generatePdfWithRetry(
  '<h1>請求書</h1><p>合計: ¥10,000</p>',
  'your-api-key',
  { format: 'A4' },
  { maxRetries: 3, initialDelay: 2000 }
)
  .then((pdfBuffer) => {
    require('fs').writeFileSync('invoice.pdf', pdfBuffer);
    console.log('PDF生成成功');
  })
  .catch((err) => console.error('エラー:', err.message));

PHP

<?php

class PdfApiClient
{
    private const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504];

    public function __construct(
        private string $apiKey,
        private string $baseUrl = 'https://pdf.funbrew.cloud/api/v1',
        private int $maxRetries = 5,
        private float $initialDelay = 1.0,
        private float $maxDelay = 60.0,
        private float $backoffMultiplier = 2.0,
    ) {}

    /**
     * 指数バックオフ付きリトライでPDFを生成する
     *
     * @throws \RuntimeException リトライ上限に達した場合
     * @throws \InvalidArgumentException リトライ不可能なエラーの場合
     */
    public function generateWithRetry(string $html, array $options = []): string
    {
        $delay = $this->initialDelay;
        $lastError = null;

        for ($attempt = 0; $attempt <= $this->maxRetries; $attempt++) {
            try {
                return $this->callApi($html, $options);
            } catch (\RuntimeException $e) {
                $statusCode = $e->getCode();

                // リトライしないエラー
                if (!in_array($statusCode, self::RETRYABLE_STATUS_CODES, true)) {
                    throw new \InvalidArgumentException(
                        "リトライ不可能なエラー: HTTP {$statusCode} - " . $e->getMessage(),
                        $statusCode
                    );
                }

                $lastError = $e;

                if ($attempt >= $this->maxRetries) {
                    break;
                }

                // ジッターを加えた待機
                $jitter = mt_rand(0, 1000) / 1000;
                $waitTime = min($delay + $jitter, $this->maxDelay);
                error_log("リトライ " . ($attempt + 1) . "/{$this->maxRetries}: {$waitTime}秒後に再試行");
                usleep((int) ($waitTime * 1_000_000));
                $delay = min($delay * $this->backoffMultiplier, $this->maxDelay);
            }
        }

        throw new \RuntimeException(
            "最大リトライ回数({$this->maxRetries})に達しました: " . $lastError?->getMessage(),
            0,
            $lastError
        );
    }

    private function callApi(string $html, array $options): string
    {
        $ch = curl_init("{$this->baseUrl}/generate");
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode([
                'html' => $html,
                'options' => array_merge(['format' => 'A4'], $options),
            ]),
            CURLOPT_HTTPHEADER => [
                "Authorization: Bearer {$this->apiKey}",
                'Content-Type: application/json',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 120,
        ]);

        $body = curl_exec($ch);
        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($curlError) {
            throw new \RuntimeException("cURLエラー: {$curlError}", 0);
        }

        if ($statusCode === 200) {
            return $body;
        }

        throw new \RuntimeException(
            json_decode($body, true)['message'] ?? '不明なエラー',
            $statusCode
        );
    }
}

// 使用例
$client = new PdfApiClient(apiKey: 'your-api-key');

try {
    $pdf = $client->generateWithRetry(
        html: '<h1>請求書</h1><p>合計: ¥10,000</p>',
        options: ['format' => 'A4']
    );
    file_put_contents('invoice.pdf', $pdf);
    echo "PDF生成成功\n";
} catch (\InvalidArgumentException $e) {
    echo "入力エラー(修正が必要): " . $e->getMessage() . "\n";
} catch (\RuntimeException $e) {
    echo "サーバーエラー(後で再試行): " . $e->getMessage() . "\n";
}

エラーログ設計のベストプラクティス

エラーを適切にログに残すことで、問題の早期発見と根本原因の特定が容易になります。

ログに含めるべき情報

{
  "timestamp": "2026-03-31T12:00:00Z",
  "level": "error",
  "event": "pdf_generation_failed",
  "request_id": "req_abc123",
  "attempt": 3,
  "max_retries": 5,
  "status_code": 503,
  "error_message": "Service Unavailable",
  "html_size_bytes": 15420,
  "duration_ms": 5230,
  "will_retry": true
}

ログレベルの使い分け

状況 ログレベル 対応
リトライ可能なエラー(試行中) warn 監視不要
最大リトライ到達 error アラート対象
認証エラー(401/403) error 即時アラート
入力エラー(400) warn コード修正が必要
成功(リトライあり) info 記録のみ

FUNBREW PDFでのエラー対処

エラーハンドリングの実装後は、Webhookを使ってエラー発生時に即時通知する仕組みを導入するのが効果的です。詳しくはWebhook連携ガイドを参照してください。請求書や証明書など実務ユースケースでのエラー対策事例は請求書PDF自動化ガイド証明書PDF自動化ガイドでも触れています。Next.js/Nuxtでのエラー処理実装例はNext.js・Nuxt PDF APIガイドで詳しく紹介しています。

タイムアウト対策

HTMLに外部リソースを含む場合、Base64でインライン埋め込みすると読み込み時間を短縮できます。

{
  "html": "<img src='data:image/png;base64,iVBORw0KGgo...' />",
  "options": {
    "format": "A4",
    "waitUntil": "networkidle0"
  }
}

レート制限の回避

バッチ処理を使って1リクエストで複数PDFを生成すると、APIコール数を削減できます。

{
  "batch": [
    { "html": "<h1>請求書 #001</h1>", "filename": "invoice-001.pdf" },
    { "html": "<h1>請求書 #002</h1>", "filename": "invoice-002.pdf" }
  ]
}

APIキーのローテーション

認証エラーが続く場合は、ダッシュボードからAPIキーを再発行してください。本番とステージングで別々のキーを使うことを推奨します。利用量とレート制限の上限はプランによって異なります。料金プラン比較で自社のユースケースに合ったプランを確認してください。

FUNBREW PDFのその他の活用事例はユースケース一覧からご覧いただけます。請求書PDF自動化は/use-cases/invoices、レポートPDF生成は/use-cases/reportsで詳しく紹介しています。

まとめ

堅牢なPDF API統合に必要なエラーハンドリングのポイントを整理します。

  1. エラーを分類する: リトライ可能(4xx一部・5xx)とリトライ不可(400・401・403)を区別する
  2. 指数バックオフを使う: 固定間隔ではなく指数的に増やし、ジッターを加える
  3. 上限を設ける: 最大リトライ回数・最大待機時間を必ず設定する
  4. ログを残す: リクエストID・ステータスコード・リトライ回数を記録する
  5. アラートを設定する: 最大リトライ到達・認証エラーは即時通知する

エラーハンドリングを含む本番運用の全体像はPDF API本番運用チェックリストでまとめています。FUNBREW PDFのAPI仕様を確認して、各エラーコードの詳細な意味と推奨対応を参照してください。実際にAPIを試すにはプレイグラウンドが便利です。証明書や請求書など実際の業務での活用事例はユースケース一覧も参考にしてください。AWS LambdaやサーバーレスでのPDF生成ではサーバーレスPDF生成ガイド、Django/FastAPIでの統合はDjango/FastAPIガイドも参考になります。Puppeteerからの移行時に発生しやすいエラーについてはPuppeteer移行ガイド、レポート生成でのエラー対策はレポートPDF生成ガイドをご覧ください。他のPDF APIとのエラーハンドリング比較はPDF API比較も参考になります。

Powered by FUNBREW PDF