跳转到内容

实现自定义错误恢复与重试策略

生产级文档服务不只是捕捉异常并记录下来。它会决定下一步该怎么做:以降级结果继续、切换到第二条渲染路径、改用引擎能够接受的输入重试,或在交付失败前保存已生成的那些页面。这则 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() 返回包含当前尚未 flush 页面在内的总页数,而 getPage() 返回当前页面从零开始的索引。构建中途发生失败后,读取 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() 会把当前尚未 flush 的页面也算进去。 当目前有一页开启时,它返回已 flush 的页数再加一。因此「局部保存」会包含失败发生当下正在构建的那一页,这通常正是你想要的。如果你只需要完全构建好的页面,请同时以 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 加上仅比对元数据的方式,则可在多次执行之间保持稳定。