跳到內容

實作自訂錯誤復原與重試策略

生產級文件服務不只是捕捉例外並記錄下來而已。它會決定下一步該怎麼做:以降級結果繼續、切換到第二條渲染路徑、改用引擎能接受的輸入重試,或交付失敗前已建好的頁面。這則 recipe(範例)示範四種建立在 NextPDF 例外階層與文件狀態檢視方法之上的復原策略:

  • 字型失敗時的優雅降級 — 捕捉 NextPDF\Exception\FontNotFoundException,退回到必定可用的字型,讓文件繼續建立。
  • 退回用的 renderer — 當行程內的 Document::writeHtml() 路徑拒絕輸入時,改透過 Document::writeHtmlChrome()(也就是 nextpdf/artisan 的 Chrome 橋接)重試。
  • 改用替代 HTML 重試 — 當 NextPDF\Exception\HtmlParsingExceptionNextPDF\Exception\CssResolutionBudgetExceededException 觸發時,改用一份已知可行的簡化 HTML 變體來重試。
  • 局部文件復原 — 在失敗後讀取 Document::getNumPages(),保存已建好的內容,而不是整份丟棄。

你已經知道如何在正確的粒度上捕捉例外。搭配閱讀的 用 NextPDF 例外階層處理錯誤 會說明階層本身。這一頁要說明的是你在捕捉之後該做什麼。

這則 recipe 針對 OSS 核心版。這裡提到的每一個 API 都位於 nextpdf/core 中。唯一的選用相依套件是用於 Chrome 退回機制的 nextpdf/artisan

Terminal window
composer require nextpdf/core:^3

退回 renderer(渲染器)的策略另外還會用到 Chrome 橋接:

Terminal window
composer require nextpdf/artisan

nextpdf/artisan 不存在時,Document::writeHtmlChrome() 會丟出 NextPDF\Exception\PageLayoutException 而非進行渲染,因此下方的退回策略會把缺少橋接也當成另一種可復原的情況來處理。

復原機制建立在 NextPDF 的兩項事實之上,兩者都已對照原始碼驗證過。

例外階層會告訴你哪些是可復原的。 每一個領域例外都繼承自抽象基底 NextPDF\Exception\NextPdfException,而它又繼承 RuntimeException 並實作 NextPDF\Contracts\ContextAwareExceptionInterface。捕捉特定子型別,就能針對該失敗選擇對應的復原路徑:

  • FontNotFoundException 帶有 getFontName()getSearchPaths()wasFallbackAttempted() — 足以改用另一個字型重試。
  • HtmlParsingException 帶有 getRule()getPosition()getHtmlSnippet() — 足以判斷是否值得嘗試簡化重試。
  • CssResolutionBudgetExceededException 帶有 getVisits()getBudget() — 這是病態選擇器的訊號,可透過精簡過的樣式表清除。
  • 有一條重要界線:NextPDF\Support\DegradedException 繼承的是 RuntimeException,而且是直接繼承,並非 NextPdfException。所以 catch (NextPdfException $e) 並不會捕捉到降級政策的拒絕。當作用中的 NextPDF\Contracts\DegradationPolicyStrictBalanced 時,要明確捕捉 DegradedException 才能從中復原。

文件在建立過程中就可以檢視。 Document 會透過唯讀存取子公開它的建構狀態。getNumPages() 回傳包含作用中未沖出頁面在內的總頁數,而 getPage() 回傳目前頁面從零起算的 Index(索引)。在建構中途發生失敗後,讀取 getNumPages() 即可得知是否存在任何完整頁面,接著呼叫 save()getPdfData() 將它們輸出。引擎也會記錄非致命的降級事件:getWarnings() 回傳一個 list<NextPDF\Support\Warning>hasWarnings() 回報是否有收集到任何警告,而 hasDegradedParity() 回報輸出保真度是否受影響。這些方法讓復原常式不必剖析任何例外,就能分辨「乾淨地成功」與「以較低保真度成功」。

降級政策決定哪些事件要當成例外處理、哪些要當成警告處理。NextPDF\Core\Config 預設為 DegradationPolicy::Balanced,它會對有界降級發出警告並繼續,但在發生阻斷性影響時丟出例外。DegradationPolicy::Permissive 從不丟出例外,並把一切都收集到警告通道。DegradationPolicy::Strict 會在任何合規風險、語意遺失或阻斷性影響時丟出例外。先選定政策,再針對該政策會產生的失敗型態撰寫復原邏輯。

