Skip to content

Implement custom error recovery and retry strategies

A production document service does more than catch and log an exception. It decides what to do next: continue with degraded output, switch to a second rendering path, retry with input the engine accepts, or deliver the pages built before the failure. This recipe shows four recovery strategies built on the NextPDF exception hierarchy and document state-inspection methods:

  • Graceful degradation on a font failure — catch NextPDF\Exception\FontNotFoundException, fall back to a guaranteed face, and keep building the document.
  • A fallback renderer — when the in-process Document::writeHtml() path rejects input, retry through Document::writeHtmlChrome(), the nextpdf/artisan Chrome bridge.
  • Retry with alternative HTML — when NextPDF\Exception\HtmlParsingException or NextPDF\Exception\CssResolutionBudgetExceededException occurs, retry with a simplified, known-good HTML variant.
  • Partial-document recovery — read Document::getNumPages() after a failure, and save what was already built instead of discarding it.

You already know how to catch at the right level. The companion page Handle errors with the NextPDF exception hierarchy covers the hierarchy itself. This page shows what you do after the catch.

This recipe targets the open source software (OSS) core edition. Every application programming interface (API) named here lives in nextpdf/core. The only optional dependency is nextpdf/artisan for the Chrome fallback.

Terminal window
composer require nextpdf/core:^3

The fallback-renderer strategy additionally uses the Chrome bridge:

Terminal window
composer require nextpdf/artisan

When nextpdf/artisan is absent, Document::writeHtmlChrome() throws NextPDF\Exception\PageLayoutException instead of rendering. The fallback strategy below treats a missing bridge as another recoverable case.

Recovery depends on two facts about NextPDF, both verified against the source.

The exception hierarchy tells you what is recoverable. Every domain exception extends the abstract base NextPDF\Exception\NextPdfException, which extends RuntimeException and implements NextPDF\Contracts\ContextAwareExceptionInterface. Catch a specific subtype to choose a recovery path for that failure:

  • FontNotFoundException carries getFontName(), getSearchPaths(), and wasFallbackAttempted() — enough to retry with a different face.
  • HtmlParsingException carries getRule(), getPosition(), and getHtmlSnippet() — enough to decide whether a simplified retry is worth attempting.
  • CssResolutionBudgetExceededException carries getVisits() and getBudget() — a signal that a stripped-down stylesheet may clear a pathological selector.
  • One important boundary: NextPDF\Support\DegradedException extends RuntimeException directly, not NextPdfException. So catch (NextPdfException $e) does not catch a degradation-policy rejection. When the active NextPDF\Contracts\DegradationPolicy is Strict or Balanced, catch DegradedException explicitly to recover from it.

The document is inspectable while you build it. A Document exposes its construction state through read-only accessors. getNumPages() returns the total page count, including the active unflushed page, and getPage() returns the zero-based index of the current page. After a mid-build failure, read getNumPages() to learn whether any complete pages exist, then call save() or getPdfData() to emit them. The engine also records non-fatal degradation events: getWarnings() returns a list<NextPDF\Support\Warning>, hasWarnings() reports whether any were collected, and hasDegradedParity() reports whether output fidelity was affected. These methods let a recovery routine distinguish “succeeded cleanly” from “succeeded with reduced fidelity” without parsing an exception.

The degradation policy controls which events you handle as exceptions and which you handle as warnings. NextPDF\Core\Config defaults to DegradationPolicy::Balanced, which warns and continues for bounded degradation but throws on a blocking impact. DegradationPolicy::Permissive never throws and collects everything in the warning channel. DegradationPolicy::Strict throws on any compliance-risk, semantic-loss, or blocking impact. Choose the policy first, then write recovery for the failure shapes that policy produces.

The recovery code below uses these verified members:

  • 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 and the degradationPolicy default of DegradationPolicy::Balanced.
  • NextPDF\Contracts\DegradationPolicyStrict, Balanced, Permissive.
  • NextPDF\Exception\NextPdfException (abstract base), NextPDF\Exception\FontNotFoundException, NextPDF\Exception\HtmlParsingException, NextPDF\Exception\CssResolutionBudgetExceededException, NextPDF\Exception\WriterException, NextPDF\Exception\PageLayoutException.
  • NextPDF\Support\DegradedException (carrying capability and policy), NextPDF\Support\Capability (id, status, reason, isDegraded()), NextPDF\Support\Warning, NextPDF\Support\WarningSeverity.

