콘텐츠로 이동

사용자 정의 오류 복구 및 재시도 전략 구현하기

프로덕션 문서 서비스는 예외를 잡아 기록하는 것 이상의 일을 합니다. 다음에 무엇을 할지 결정합니다. 저하된 결과를 허용해 계속 진행하거나, 두 번째 렌더링 경로로 전환하거나, 엔진이 허용하는 입력으로 재시도하거나, 실패 전에 이미 만들어진 페이지라도 전달합니다. 이 레시피는 NextPDF 예외 계층과 문서 상태 검사 메서드를 기반으로 구축한 네 가지 복구 전략을 보여 줍니다.

  • 글꼴 실패 시 단계적 저하NextPDF\Exception\FontNotFoundException을 잡고, 항상 사용할 수 있는 서체로 폴백한 뒤 문서 빌드를 계속 진행합니다.
  • 폴백 렌더러 — 인프로세스 Document::writeHtml() 경로가 입력을 거부하면, Document::writeHtmlChrome()(nextpdf/artisan Chrome 브리지)을 통해 재시도합니다.
  • 대체 HTML로 재시도NextPDF\Exception\HtmlParsingException 또는 NextPDF\Exception\CssResolutionBudgetExceededException이 발생하면, 단순화되고 검증된 HTML 변형으로 재시도합니다.
  • 부분 문서 복구 — 실패 후 Document::getNumPages()를 읽고, 이미 생성된 내용을 버리는 대신 저장합니다.

적절한 세분화 수준으로 예외를 잡는 방법은 이미 알고 있습니다. 관련 페이지인 NextPDF 예외 계층으로 오류 처리하기에서는 계층 자체를 다룹니다. 이 페이지는 예외를 잡은 후에 무엇을 하는지를 다룹니다.

이 레시피는 OSS core 에디션을 대상으로 합니다. 여기에 명시된 모든 API는 nextpdf/core에 있습니다. 유일한 선택적 종속성은 Chrome 폴백을 위한 nextpdf/artisan입니다.

Terminal window
composer require nextpdf/core:^3

폴백 렌더러 전략에는 추가로 Chrome 브리지가 필요합니다.

Terminal window
composer require nextpdf/artisan

만약 nextpdf/artisan이 없다면 Document::writeHtmlChrome()는 렌더링하는 대신 NextPDF\Exception\PageLayoutException을 던지므로, 아래 폴백 전략은 누락된 브리지를 또 하나의 복구 가능한 경우로 취급합니다.

복구는 NextPDF에 대한 두 가지 사실을 기반으로 하며, 둘 다 소스에서 검증되었습니다.

예외 계층은 무엇이 복구 가능한지 알려 줍니다. 모든 도메인 예외는 추상 기본 클래스 NextPDF\Exception\NextPdfException을 확장하며, 이 클래스는 RuntimeException을 확장하고 NextPDF\Contracts\ContextAwareExceptionInterface를 구현합니다. 특정 하위 유형을 잡아 실패에 맞는 복구 경로를 선택하세요.

  • FontNotFoundExceptiongetFontName(), getSearchPaths(), wasFallbackAttempted()을 제공합니다. 다른 서체로 재시도하기에 충분합니다.
  • HtmlParsingExceptiongetRule(), getPosition(), getHtmlSnippet()을 제공합니다. 단순화된 재시도를 시도할 가치가 있는지 판단하기에 충분합니다.
  • CssResolutionBudgetExceededExceptiongetVisits()getBudget()를 제공합니다. 간소화된 스타일시트로 해소할 수 있는 병리적인 선택자 신호입니다.
  • 중요한 경계가 하나 있습니다. NextPDF\Support\DegradedExceptionRuntimeException직접 확장하며, NextPdfException은 확장하지 않습니다. 따라서 catch (NextPdfException $e)는 저하 정책 거부를 잡지 않습니다. 활성 NextPDF\Contracts\DegradationPolicyStrict 또는 Balanced일 때는 DegradedException을 명시적으로 잡아 복구하세요.

