Aller au contenu

Mettre en place une récupération après erreur et des stratégies de réessai sur mesure

Un service de documents de production ne se contente pas d’intercepter une exception et de la journaliser. Il décide quoi faire ensuite : continuer avec un résultat dégradé, basculer vers un second chemin de rendu, réessayer avec une entrée que le moteur accepte, ou livrer les pages déjà construites avant l’échec. Cette recette présente quatre stratégies de récupération bâties sur la hiérarchie d’exceptions NextPDF et les méthodes d’inspection de l’état du document :

  • Dégradation contrôlée lors d’un échec de police — intercepte NextPDF\Exception\FontNotFoundException, rabats-toi sur une fonte garantie et poursuis la construction du document.
  • Un renderer de repli — lorsque le chemin in-process Document::writeHtml() rejette l’entrée, réessaie via Document::writeHtmlChrome(), le pont Chrome de nextpdf/artisan.
  • Réessai avec un HTML alternatif — lorsque NextPDF\Exception\HtmlParsingException ou NextPDF\Exception\CssResolutionBudgetExceededException est déclenchée, réessaie avec une variante HTML simplifiée et éprouvée.
  • Récupération de document partiel — lis Document::getNumPages() après un échec et sauvegarde ce qui a déjà été construit au lieu de le jeter.

Tu sais déjà intercepter à la bonne granularité. La page compagnon Gérer les erreurs avec la hiérarchie d’exceptions NextPDF couvre la hiérarchie elle-même. Cette page couvre ce que tu fais après l’interception.

Cette recette vise l’édition cœur OSS. Chaque API mentionnée ici se trouve dans nextpdf/core. La seule dépendance optionnelle est nextpdf/artisan pour le repli Chrome.

Fenêtre de terminal
composer require nextpdf/core:^3

La stratégie du renderer de repli utilise aussi le pont Chrome :

Fenêtre de terminal
composer require nextpdf/artisan

Lorsque nextpdf/artisan est absent, Document::writeHtmlChrome() lève NextPDF\Exception\PageLayoutException au lieu d’effectuer le rendu ; la stratégie de repli ci-dessous traite donc un pont manquant comme un cas récupérable supplémentaire.

La récupération repose sur deux faits concernant NextPDF, tous deux vérifiés dans le code source.

La hiérarchie d’exceptions t’indique ce qui est récupérable. Chaque exception métier étend la base abstraite NextPDF\Exception\NextPdfException, qui étend RuntimeException et implémente NextPDF\Contracts\ContextAwareExceptionInterface. Intercepte un sous-type spécifique pour choisir un chemin de récupération adapté à l’échec :

  • FontNotFoundException porte getFontName(), getSearchPaths(), et wasFallbackAttempted() — ce qui permet de réessayer avec une fonte différente.
  • HtmlParsingException porte getRule(), getPosition(), et getHtmlSnippet() — ce qui permet de décider si un réessai simplifié vaut la peine d’être tenté.
  • CssResolutionBudgetExceededException porte getVisits() et getBudget() — ce qui signale un sélecteur pathologique qu’une feuille de style allégée peut corriger.
  • Limite importante : NextPDF\Support\DegradedException étend RuntimeException directement, et non NextPdfException. Par conséquent, catch (NextPdfException $e) n’intercepte pas un rejet de politique de dégradation. Lorsque la NextPDF\Contracts\DegradationPolicy active vaut Strict ou Balanced, intercepte explicitement DegradedException pour pouvoir récupérer.

Le document est inspectable pendant que tu le construis. Un Document expose son état de construction via des accesseurs en lecture seule. getNumPages() renvoie le nombre total de pages, y compris la page active non vidée, et getPage() renvoie l’index basé sur zéro de la page courante. Après un échec en cours de construction, lis getNumPages() pour savoir s’il existe des pages complètes, puis appelle save() ou getPdfData() pour les émettre. Le moteur enregistre aussi les événements de dégradation non fatals : getWarnings() renvoie une list<NextPDF\Support\Warning>, hasWarnings() indique si des avertissements ont été collectés, et hasDegradedParity() indique si la fidélité de sortie a été affectée. Cela permet à une routine de récupération de distinguer « réussi proprement » de « réussi avec une fidélité réduite » sans avoir à analyser une exception.

La politique de dégradation détermine quels événements tu traites comme des exceptions plutôt que comme des avertissements. NextPDF\Core\Config a pour valeur par défaut DegradationPolicy::Balanced, qui avertit et continue pour une dégradation bornée, mais lève en cas d’impact bloquant. DegradationPolicy::Permissive ne lève jamais et collecte tout dans le canal d’avertissements. DegradationPolicy::Strict lève en cas de risque de conformité, de perte sémantique ou d’impact bloquant. Choisis d’abord la politique, puis écris la récupération pour les formes d’échec qu’elle produit.

Le code de récupération ci-dessous utilise ces membres vérifiés :

  • 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 et la valeur par défaut degradationPolicy de DegradationPolicy::Balanced.
  • NextPDF\Contracts\DegradationPolicyStrict, Balanced, Permissive.
  • NextPDF\Exception\NextPdfException (base abstraite), NextPDF\Exception\FontNotFoundException, NextPDF\Exception\HtmlParsingException, NextPDF\Exception\CssResolutionBudgetExceededException, NextPDF\Exception\WriterException, NextPDF\Exception\PageLayoutException.
  • NextPDF\Support\DegradedException (portant capability et policy), NextPDF\Support\Capability (id, status, reason, isDegraded()), NextPDF\Support\Warning, NextPDF\Support\WarningSeverity.

