实现自定义错误恢复与重试策略
重点速览
标题为“重点速览”的章节生产级文档服务不只是捕捉异常并记录下来。它会决定下一步该怎么做:以降级结果继续、切换到第二条渲染路径、改用引擎能够接受的输入重试,或在交付失败前保存已生成的那些页面。这则 recipe(示例)演示四种建立在 NextPDF 异常层级与文档状态检查方法之上的恢复策略:
- 字体失败时的优雅降级 — 捕捉
NextPDF\Exception\FontNotFoundException,回退到一个必定可用的字体,让文档继续生成。 - 回退用的 renderer — 当进程内的
Document::writeHtml()路径拒绝输入时,改通过Document::writeHtmlChrome()(也就是nextpdf/artisan的 Chrome 桥接)重试。 - 改用替代 HTML 重试 — 当
NextPDF\Exception\HtmlParsingException或NextPDF\Exception\CssResolutionBudgetExceededException触发时,改用一份已知可行的简化 HTML 变体重试。 - 局部文档恢复 — 在失败后读取
Document::getNumPages(),保存已生成的内容,而不是丢弃整份文档。
你已经知道如何在正确的粒度上捕捉异常。配套阅读的 使用 NextPDF 异常层级处理错误 一页介绍层级本身。这一页关注的是你在捕捉之后该做什么。
这则 recipe 面向 OSS 核心版。这里提到的每一个 API 都位于 nextpdf/core 中。唯一的可选依赖包是用于 Chrome 回退机制的 nextpdf/artisan。
composer require nextpdf/core:^3回退 renderer(渲染器)的策略另外还会用到 Chrome 桥接:
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\DegradationPolicy是Strict或Balanced时,必须明确捕捉DegradedException才能从中恢复。
文档在构建过程中就可以检查。 Document 会通过只读访问器公开它的构建状态。getNumPages() 返回包含当前尚未 flush 页面在内的总页数,而 getPage() 返回当前页面从零开始的索引。构建中途发生失败后,读取 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');代码示例 — 生产环境
标题为“代码示例 — 生产环境”的章节完整示例把四种策略全部接入同一条渲染管线:字体回退、从进程内路径回退到 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 加上仅比对元数据的方式,则可在多次执行之间保持稳定。
另请参阅
标题为“另请参阅”的章节- 使用 NextPDF 异常层级处理错误 — 捕捉粒度与结构化上下文,是这一页所依据的基础。
- Exception 模块 — 完整的异常参考。
- Support 模块 —
DegradedException、Capability、Warning,以及各种降级类型。 - Config 模块 — 降级政策设置。
- 在长时间运行的 worker 中安全渲染 PDF — 在重用共享注册表的 worker 中进行恢复。