문서는 빌드하는 동안 검사할 수 있습니다. Document는 읽기 전용 접근자를 통해 자체 구성 상태를 노출합니다. getNumPages()는 활성 상태의 미플러시 페이지를 포함한 전체 페이지 수를 반환하고, getPage()는 현재 페이지의 0부터 시작하는 인덱스를 반환합니다. 빌드 도중 실패한 후에는 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): selfdegradationPolicy 기본값인 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(capabilitypolicy를 담음), 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()로 구동되는 부분 문서 복구입니다. 하니스 출력 채널을 준수하며, 절대로 bare 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) 앞에 두면 구체적인 블록이 죽은 코드가 됩니다. WriterExceptionNextPdfException을 확장하기 때문입니다.
  • DegradedException은 계층 밖에 있습니다. 이것은 RuntimeException을 확장하며, NextPdfException은 확장하지 않습니다. NextPdfException만 잡는 파이프라인은 strict 정책 거부가 잡히지 않은 채 전파되도록 둡니다. 기본값이 아닌 저하 정책이 활성화된 경우 DegradedException(또는 더 넓은 RuntimeException)을 잡으세요.
  • 글꼴 폴백도 실패할 수 있습니다. 폴백 서체 자체가 등록되어 있지 않으면 두 번째 setFont()도 다시 예외를 던집니다. 엔진이 파일 시스템 조회 없이 해석하는 helvetica 같은 Base14 별칭을 사용하거나, 시작 시 addFontDirectory()를 통해 번들 서체를 등록하여 폴백을 보장하세요.
  • getNumPages()는 활성 상태의 미플러시 페이지를 셉니다. 페이지가 현재 열려 있을 때는 플러시된 페이지 수에 1을 더한 값을 반환합니다. 따라서 “부분 저장”에는 실패가 발생했을 때 빌드 중이던 페이지가 포함되며, 이는 보통 원하는 결과입니다. 완전히 완성된 페이지만 필요하다면 getPage()으로도 분기하세요.
  • Chrome 폴백은 가용성뿐만 아니라 충실도도 바꿉니다. 인프로세스 파이프라인과 Chrome 브리지는 서로 다른 레이아웃 엔진을 사용하므로, Chrome으로 폴백한 문서는 다르게 보일 수 있습니다. 폴백을 완전히 투명한 대체물이 아니라 복구로 취급하고, 어떤 경로가 출력을 만들어 냈는지 기록하세요.
  • 재시도는 검증된 입력을 사용해야 합니다. 단순화된 HTML 재시도는 단순화된 변형이 실제로 더 단순할 때만 도움이 됩니다. 예를 들어 중첩된 선택자가 더 적고, 해석 예산을 소진시키는 :has() 체인이 없어야 합니다. 이미 실패한 동일한 입력으로 재시도하면 같은 예외가 반복됩니다.
  • 정상 실행 후에도 경고를 검사하세요. 예외를 던지지 않고 반환된 렌더링도 저하되었을 수 있습니다. 출력을 픽셀 단위로 충실하다고 취급하기 전에 hasDegradedParity()를 확인하고 getWarnings()를 읽으세요. DegradationPolicy::Permissive에서는 모든 저하가 예외가 아니라 경고입니다.
  • 복구는 실패 경로에서만 비용을 추가합니다. NextPDF는 예외 상태에서 예외를 던지므로, 정상 렌더링은 둘러싼 try/catch에 대해 실질적인 비용을 지불하지 않습니다.
  • 렌더러 폴백은 렌더링을 다시 실행합니다. 인프로세스 시도는 폐기되고 Chrome 시도는 처음부터 시작되므로, 폴백 렌더링은 최악의 경우 두 번의 렌더링 시간에 Chrome으로의 프로세스 간 왕복 비용을 더한 만큼의 비용이 듭니다. 요청 타임아웃을 설정할 때 이 비용을 고려하세요.
  • 대체 HTML 재시도는 두 번째 문서를 구문 분석합니다. 단순화된 변형을 작게 유지하여 재시도가 기본 시도에 비해 저렴하도록 하세요.
  • 부분 저장은 이미 만들어진 페이지를 직렬화합니다. 이 비용은 실패한 작업이 아니라 살아남은 페이지 수에 비례합니다.
  • 원시 예외 메시지나 파일 시스템 경로를 최종 사용자에게 노출하지 마세요. FontNotFoundException 메시지에는 검색한 디렉터리가 포함되고 WriterException에는 출력 경로가 포함되므로, 둘 다 서버 레이아웃을 노출합니다. 구조화된 컨텍스트는 서버 측에 기록하고 호출자에게는 일반적인 메시지를 반환하세요.
  • 재시도된 HTML은 매 시도마다 신뢰할 수 없는 입력으로 취급하세요. 폴백과 단순화된 HTML 재시도는 모두 동일한 입력 경계를 통과합니다. 인프로세스 파이프라인과 Chrome 브리지는 각각 자체 HTML 보안 정책을 적용하며, 재시도는 그 검증을 완화하지 않습니다. “단순화된” 변형을 직접 작성했다고 해서 더 안전하다고 가정하지 마세요.
  • 부분 저장도 여전히 파일을 기록합니다. 완전한 출력에 적용하는 것과 동일한 경로 검증, 권한, 저장 위치 규칙을 부분 출력에도 적용하세요. Document::save()는 스트림 래퍼와 널 바이트를 거부하고 경로 탐색을 차단하기 위해 상위 디렉터리를 해석하지만, 전달하는 대상은 사용자의 책임입니다.

이 레시피는 규범적 표준 주장을 하지 않습니다. 이는 공개된 NextPDF 예외 및 문서 검사 API를 복구 제어 흐름으로 구성합니다. ISO 32000-2나 다른 표준에서 정의한 동작을 주장하지 않으므로, citations: 블록을 포함하지 않습니다.

이는 semantic 재현성 프로파일로 검증됩니다. 복구된 문서에는 저장할 때마다 다시 생성되는 트레일러 /ID와 수정 날짜가 들어 있으므로, 바이트 단위 동일성은 달성할 수 없습니다. 구조적 AST와 메타데이터 전용 비교는 실행 간에 안정적입니다.