下方的復原程式碼會用到這些已驗證的成員:

  • NextPDF\Core\Document::createStandalone(?Config $config = null): selfaddPage()setFont(string $family, string $style = '', float $size = 12.0): staticcell(...)writeHtml(string $html): staticwriteHtmlChrome(string $html, ?float $width = null, ?float $height = null): staticsave(string $path): voidgetPdfData(): stringgetNumPages(): intgetPage(): intgetWarnings(): list<Warning>hasWarnings(): boolhasDegradedParity(): booladdFontDirectory(string $directory): static
  • NextPDF\Core\Config::withDegradationPolicy(DegradationPolicy $policy): self,以及 degradationPolicy 的預設值 DegradationPolicy::Balanced
  • NextPDF\Contracts\DegradationPolicyStrictBalancedPermissive
  • NextPDF\Exception\NextPdfException(抽象基底)、NextPDF\Exception\FontNotFoundExceptionNextPDF\Exception\HtmlParsingExceptionNextPDF\Exception\CssResolutionBudgetExceededExceptionNextPDF\Exception\WriterExceptionNextPDF\Exception\PageLayoutException
  • NextPDF\Support\DegradedException(帶有 capabilitypolicy)、NextPDF\Support\CapabilityidstatusreasonisDegraded())、NextPDF\Support\WarningNextPDF\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');

完整範例會把四種策略全部接進同一條渲染管線:字型退回、從行程內路徑退回到 Chrome renderer、替代 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 的管線,會讓嚴格政策的拒絕未經捕捉就向外傳播。當有非預設的降級政策作用時,要捕捉 DegradedException(或範圍更廣的 RuntimeException)。
  • 字型退回也可能失敗。 如果你的退回字型本身未註冊,第二次 setFont() 會再次丟出例外。請使用像 helvetica 這類 Base14 別名,讓引擎不必查詢檔案系統即可 resolve(解析);或在啟動時透過 addFontDirectory() 註冊隨附字型,以保證退回必定可用。
  • getNumPages() 會把作用中尚未沖出的頁面也算進去。 當目前有一頁開啟時,它回傳已沖出的頁數再加一。因此「局部儲存」會包含失敗發生當下正在建立的那一頁,這通常正是你想要的。如果你只需要完全建好的頁面,請一併以 getPage() 做分支判斷。
  • Chrome 退回機制改變的是保真度,而不只是可用性。 行程內管線與 Chrome 橋接使用不同的版面引擎,所以退回到 Chrome 的文件外觀可能會不同。請把退回視為一種復原,而非透明的替代品,並記錄是哪一條路徑產生了輸出。
  • 重試必須使用已知可行的輸入。 簡化 HTML 重試只有在簡化變體真的比較簡單時才有幫助 — 例如更少的巢狀選擇器、沒有會耗盡解析預算的 :has() 串接。用已經失敗過的同一份輸入重試,只會迴圈到同一個例外。
  • 乾淨執行後也要檢查警告。 一次未丟出例外就回傳的渲染,仍然可能已經降級。在你把輸出當成像素精確之前,先檢查 hasDegradedParity() 並讀取 getWarnings();在 DegradationPolicy::Permissive 之下,每一次降級都是警告,而非例外。
  • 復原只會在失敗路徑上增加成本。NextPDF 只在例外狀態下丟出例外,所以一次乾淨的渲染不會為周圍的 try/catch 付出任何代價。
  • 一次 renderer 退回會重跑整個渲染。行程內的嘗試會被丟棄,而 Chrome 的嘗試會從頭開始,所以最壞情況下,一次退回渲染的成本是兩段渲染時間再加上對 Chrome 的跨行程往返。設定請求逾時時,要把這部分一併納入預算。
  • 一次替代 HTML 重試會剖析第二份文件。請讓簡化變體保持精簡,使重試相對於主要嘗試而言成本低廉。
  • 一次局部儲存會序列化已經建好的頁面。它的成本隨倖存頁數而增減,而不是隨失敗的那部分工作而定。
  • 不要把原始的例外訊息或檔案系統路徑暴露給終端使用者。一則 FontNotFoundException 訊息包含搜尋過的目錄,而 WriterException 包含輸出路徑;兩者都會洩漏伺服器的佈局。請在伺服器端記錄結構化的脈絡資訊,並對呼叫端回傳一則通用訊息。
  • 每一次嘗試都要把重試的 HTML 當成不受信任的輸入。退回與簡化 HTML 重試都會流經同一個輸入邊界;行程內管線與 Chrome 橋接各自套用自己的 HTML 安全政策,而重試並不會放寬該驗證。不要因為「簡化」變體是你自己寫的,就假設它比較安全。
  • 局部儲存仍然會寫出一個檔案。請對局部輸出套用與完整輸出相同的路徑驗證、權限以及儲存位置規則。Document::save() 會拒絕串流包裝器與 null 位元組,並解析父目錄以阻擋路徑穿越,但你傳入的目的地仍是你自己的責任。

這則 recipe 不提出任何規範性的標準主張。它把公開的 NextPDF 例外與文件檢視 API 組合成復原控制流;它並未主張任何由 ISO 32000-2 或其他標準定義的行為,因此不帶任何 citations: 區塊。

它以語意可重現性設定檔進行驗證。復原後的文件帶有每次儲存都會重新產生的 trailer /ID 與修改日期,因此無法達成位元組層級的相同。以結構化 AST 加上僅比對中介資料的方式,則可跨多次執行保持穩定。