The smallest useful recovery catches a missing-font failure, falls back to a guaranteed face, and continues. This snippet leaves out the broader handling from the production sample. For a complete handler with logging and the DegradedException boundary, read the production sample below.

<?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');

The full example connects all four strategies in one render pipeline: a font fallback, a renderer fallback from the in-process path to Chrome, an alternative-HTML retry, and partial-document recovery driven by getNumPages(). It honors the harness output channel and never catches a bare Exception or leaves a catch block empty.

<?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 stays free for the harness. Recovery diagnostics go to STDERR, and the Portable Document Format (PDF) file is written only to NEXTPDF_COOKBOOK_OUTPUT.

  • Order catch blocks from specific to general. PHP matches the first compatible catch. Placing catch (NextPdfException $e) before catch (WriterException $e) turns the specific block into dead code, because WriterException extends NextPdfException.
  • DegradedException sits outside the hierarchy. It extends RuntimeException, not NextPdfException. A pipeline that catches only NextPdfException lets a strict-policy rejection propagate uncaught. Catch DegradedException (or a broader RuntimeException) when a non-default degradation policy is active.
  • A font fallback can fail too. If your fallback face is itself unregistered, the second setFont() throws again. Use a Base14 alias such as helvetica, which the engine resolves without a filesystem lookup, or register a bundled face through addFontDirectory() at startup so the fallback is guaranteed.
  • getNumPages() counts the active unflushed page. It returns the flushed page count plus one when a page is currently open. A “partial save” includes the page that was being built when the failure occurred, which is usually what you want. If you need only fully completed pages, branch on getPage() as well.
  • The Chrome fallback changes fidelity, not only availability. The in-process pipeline and the Chrome bridge use different layout engines, so a document that falls back to Chrome can look different. Treat the fallback as a recovery, not a transparent substitute, and record which path produced the output.
  • A retry must use known-good input. The simplified-HTML retry only helps when the simplified variant is genuinely simpler: fewer nested selectors, no :has() chains that exhaust the resolution budget. Retrying with the same input that already failed loops to the same exception.
  • Inspect warnings after a clean run. A render that returns without throwing can still have degraded. Check hasDegradedParity() and read getWarnings() before you treat the output as pixel-faithful; under DegradationPolicy::Permissive every degradation is a warning, never an exception.
  • Recovery adds cost only on the failure path. NextPDF throws on exceptional states, so a clean render pays nothing for the surrounding try/catch.
  • A renderer fallback re-runs the render. The in-process attempt is discarded and the Chrome attempt starts fresh, so a fallback render costs, in the worst case, both render times plus the inter-process round trip to Chrome. Budget for it when you set request timeouts.
  • An alternative-HTML retry parses a second document. Keep the simplified variant small so the retry is cheap relative to the primary attempt.
  • A partial save serializes the pages already built. Its cost scales with the surviving page count, not with the work that failed.
  • Do not show raw exception messages or filesystem paths to end users. A FontNotFoundException message includes the searched directories and a WriterException includes the output path; both leak server layout. Log the structured context server-side and return a generic message to the caller.
  • Treat retried HTML as untrusted input on every attempt. The fallback and the simplified-HTML retry both flow through the same input boundary; the in-process pipeline and the Chrome bridge each apply their own HTML security policy, and a retry does not relax that validation. Do not assume a “simplified” variant is safer because you authored it.
  • A partial save still writes a file. Apply the same path validation, permissions, and storage-location rules to a partial output that you apply to a complete one. Document::save() rejects stream wrappers and null bytes and resolves the parent directory to block path traversal, but the destination you pass is your responsibility.

This recipe makes no normative standards claim. It composes the public NextPDF exception and document-inspection APIs into recovery control flow; it does not assert behavior defined by ISO 32000-2 or any other standard, so it carries no citations: block.

This page is verified with the semantic reproducibility profile. The recovered document carries a trailer /ID and a modification date that are regenerated per save, so byte identity is not achievable. The structural abstract syntax tree (AST) plus metadata-only comparison is stable across runs.