Перейти к содержимому

Реализация собственных стратегий восстановления после ошибок и повторных попыток

Промышленный сервис генерации документов не ограничивается перехватом и журналированием исключений. Он решает, что делать дальше: продолжить с упрощённым результатом, переключиться на другой путь отрисовки, повторить попытку с входными данными, которые принимает движок, или выдать страницы, построенные до сбоя. Этот рецепт показывает четыре стратегии восстановления, основанные на иерархии исключений NextPDF и методах проверки состояния документа:

  • Плавная деградация при сбое со шрифтом — перехватите NextPDF\Exception\FontNotFoundException, переключитесь на гарантированный шрифт и продолжите построение документа.
  • Резервный рендерер — когда встроенный путь Document::writeHtml() отклоняет входные данные, повторите попытку через Document::writeHtmlChrome() — мост к Chrome из nextpdf/artisan.
  • Повторная попытка с альтернативным HTML — когда возникает NextPDF\Exception\HtmlParsingException или NextPDF\Exception\CssResolutionBudgetExceededException, повторите попытку с упрощённым, заведомо рабочим вариантом HTML.
  • Восстановление частичного документа — считайте Document::getNumPages() после сбоя и сохраните уже построенное, вместо того чтобы отбросить результат.

Вы уже знаете, как перехватывать исключения на нужном уровне. Сопутствующая страница Обработка ошибок с помощью иерархии исключений NextPDF описывает саму иерархию. Эта страница показывает, что делать после перехвата.

Этот рецепт рассчитан на редакцию core с открытым исходным кодом (OSS). Каждый упомянутый здесь интерфейс программирования приложений (API) находится в nextpdf/core. Единственная опциональная зависимость — nextpdf/artisan для резервного перехода на Chrome.

Окно терминала
composer require nextpdf/core:^3

Стратегия резервного рендерера дополнительно использует мост к 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\DegradationPolicyStrict или Balanced, перехватывайте DegradedException явно, чтобы восстановиться после него.

Документ можно проверять во время построения. Document раскрывает своё состояние построения через методы доступа только для чтения. getNumPages() возвращает общее число страниц, включая активную несброшенную страницу, а 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): 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\DegradationPolicyStrict, 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, повторную попытку с альтернативным 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, а файл Portable Document Format (PDF) записывается только в NEXTPDF_COOKBOOK_OUTPUT.

  • Упорядочивайте блоки catch от частного к общему. PHP сопоставляет первый совместимый catch. Размещение catch (NextPdfException $e) перед catch (WriterException $e) превращает частный блок в недостижимый код, потому что WriterException наследуется от NextPdfException.
  • DegradedException находится вне иерархии. Он наследуется от RuntimeException, а не от NextPdfException. Конвейер, перехватывающий только NextPdfException, пропускает отказ строгой политики наружу. Перехватывайте DegradedException (или более общий RuntimeException), когда активна недефолтная политика деградации.
  • Переход на резервный шрифт тоже может завершиться сбоем. Если резервный шрифт сам не зарегистрирован, второй вызов setFont() снова выбрасывает исключение. Используйте псевдоним Base14, например helvetica, который движок разрешает без обращения к файловой системе, либо зарегистрируйте поставляемый в комплекте шрифт через addFontDirectory() при запуске, чтобы резервный вариант был гарантирован.
  • getNumPages() учитывает активную несброшенную страницу. Он возвращает число сброшенных страниц плюс одну, когда в данный момент открыта страница. “Частичное сохранение” включает страницу, которая строилась в момент сбоя, что обычно и требуется. Если вам нужны только полностью завершённые страницы, дополнительно ветвитесь по getPage().
  • Переход на Chrome меняет точность, а не только доступность. Встроенный конвейер и мост к Chrome используют разные движки макета, поэтому документ, перешедший на Chrome, может выглядеть иначе. Рассматривайте резервный переход как восстановление, а не как прозрачную замену, и фиксируйте, какой путь сформировал вывод.
  • Повторная попытка должна использовать заведомо рабочие входные данные. Повторная попытка с упрощённым HTML помогает только тогда, когда упрощённый вариант действительно проще: меньше вложенных селекторов, нет цепочек :has(), исчерпывающих бюджет разрешения. Повторная попытка с теми же входными данными, которые уже привели к сбою, зациклится на том же исключении.
  • Проверяйте предупреждения после успешного прогона. Отрисовка, завершившаяся без исключения, всё же могла пройти с деградацией. Проверьте hasDegradedParity() и считайте getWarnings(), прежде чем считать вывод попиксельно точным; при DegradationPolicy::Permissive каждая деградация — это предупреждение, а не исключение.
  • Восстановление добавляет затраты только на пути сбоя. NextPDF выбрасывает исключение в исключительных состояниях, поэтому обычная отрисовка ничего не платит за окружающий try/catch.
  • Переход на резервный рендерер повторно запускает отрисовку. Встроенная попытка отбрасывается, а попытка через Chrome начинается заново, поэтому резервная отрисовка в худшем случае стоит времени обеих отрисовок плюс межпроцессный обмен с Chrome. Учитывайте это при настройке таймаутов запросов.
  • Повторная попытка с альтернативным HTML разбирает второй документ. Делайте упрощённый вариант небольшим, чтобы повторная попытка была дешёвой по сравнению с основной.
  • Частичное сохранение сериализует уже построенные страницы. Его стоимость растёт с числом сохранившихся страниц, а не с объёмом работы, завершившейся сбоем.
  • Не показывайте конечным пользователям необработанные сообщения исключений или пути файловой системы. Сообщение FontNotFoundException включает каталоги, в которых выполнялся поиск, а WriterException включает путь вывода; оба раскрывают структуру сервера. Журналируйте структурированный контекст на стороне сервера и возвращайте вызывающей стороне обобщённое сообщение.
  • Рассматривайте HTML при повторной попытке как недоверенные входные данные при каждой попытке. И резервный переход, и повторная попытка с упрощённым HTML проходят через одну и ту же границу ввода; встроенный конвейер и мост к Chrome применяют собственные политики безопасности HTML, и повторная попытка не ослабляет эту проверку. Не считайте “упрощённый” вариант более безопасным только потому, что вы его создали.
  • Частичное сохранение всё равно записывает файл. Применяйте к частичному выводу те же правила проверки пути, разрешений и места хранения, что и к полному. Document::save() отклоняет обёртки потоков и нулевые байты и разрешает родительский каталог, чтобы заблокировать обход пути, но место назначения, которое вы передаёте, остаётся вашей ответственностью.

Этот рецепт не заявляет соответствие нормативным стандартам. Он объединяет публичные API исключений и проверки документа NextPDF в поток управления восстановлением; он не утверждает поведение, определённое ISO 32000-2 или каким-либо другим стандартом, поэтому не содержит блока citations:.

Эта страница проверена с профилем воспроизводимости semantic. Восстановленный документ содержит в трейлере /ID и дату изменения, которые регенерируются при каждом сохранении, поэтому побайтовая идентичность недостижима. Сравнение структурного абстрактного синтаксического дерева (AST) вместе только с метаданными стабильно между запусками.