カスタムエラー回復とリトライ戦略を実装する
本番のドキュメントサービスでは、例外をキャッチしてログに記録するだけでは不十分です。その後に何を行うかを決める必要があります。縮退した結果のまま処理を続ける、2 つ目のレンダリング経路に切り替える、エンジンが受け付ける入力でリトライする、または失敗前にすでに構築済みのページをそのまま提供する、といった対応です。このレシピでは、NextPDF の例外階層とドキュメント状態の検査メソッドを土台にした、4 つの回復戦略を示します。
- フォント失敗時の段階的な縮退 —
NextPDF\Exception\FontNotFoundExceptionをキャッチし、確実に利用できるフェースへフォールバックして、ドキュメントの構築を継続します。 - フォールバックレンダラー — インプロセスの
Document::writeHtml()経路が入力を拒否した場合は、Document::writeHtmlChrome()(nextpdf/artisanの Chrome ブリッジ)経由でリトライします。 - 代替 HTML でのリトライ —
NextPDF\Exception\HtmlParsingExceptionまたはNextPDF\Exception\CssResolutionBudgetExceededExceptionが発生した場合は、簡素化された既知の正常な HTML バリアントでリトライします。 - 部分的なドキュメントの回復 — 失敗後に
Document::getNumPages()を読み取り、すでに構築済みの内容を破棄せず保存します。
適切な粒度でキャッチする方法については、すでに扱っています。関連ページ NextPDF の例外階層でエラーを処理するでは、階層そのものを説明しています。このページでは、キャッチした後に何をするかを扱います。
このレシピは OSS コアエディションを対象としています。ここで名前を挙げているすべての API は nextpdf/core に含まれます。唯一の省略可能な依存関係は、Chrome フォールバック用の nextpdf/artisan です。
インストール
「インストール」という見出しのセクションcomposer require nextpdf/core:^3フォールバックレンダラー戦略では、追加で Chrome ブリッジを使用します。
composer require nextpdf/artisannextpdf/artisan が存在しない場合、Document::writeHtmlChrome() はレンダリングせずに NextPDF\Exception\PageLayoutException をスローします。そのため、以下のフォールバック戦略では、ブリッジが存在しないことをもう 1 つの回復可能なケースとして扱います。
概念的な概要
「概念的な概要」という見出しのセクション回復処理は、NextPDF についてソースで検証済みの 2 つの事実に基づいています。
例外階層は、何が回復可能かを示します。 すべてのドメイン例外は、抽象基底クラス NextPDF\Exception\NextPdfException を継承します。これは RuntimeException を継承し、NextPDF\Contracts\ContextAwareExceptionInterface を実装します。特定のサブタイプをキャッチして、失敗に応じた回復経路を選択します。
FontNotFoundExceptionはgetFontName()、getSearchPaths()、wasFallbackAttempted()を持ち — 別のフェースでリトライするために十分な情報を提供します。HtmlParsingExceptionはgetRule()、getPosition()、getHtmlSnippet()を持ち — 簡素化したリトライを試みる価値があるかどうかを判断するために十分な情報を提供します。CssResolutionBudgetExceededExceptionはgetVisits()とgetBudget()を持ち — 簡素化したスタイルシートで解消できる、病的なセレクターのシグナルを提供します。- 重要な境界が 1 つあります。
NextPDF\Support\DegradedExceptionはRuntimeExceptionを直接継承しており、NextPdfExceptionは継承しません。そのため、catch (NextPdfException $e)では、縮退ポリシーによる拒否はキャッチされません。アクティブなNextPDF\Contracts\DegradationPolicyがStrictまたはBalancedの場合は、DegradedExceptionを明示的にキャッチして回復します。
ドキュメントは構築中に検査できます。 Document は、読み取り専用のアクセサーを通じて構築状態を公開します。getNumPages() は、アクティブな未フラッシュのページを含む合計ページ数を返し、getPage() は現在のページの 0 始まりのインデックスを返します。構築途中の失敗後は、getNumPages() を読み取って完成したページが存在するかどうかを確認し、次に save() または getPdfData() を呼び出してそれらを出力します。エンジンは、致命的でない縮退イベントも記録します。getWarnings() は list<NextPDF\Support\Warning> を返し、hasWarnings() は警告が収集されたかどうかを報告し、hasDegradedParity() は出力の忠実度が影響を受けたかどうかを報告します。これらを使うと、回復ルーチンは例外を解析することなく、「正常に成功した」と「忠実度を下げて成功した」を区別できます。
縮退ポリシーは、どのイベントを例外として処理し、どのイベントを警告として処理するかを決めます。NextPDF\Core\Config のデフォルトは DegradationPolicy::Balanced です。これは、限定的な縮退に対しては警告を出して処理を続けますが、処理を妨げる影響に対してはスローします。DegradationPolicy::Permissive は決してスローせず、すべてを警告チャネルに収集します。DegradationPolicy::Strict は、コンプライアンスリスク、意味的な損失、または処理を妨げる影響のいずれに対してもスローします。まずポリシーを選択し、次にそのポリシーで生じる失敗の形に応じた回復処理を記述します。
API サーフェス
「API サーフェス」という見出しのセクション以下の回復コードでは、検証済みの次のメンバーを使用します。
NextPDF\Core\Document::createStandalone(?Config $config = null): self、addPage()、setFont(string $family, string $style = '', float $size = 12.0): static、cell(...)、writeHtml(string $html): static、writeHtmlChrome(string $html, ?float $width = null, ?float $height = null): static、save(string $path): void、getPdfData(): string、getNumPages(): int、getPage(): int、getWarnings(): list<Warning>、hasWarnings(): bool、hasDegradedParity(): bool、addFontDirectory(string $directory): static。NextPDF\Core\Config::withDegradationPolicy(DegradationPolicy $policy): selfと、degradationPolicyのデフォルトであるDegradationPolicy::Balanced。NextPDF\Contracts\DegradationPolicy—Strict、Balanced、Permissive。NextPDF\Exception\NextPdfException(抽象基底クラス)、NextPDF\Exception\FontNotFoundException、NextPDF\Exception\HtmlParsingException、NextPDF\Exception\CssResolutionBudgetExceededException、NextPDF\Exception\WriterException、NextPDF\Exception\PageLayoutException。NextPDF\Support\DegradedException(capabilityとpolicyを保持)、NextPDF\Support\Capability(id、status、reason、isDegraded())、NextPDF\Support\Warning、NextPDF\Support\WarningSeverity。
コードサンプル — クイックスタート
「コードサンプル — クイックスタート」という見出しのセクション最小限ながら実用的な回復処理です。フォントが見つからない失敗をキャッチし、確実に利用できるフェースへフォールバックして、処理を続けます。このスニペットでは、本番サンプルに含まれる、より広範な処理を省いています。ロギングと DegradedException の境界を含む完全なハンドラーについては、以下の本番サンプルをお読みください。
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;use NextPDF\Exception\FontNotFoundException;
$doc = Document::createStandalone();$doc->addPage();
try { // A face that may not be installed on every host. $doc->setFont('CorporateSans', '', 12);} catch (FontNotFoundException $e) { // Recover: fall back to a face the engine always resolves. $doc->setFont('helvetica', '', 12);}
$doc->cell(0, 10, 'Rendered with a recovered font.', newLine: true);$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');コードサンプル — 本番
「コードサンプル — 本番」という見出しのセクション完全な例では、4 つの戦略すべてを 1 つのレンダリングパイプラインに組み込みます。フォントのフォールバック、インプロセス経路から Chrome へのレンダラーのフォールバック、代替 HTML でのリトライ、そして getNumPages() によって駆動される部分的なドキュメントの回復です。この例ではハーネスの出力チャネルを尊重し、素の Exception をキャッチすることも、catch ブロックを空のままにすることも決してありません。
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Contracts\ContextAwareExceptionInterface;use NextPDF\Contracts\DegradationPolicy;use NextPDF\Core\Config;use NextPDF\Core\Document;use NextPDF\Exception\CssResolutionBudgetExceededException;use NextPDF\Exception\FontNotFoundException;use NextPDF\Exception\HtmlParsingException;use NextPDF\Exception\NextPdfException;use NextPDF\Exception\PageLayoutException;use NextPDF\Exception\WriterException;use NextPDF\Support\DegradedException;
/** * A minimal structured sink. In production this is your PSR-3 logger; the * exception class and its structured context become log fields. * * @param array<string, mixed> $context */function logRecovery(string $message, array $context): void{ fwrite(STDERR, $message . ' ' . json_encode($context, JSON_THROW_ON_ERROR) . "\n");}
/** * Resolve a usable font, degrading from the requested face to a guaranteed * fallback. Returns the face actually applied so the caller can record it. * * @param non-empty-string $requested * @param non-empty-string $fallback * * @return non-empty-string */function applyFontWithFallback(Document $doc, string $requested, string $fallback): string{ try { $doc->setFont($requested, '', 12);
return $requested; } catch (FontNotFoundException $e) { // STRATEGY 1 — graceful degradation on a font failure. logRecovery('Font unavailable; degrading to a guaranteed face', [ 'exception' => $e::class, 'font_name' => $e->getFontName(), 'searched' => $e->getSearchPaths(), 'fallback' => $fallback, ]); $doc->setFont($fallback, '', 12);
return $fallback; }}
/** * Render HTML through the in-process pipeline, then through the Chrome bridge, * then through a simplified HTML variant. Each layer recovers a more specific * failure than the last. */function renderHtmlWithRecovery(Document $doc, string $primaryHtml, string $simplifiedHtml): void{ try { // Primary path: the in-process HTML/CSS pipeline. $doc->writeHtml($primaryHtml);
return; } catch (CssResolutionBudgetExceededException $e) { // STRATEGY 3 — retry with alternative HTML for a pathological selector. logRecovery('CSS resolution budget exceeded; retrying with simplified HTML', [ 'exception' => $e::class, 'visits' => $e->getVisits(), 'budget' => $e->getBudget(), ]); $doc->writeHtml($simplifiedHtml);
return; } catch (HtmlParsingException $e) { // STRATEGY 2 — fall back to the Chrome renderer for input the // in-process parser rejects. The Chrome bridge uses a browser CSS // engine, so it may accept what the in-process parser would not. logRecovery('In-process HTML parse failed; trying the Chrome fallback renderer', [ 'exception' => $e::class, 'rule' => $e->getRule(), 'position' => $e->getPosition(), ]);
try { $doc->writeHtmlChrome($primaryHtml);
return; } catch (PageLayoutException $chromeError) { // The Chrome bridge is absent (nextpdf/artisan not installed) or // rejected the input. Last resort: the simplified HTML variant // through the in-process pipeline. logRecovery('Chrome fallback unavailable; retrying with simplified HTML', [ 'exception' => $chromeError::class, ]); $doc->writeHtml($simplifiedHtml);
return; } }}
// --- Configure the degradation policy up front ---------------------------// Balanced (the default) warns on bounded degradation and throws only on a// blocking impact. A regulated workflow would choose DegradationPolicy::Strict.$config = (new Config())->withDegradationPolicy(DegradationPolicy::Balanced);$doc = Document::createStandalone($config);
$primaryHtml = '<h1>Quarterly report</h1><p>Body paragraph with rich styling.</p>';$simplifiedHtml = '<h1>Quarterly report</h1><p>Body paragraph (simplified).</p>';
$outputPath = getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf';
try { $doc->addPage(); $applied = applyFontWithFallback($doc, 'CorporateSans', 'helvetica'); $doc->cell(0, 12, 'Custom error recovery patterns', newLine: true);
renderHtmlWithRecovery($doc, $primaryHtml, $simplifiedHtml);
$doc->save($outputPath);
logRecovery('Document built', [ 'font_applied' => $applied, 'pages' => $doc->getNumPages(), 'has_warnings' => $doc->hasWarnings(), 'degraded_parity' => $doc->hasDegradedParity(), ]);} catch (DegradedException $e) { // BOUNDARY: DegradedException extends RuntimeException directly, NOT // NextPdfException, so the catch-all below would not have caught it. // Under Strict/Balanced policy a blocking degradation lands here. logRecovery('Capability degraded under the active policy; emitting a built partial', [ 'exception' => $e::class, 'capability' => $e->capability->id, 'status' => $e->capability->status->value, 'reason' => $e->capability->reason ?? 'unknown', 'policy' => $e->policy->value, ]); // STRATEGY 4 — partial-document recovery: save whatever pages exist. if ($doc->getNumPages() > 0) { $doc->save($outputPath); }} catch (WriterException $e) { // Serialization or I/O failure: the in-memory document is valid but could // not be written. Surface the stage so infrastructure can act on it. logRecovery('PDF write failed; document was valid in memory', [ 'exception' => $e::class, 'writer_state' => $e->getWriterState(), 'output_path' => $e->getOutputPath(), ]);} catch (NextPdfException $e) { // Catch-all for every other NextPDF\Exception\*. STRATEGY 4 again: if any // complete pages were built before the failure, emit them rather than // discarding the work. $context = ['exception' => $e::class, 'pages' => $doc->getNumPages()]; if ($e instanceof ContextAwareExceptionInterface) { $context += $e->getContext(); } logRecovery('Unrecovered NextPDF failure; attempting a partial save', $context); if ($doc->getNumPages() > 0) { $doc->save($outputPath); }}
fwrite(STDERR, "Recovery pipeline complete.\n");STDOUT はハーネス用に空けておきます。回復処理の診断情報は STDERR に送られ、PDF は NEXTPDF_COOKBOOK_OUTPUT にのみ書き込まれます。
エッジケースと注意点
「エッジケースと注意点」という見出しのセクション- catch ブロックは具体的なものから一般的なものへと並べます。 PHP は最初に一致する
catchを選びます。catch (NextPdfException $e)をcatch (WriterException $e)より前に配置すると、WriterExceptionがNextPdfExceptionを継承しているため、具体的なブロックがデッドコードになります。 DegradedExceptionは階層の外にあります。 これはRuntimeExceptionを継承しており、NextPdfExceptionは継承しません。NextPdfExceptionのみをキャッチするパイプラインでは、Strict ポリシーによる拒否がキャッチされずに伝播してしまいます。デフォルト以外の縮退ポリシーが有効な場合は、DegradedException(またはより広いRuntimeException)をキャッチします。- フォントのフォールバックも失敗する可能性があります。 フォールバック先のフェース自体が未登録の場合、2 回目の
setFont()も再びスローします。エンジンがファイルシステムを検索せずに resolve(解決)できるhelveticaのような Base14 エイリアスを使用するか、起動時にaddFontDirectory()でバンドル済みのフェースを登録して、フォールバックを確実にしてください。 getNumPages()はアクティブな未フラッシュのページを数えます。 ページが現在開いている場合は、フラッシュ済みのページ数に 1 を加えた値を返します。そのため「部分保存」には、失敗が発生した時点で構築中だったページが含まれます。これは通常、望ましい挙動です。完全に完成したページのみが必要な場合は、getPage()でも分岐してください。- Chrome フォールバックは可用性だけでなく忠実度も変えます。 インプロセスのパイプラインと Chrome ブリッジは異なるレイアウトエンジンを使用するため、Chrome にフォールバックしたドキュメントは見た目が異なる場合があります。フォールバックは透過的な代替ではなく回復処理として扱い、どの経路が出力を生成したかを記録してください。
- リトライには既知の正常な入力を使用する必要があります。 簡素化された HTML でのリトライが役立つのは、簡素化されたバリアントが本当に簡素な場合だけです。つまり、ネストされたセレクターが少なく、解決バジェットを使い果たす
:has()のチェーンがない場合です。すでに失敗したのと同じ入力でリトライしても、同じ例外を繰り返すだけです。 - 正常に完了した後でも警告を確認します。 スローせずに完了したレンダリングでも、縮退している可能性があります。出力をピクセル単位で忠実であるとみなす前に、
hasDegradedParity()を確認し、getWarnings()を読み取ってください。DegradationPolicy::Permissiveでは、すべての縮退が警告となり、例外になることは決してありません。
パフォーマンス
「パフォーマンス」という見出しのセクション- 回復処理がコストを追加するのは、失敗経路の場合のみです。NextPDF は例外的な状態でスローするため、正常なレンダリングでは周囲の
try/catchに対するコストは一切かかりません。 - レンダラーのフォールバックは、レンダリングを再実行します。インプロセスでの試行は破棄され、Chrome での試行は最初からやり直すため、フォールバックレンダリングのコストは、最悪の場合、両方のレンダリング時間に加えて Chrome へのプロセス間ラウンドトリップもかかります。リクエストのタイムアウトを設定する際は、その分を見込んでおいてください。
- 代替 HTML でのリトライでは、2 つ目のドキュメントを解析します。簡素化したバリアントを小さく保ち、リトライのコストが主要な試行に比べて安く済むようにしてください。
- 部分保存では、すでに構築済みのページをシリアライズします。そのコストは、失敗した処理ではなく、残存するページ数に比例します。
セキュリティに関する注意
「セキュリティに関する注意」という見出しのセクション- 生の例外メッセージやファイルシステムのパスをエンドユーザーに表示しないでください。
FontNotFoundExceptionのメッセージには検索したディレクトリが含まれ、WriterExceptionには出力パスが含まれます。どちらもサーバーの構成を漏らします。構造化されたコンテキストはサーバー側でログに記録し、呼び出し元には汎用的なメッセージを返してください。 - リトライする HTML は、毎回の試行で信頼できない入力として扱ってください。フォールバックと簡素化された HTML でのリトライは、どちらも同じ入力境界を通ります。インプロセスのパイプラインと Chrome ブリッジはそれぞれ独自の HTML セキュリティポリシーを適用し、リトライによってその検証が緩和されることはありません。「簡素化された」バリアントを自分で作成したからといって、より安全だと思い込まないでください。
- 部分保存でも、やはりファイルを書き込みます。完全な出力に適用するのと同じパス検証、権限、保存場所のルールを、部分的な出力にも適用してください。
Document::save()はストリームラッパーとヌルバイトを拒否し、パストラバーサルを防ぐために親ディレクトリを解決しますが、渡す宛先はご自身の責任です。
このレシピは、規範的な標準に関する主張を一切行いません。これは、公開された NextPDF の例外 API とドキュメント検査 API を回復処理の制御フローに組み合わせたものです。ISO 32000-2 やその他の標準で定義された動作を主張するものではないため、citations: ブロックは持ちません。
これはセマンティックな再現性プロファイルで検証されています。回復されたドキュメントには、保存のたびに再生成されるトレーラーの /ID と変更日が含まれるため、バイト単位の同一性は達成できません。構造的な AST とメタデータのみの比較は、実行ごとに安定しています。
- NextPDF の例外階層でエラーを処理する — キャッチの粒度と構造化されたコンテキスト。このページが土台とする基礎です。
- Exception モジュール — 完全な例外リファレンスです。
- Support モジュール —
DegradedException、Capability、Warning、そして縮退関連の型です。 - Config モジュール — 縮退ポリシーの設定です。
- 長時間稼働するワーカーで安全に PDF をレンダリングする — 共有レジストリを再利用するワーカーでの回復処理です。