La récupération utile minimale : intercepte une police introuvable, rabats-toi sur une fonte garantie et continue. Ce fragment laisse de côté la gestion plus large de l’exemple de production. Pour un gestionnaire complet avec journalisation et prise en compte de la limite DegradedException, lis l’exemple de production ci-dessous.

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

L’exemple complet câble les quatre stratégies dans un seul pipeline de rendu : un repli de police, un repli de renderer du chemin in-process vers Chrome, un réessai avec HTML alternatif et une récupération de document partiel pilotée par getNumPages(). Il respecte le canal de sortie du harnais, n’intercepte jamais une Exception nue et ne laisse jamais un bloc catch vide.

<?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 reste libre pour le harnais. Les diagnostics de récupération vont vers STDERR, et le PDF est écrit uniquement vers NEXTPDF_COOKBOOK_OUTPUT.

  • Ordonne les blocs catch du spécifique au général. PHP utilise le premier catch compatible. Placer catch (NextPdfException $e) avant catch (WriterException $e) transforme le bloc spécifique en code mort, car WriterException étend NextPdfException.
  • DegradedException se situe en dehors de la hiérarchie. Elle étend RuntimeException, et non NextPdfException. Un pipeline qui n’intercepte que NextPdfException laisse un rejet de politique stricte se propager sans interception. Intercepte DegradedException (ou un RuntimeException plus large) lorsqu’une politique de dégradation autre que celle par défaut est active.
  • Un repli de police peut échouer lui aussi. Si ta fonte de repli n’est pas enregistrée non plus, le second setFont() lève à nouveau. Utilise un alias Base14 tel que helvetica, que le moteur résout sans recherche dans le système de fichiers, ou enregistre au démarrage une fonte fournie via addFontDirectory() pour que le repli soit garanti.
  • getNumPages() compte la page active non vidée. Elle renvoie le nombre de pages vidées, plus un lorsqu’une page est actuellement ouverte. Ainsi, une « sauvegarde partielle » inclut la page qui était en cours de construction quand l’échec s’est produit, ce qui est généralement ce que tu veux. Si tu n’as besoin que des pages entièrement complètes, branche-toi aussi sur getPage().
  • Le repli Chrome change la fidélité, pas seulement la disponibilité. Le pipeline in-process et le pont Chrome utilisent des moteurs de mise en page différents ; un document qui se rabat sur Chrome peut donc avoir un aspect différent. Traite le repli comme une récupération, pas comme un substitut transparent, et note quel chemin a produit la sortie.
  • Un réessai doit utiliser une entrée éprouvée. Le réessai en HTML simplifié n’aide que lorsque la variante simplifiée est réellement plus simple — moins de sélecteurs imbriqués, pas de chaînes :has() qui épuisent le budget de résolution. Réessayer avec la même entrée qui a déjà échoué revient à boucler sur la même exception.
  • Inspecte les avertissements après une exécution propre. Un rendu qui se termine sans lever d’exception peut quand même avoir subi une dégradation. Vérifie hasDegradedParity() et lis getWarnings() avant de considérer la sortie comme fidèle au pixel ; sous DegradationPolicy::Permissive, chaque dégradation est un avertissement, jamais une exception.
  • La récupération n’ajoute un coût que sur le chemin d’échec. NextPDF lève lors d’états exceptionnels ; un rendu propre ne paie donc rien pour le try/catch qui l’entoure.
  • Un repli de renderer relance le rendu. La tentative in-process est jetée et la tentative Chrome repart de zéro ; un rendu de repli coûte donc, dans le pire des cas, les deux temps de rendu plus l’aller-retour inter-processus vers Chrome. Prévois-le dans ton budget quand tu définis les délais d’expiration de requête.
  • Un réessai avec HTML alternatif analyse un second document. Garde la variante simplifiée courte pour que le réessai reste peu coûteux par rapport à la tentative primaire.
  • Une sauvegarde partielle sérialise les pages déjà construites. Son coût dépend du nombre de pages survivantes, pas du travail qui a échoué.
  • N’expose pas les messages d’exception bruts ni les chemins du système de fichiers aux utilisateurs finaux. Un message FontNotFoundException inclut les répertoires recherchés et une WriterException inclut le chemin de sortie ; les deux divulguent l’organisation du serveur. Journalise le contexte structuré côté serveur et renvoie un message générique à l’appelant.
  • Traite le HTML de chaque tentative comme une entrée non fiable. Le repli et le réessai en HTML simplifié transitent tous deux par la même frontière d’entrée ; le pipeline in-process et le pont Chrome appliquent chacun leur propre politique de sécurité HTML, et un réessai n’assouplit pas cette validation. Ne suppose pas qu’une variante « simplifiée » est plus sûre parce que tu l’as rédigée toi-même.
  • Une sauvegarde partielle écrit quand même un fichier. Applique à une sortie partielle les mêmes règles de validation de chemin, de permissions et d’emplacement de stockage qu’à une sortie complète. Document::save() rejette les wrappers de flux et les octets nuls et résout le répertoire parent pour bloquer la traversée de chemin, mais la destination que tu passes relève de ta responsabilité.

Cette recette ne fait aucune revendication normative de standard. Elle compose les API publiques d’exceptions et d’inspection de document de NextPDF dans un flux de contrôle de récupération ; elle n’affirme aucun comportement défini par ISO 32000-2 ni aucun autre standard, donc elle ne porte aucun bloc citations:.

Il est vérifié avec le profil de reproductibilité sémantique. Le document récupéré porte un /ID de trailer et une date de modification régénérés à chaque sauvegarde ; l’identité à l’octet n’est donc pas atteignable. La comparaison de l’AST structurel plus les seules métadonnées est stable d’une exécution à l’autre.