Zum Inhalt springen

Batch-PDF-Generierung mit Fortschrittsverfolgung über Connect

Führen Sie eine Liste von Dokumenten aus einem einzigen Client-Prozess über NextPDF Connect bis zum Abschluss – die eigenständige HTTP-Service-Distribution der Engine. Dieses Recipe reicht jede Render-Anfrage am Endpunkt für asynchrone Jobs POST /api/v1/jobs ein, fragt jeden Job mit GET /api/v1/jobs/{id} ab, bis er einen Endzustand erreicht, liest die pro Job vom Server gemeldeten Felder status und progress und lädt jedes fertige PDF von GET /api/v1/jobs/{id}/result herunter.

Der Lebenszyklus eines Jobs ist fest und überschaubar. Ein Job ist pending, dann running und anschließend in genau einem Endzustand: completed, failed oder cancelled. Die Statusantwort enthält eine Ganzzahl progress von 0 bis 100, sofern der Server sie verfolgt, sowie bei jeder nicht-terminalen Abfrage einen Retry-After-Header, der angibt, wie lange Sie bis zur nächsten Anfrage warten sollen. Versehen Sie jede Einreichung mit einem Idempotency-Key, damit eine erneut gesendete Einreichung denselben Job zurückgibt, statt einen zweiten Render zu starten.

Dieses Recipe beschreibt den Ablauf auf Protokollebene und bleibt dabei dem Transportverhalten treu. Es nutzt die REST-Fläche direkt und setzt kein sprachspezifisches Software Development Kit (SDK) voraus, sodass sich derselbe Ablauf auf jeden HTTP-Client übertragen lässt.

Die Serverseite ist die Standard-Connect-Distribution:

Terminal-Fenster
composer require nextpdf/server

Der PHP-Client im Produktionsbeispiel unten nutzt einen Hypertext-Transfer-Protocol-Client (HTTP) und Message-Factories, die zu PSR-18 und PSR-17 konform sind. Installieren Sie die Implementierungen, auf die sich Ihr Projekt ohnehin festgelegt hat, zum Beispiel:

Terminal-Fenster
composer require psr/http-client psr/http-factory

Die Fläche für asynchrone Jobs trennt Einreichung von Abruf. Sie halten nicht pro Dokument eine lange HTTP-Verbindung offen. Stattdessen reichen Sie einen Job ein, erhalten einen Bezeichner und fragen einen günstigen Status-Endpunkt ab, bis der Job fertig ist. Genau diese Form macht einen Batch handhabbar: Der Client verfolgt N unabhängige Jobs gleichzeitig, ohne dass N Verbindungen blockiert sind.

Drei Endpunkte tragen den Ablauf:

  • POST /api/v1/jobs akzeptiert denselben Render-Anfrage-Body wie der synchrone Endpunkt /api/v1/render: ein page_size, eine orientation und ein geordnetes operations-Array. Er gibt 201 Created für einen neuen Job zurück oder 200 OK, wenn ein Idempotency-Key zu einem Job passt, den Sie bereits eingereicht haben.
  • GET /api/v1/jobs/{id} gibt den aktuellen Job-Datensatz zurück. Für einen nicht-terminalen Job setzt er außerdem einen Retry-After-Header (der Server verwendet ein Intervall von 2 Sekunden) und ein poll_url-Feld. Halten Sie sich an den Header, statt in einer engen Schleife abzufragen.
  • GET /api/v1/jobs/{id}/result streamt das fertige PDF als application/pdf. Er gibt 409 Conflict zurück, wenn der Job noch nicht completed erreicht hat; rufen Sie ihn also erst auf, wenn die Statusabfrage den Endzustand bestätigt.

Jede erfolgreiche Antwort verwendet denselben Umschlag: ein data-Objekt mit den Job-Feldern und ein meta-Objekt mit der request_id, dem timestamp, dem duration_ms und der api_version. Die Job-Felder, die Sie lesen, liegen unter data: data.status, data.progress, data.job_id und bei einem fertigen Job data.result_url.

Ein ehrlicher Vorbehalt zum aktuellen Release: Der Server verarbeitet einen eingereichten Job inline, bevor er den POST beantwortet. Daher trägt die Einreichungsantwort in der Praxis bereits einen terminalen status, und das Ergebnis kann schon bei der ersten Abfrage bereitstehen. Der hier dokumentierte Vertrag aus Abfrage und Fortschritt ist die stabile API-Form. Der Server behält ihn unverändert bei, während das verarbeitende Backend auf einen warteschlangenbasierten Worker-Pool umzieht. Ein Client, der auf Abfragen ausgelegt ist, ist damit heute korrekt und bleibt es auch nach dieser Änderung. Schreiben Sie die Poll-Schleife. Gehen Sie nicht davon aus, dass die erste Antwort nicht-terminal ist, aber auch nicht davon, dass sie terminal ist.

Die REST-Fläche der asynchronen Jobs von Connect ist im OpenAPI-Dokument des Servers und im JobHandler-Routing wie folgt definiert:

  • POST /api/v1/jobs: einen Render-Job einreichen. Optionaler Idempotency-Key-Request-Header. Der Body ist eine Render-Anfrage (operations ist erforderlich und muss mindestens eine Operation enthalten). Antworten: 201 neu, 200 idempotente Wiederholung, 422 ungültiger Body, 409 Idempotenzkonflikt, 429 Ratenbegrenzung.
  • GET /api/v1/jobs/{id}: den Status abfragen. Antwort 200 mit dem Job-Datensatz; der Retry-After-Header ist vorhanden, solange der Job nicht-terminal ist; 404, wenn der Job nicht existiert oder einem anderen Client gehört.
  • GET /api/v1/jobs/{id}/result: das PDF herunterladen. 200 application/pdf, wenn completed; 409, wenn noch nicht abgeschlossen; 404, wenn unbekannt.
  • DELETE /api/v1/jobs/{id}: einen pending- oder running-Job abbrechen oder einen completed-Job löschen (204).

Der Job-Datensatz unter data trägt diese Felder, genau so, wie der Server sie serialisiert.

  • job_id: der Bezeichner (ein job_-Präfix und 24 hexadezimale Zeichen).
  • status: einer von pending, running, completed, failed, cancelled. Die ersten beiden sind nicht-terminal; die letzten drei sind terminal.
  • created_at und, sobald gesetzt, started_at und completed_at: ISO-8601-Zeitstempel.
  • progress: eine Ganzzahl von 0 bis 100, nur vorhanden, wenn der Server sie für den Job verfolgt; andernfalls fehlend (als unbekannt behandeln).
  • error: ein Nachrichten-String, nur bei einem failed-Job vorhanden.
  • result_url: nur bei einem completed-Job vorhanden; der Pfad zum Ergebnis-Download.
  • poll_url: nur vorhanden, solange der Job nicht-terminal ist.

Die Authentifizierung erfolgt über ein Bearer-Token im Authorization-Header: Authorization: Bearer npk_live_{kid}_{secret}.

Dies führt einen Job auf Protokollebene von Anfang bis Ende, sodass Sie die drei Aufrufe und die Felder sehen, die sie zurückgeben. Der Schnellstart reicht ein, fragt einmal ab und lädt herunter. Das Produktionsbeispiel unten ergänzt die Batch-Schleife, die Retry-After-Wartezeit und die vollständige Fehlerbehandlung.

Terminal-Fenster
# 1. Submit an async render job. Capture the job_id from data.job_id.
curl -sS -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: invoice-2026-04-0001" \
-d '{"page_size":"A4","orientation":"portrait","operations":[{"type":"add_text","text":"Invoice 0001"}]}'
# 2. Poll status. Read data.status and data.progress; honour Retry-After.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
# 3. Once data.status is "completed", download the PDF binary.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-0001.pdf

Dies ist ein in sich geschlossener Client. Er reicht einen Batch von Render-Anfragen ein, begrenzt, wie viele Jobs gleichzeitig in Bearbeitung sind, fragt jeden Job in dem Takt ab, den der Server über Retry-After verlangt, meldet den vom Server gemeldeten progress, lädt jedes fertige PDF herunter und protokolliert Fehlschläge. Er nutzt einen PSR-18-HTTP-Client und PSR-17-Factories (den Transport-Vertrag, auf den sich die Connect-Recipes standardisieren) und fängt jeweils die spezifischste Exception ab, die jeder Aufruf auslösen kann: Psr\Http\Client\ClientExceptionInterface für einen Transportfehler und eine typisierte BatchJobException für eine Serverantwort, mit der der Batch nicht fortfahren kann. Kein catch-Block ist leer. Jeder protokolliert und löst die Exception erneut aus oder hält ein definiertes Ergebnis fest.

Ersetzen Sie die direkt im Code stehende $documents-Liste durch Ihre eigenen Eingaben. Injizieren Sie den konkreten HTTP-Client und die Factories Ihres Projekts dort, wo der Konstruktor die PSR-Interfaces erwartet.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Raised when a Connect job response prevents the batch from proceeding.
*
* Distinct from the PSR-18 transport exception: this means the request was
* delivered and the server answered, but the answer is one the batch
* cannot act on (a non-success status code, or a job that ended in a
* terminal failure).
*/
final class BatchJobException extends RuntimeException
{
}
/**
* Drives a batch of async render jobs over the NextPDF Connect REST surface.
*
* The client submits each render request, polls every job on the cadence
* the server requests through Retry-After, and downloads each completed
* PDF. It enforces bounded concurrency so a large batch never opens more
* in-flight jobs than the host should track at once.
*/
final readonly class ConnectBatchRunner
{
/**
* @param non-empty-string $baseUrl Connect base URL, no trailing slash
* @param non-empty-string $bearerToken Connect API key (npk_live_...)
* @param positive-int $maxInFlight Concurrent jobs cap
* @param positive-int $maxPolls Per-job poll attempts before giving up
*/
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
private string $baseUrl,
private string $bearerToken,
private int $maxInFlight = 8,
private int $maxPolls = 150,
) {}
/**
* Render every document in the batch and write each completed PDF.
*
* @param array<non-empty-string, array<string, mixed>> $documents
* Map of stable document key to render request body. The key
* doubles as the Idempotency-Key, so a re-run of the same batch
* does not duplicate server-side work.
* @param non-empty-string $outputDir Directory for the written PDFs
*
* @throws BatchJobException When the batch cannot proceed at all
* @throws ClientExceptionInterface When the transport cannot send a request
*
* @return array<non-empty-string, string> Map of document key to a
* human-readable outcome line
*/
public function run(array $documents, string $outputDir): array
{
$this->assertWritableDir($outputDir);
$outcomes = [];
// Process in bounded windows so the in-flight job count never
// exceeds the configured cap, regardless of batch size.
foreach (array_chunk($documents, $this->maxInFlight, preserve_keys: true) as $window) {
$jobIds = [];
foreach ($window as $key => $body) {
$jobIds[$key] = $this->submit($key, $body);
}
foreach ($jobIds as $key => $jobId) {
$record = $this->pollToTerminal($jobId);
$outcomes[$key] = $this->finish($key, $record, $outputDir);
}
}
return $outcomes;
}
/**
* Submit one render job and return its identifier.
*
* @param non-empty-string $idempotencyKey Stable per-document key
* @param array<string, mixed> $body Render request body
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return non-empty-string The job_id from data.job_id
*/
private function submit(string $idempotencyKey, array $body): string
{
$request = $this->requestFactory
->createRequest('POST', $this->baseUrl . '/api/v1/jobs')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken)
->withHeader('Content-Type', 'application/json')
->withHeader('Idempotency-Key', $idempotencyKey)
->withBody($this->streamFactory->createStream($this->encode($body)));
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
// 201 new job; 200 idempotent replay. Anything else stops the batch.
if ($status !== 201 && $status !== 200) {
throw new BatchJobException(
sprintf('Submit for "%s" returned HTTP %d.', $idempotencyKey, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobId = $data['job_id'] ?? null;
if (!is_string($jobId) || $jobId === '') {
throw new BatchJobException(
sprintf('Submit for "%s" returned no job_id.', $idempotencyKey),
);
}
return $jobId;
}
/**
* Poll one job until it reaches a terminal state.
*
* Honours the Retry-After header on every non-terminal poll. Gives up
* after maxPolls attempts and reports the wait as a failure so the
* batch records it rather than blocking forever.
*
* @param non-empty-string $jobId
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return array<string, mixed> The terminal job record (data object)
*/
private function pollToTerminal(string $jobId): array
{
$url = $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId);
for ($attempt = 0; $attempt < $this->maxPolls; $attempt++) {
$request = $this->requestFactory
->createRequest('GET', $url)
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new BatchJobException(
sprintf('Poll for job "%s" returned HTTP %d.', $jobId, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobStatus = is_string($data['status'] ?? null) ? $data['status'] : 'unknown';
$progress = is_int($data['progress'] ?? null) ? $data['progress'] : null;
$this->logProgress($jobId, $jobStatus, $progress);
// Terminal states: completed, failed, cancelled.
if (in_array($jobStatus, ['completed', 'failed', 'cancelled'], strict: true)) {
return $data;
}
// Non-terminal: wait the interval the server asked for.
$this->waitRetryAfter($response->getHeaderLine('Retry-After'));
}
throw new BatchJobException(
sprintf('Job "%s" did not finish within %d polls.', $jobId, $this->maxPolls),
);
}
/**
* Act on a terminal job record: download a completed PDF, or report.
*
* @param non-empty-string $key Document key
* @param array<string, mixed> $record Terminal job record (data object)
* @param non-empty-string $outputDir Where to write the PDF
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return string A human-readable outcome line
*/
private function finish(string $key, array $record, string $outputDir): string
{
$jobStatus = is_string($record['status'] ?? null) ? $record['status'] : 'unknown';
$jobId = is_string($record['job_id'] ?? null) ? $record['job_id'] : '';
if ($jobStatus !== 'completed') {
// A failed job carries an error message; surface it, do not swallow.
$error = is_string($record['error'] ?? null) ? $record['error'] : 'no detail';
return sprintf('%s -> %s (%s)', $key, $jobStatus, $error);
}
$path = rtrim($outputDir, '/\\') . DIRECTORY_SEPARATOR . $key . '.pdf';
$this->download($jobId, $path);
return sprintf('%s -> completed, written to %s', $key, $path);
}
/**
* Download a completed job result and write it to a server-derived path.
*
* @param non-empty-string $jobId
* @param non-empty-string $path Caller-controlled output path
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*/
private function download(string $jobId, string $path): void
{
$request = $this->requestFactory
->createRequest('GET', $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId) . '/result')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new BatchJobException(
sprintf('Result download for job "%s" returned HTTP %d.', $jobId, $response->getStatusCode()),
);
}
$bytes = $response->getBody()->__toString();
if (!str_starts_with($bytes, '%PDF')) {
throw new BatchJobException(
sprintf('Result for job "%s" is not a PDF.', $jobId),
);
}
if (file_put_contents($path, $bytes) === false) {
throw new BatchJobException(sprintf('Could not write result to "%s".', $path));
}
}
/**
* Sleep for the server-requested interval, with a safe floor and ceiling.
*/
private function waitRetryAfter(string $retryAfter): void
{
$seconds = ctype_digit($retryAfter) ? (int) $retryAfter : 2;
// Clamp to a sane band so a hostile header cannot stall or busy-loop.
$seconds = max(1, min(30, $seconds));
sleep($seconds);
}
/**
* Emit a progress line. Replace with your logger.
*/
private function logProgress(string $jobId, string $jobStatus, ?int $progress): void
{
$pct = $progress === null ? 'n/a' : $progress . '%';
fwrite(STDERR, sprintf("[%s] status=%s progress=%s\n", $jobId, $jobStatus, $pct));
}
/**
* Decode a response envelope and return its data object.
*
* @throws BatchJobException When the body is not the expected envelope
*
* @return array<string, mixed>
*/
private function decodeData(string $json): array
{
try {
/** @var mixed $decoded */
$decoded = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BatchJobException('Response body is not valid JSON.', previous: $e);
}
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
throw new BatchJobException('Response is missing the data envelope.');
}
/** @var array<string, mixed> $data */
$data = $decoded['data'];
return $data;
}
/**
* @param array<string, mixed> $body
*
* @throws BatchJobException
*/
private function encode(array $body): string
{
try {
return json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (JsonException $e) {
throw new BatchJobException('Render request body is not encodable.', previous: $e);
}
}
/**
* @param non-empty-string $dir
*
* @throws BatchJobException
*/
private function assertWritableDir(string $dir): void
{
if (!is_dir($dir) || !is_writable($dir)) {
throw new BatchJobException(sprintf('Output directory "%s" is not writable.', $dir));
}
}
}
// ---------------------------------------------------------------------------
// Wiring. Provide your project's concrete PSR-18 client and PSR-17 factories.
// ---------------------------------------------------------------------------
/** @var ClientInterface $httpClient */
/** @var RequestFactoryInterface $requestFactory */
/** @var StreamFactoryInterface $streamFactory */
$baseUrl = getenv('NEXTPDF_CONNECT_URL');
$token = getenv('NEXTPDF_CONNECT_TOKEN');
if ($baseUrl === false || $baseUrl === '' || $token === false || $token === '') {
fwrite(STDERR, "Set NEXTPDF_CONNECT_URL and NEXTPDF_CONNECT_TOKEN.\n");
exit(2);
}
/** @var array<non-empty-string, array<string, mixed>> $documents */
$documents = [
'invoice-0001' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0001'],
],
],
'invoice-0002' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0002'],
],
],
];
$runner = new ConnectBatchRunner(
httpClient: $httpClient,
requestFactory: $requestFactory,
streamFactory: $streamFactory,
baseUrl: rtrim($baseUrl, '/'),
bearerToken: $token,
maxInFlight: 8,
);
try {
$outcomes = $runner->run($documents, getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: sys_get_temp_dir());
} catch (BatchJobException $e) {
fwrite(STDERR, 'Batch stopped: ' . $e->getMessage() . "\n");
exit(1);
} catch (ClientExceptionInterface $e) {
fwrite(STDERR, 'Transport failure: ' . $e->getMessage() . "\n");
exit(1);
}
foreach ($outcomes as $line) {
echo $line, "\n";
}

Erwartetes STDOUT, eine Zeile pro Dokument; die Pfade hängen von Ihrem Ausgabeverzeichnis ab:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Lesen Sie die Job-Felder unter data, nicht auf der obersten Ebene. Jede Erfolgsantwort ist in einen { "data": ..., "meta": ... }-Umschlag verpackt. data.status und data.progress sind die Felder, auf die Sie reagieren; meta trägt die request_id für die Support-Korrelation.
  • progress kann fehlen. Der Server schließt progress nur ein, wenn er ihn für diesen Job verfolgt. Behandeln Sie ein fehlendes Feld als „unbekannt“, nicht als null, und steuern Sie Ihre Schleife über status, der immer vorhanden ist.
  • Die Einreichung kann bereits terminal sein. Im aktuellen Release rendert der Server inline, bevor er den POST beantwortet, sodass die Einreichungsantwort status: completed tragen kann und das Ergebnis schon bei der ersten Abfrage bereit sein kann. Ihre Poll-Schleife muss schon beim nullten Versuch einen Endzustand akzeptieren, statt zuerst auf einem pending zu bestehen.
  • Halten Sie sich an Retry-After. Nicht-terminale Statusantworten setzen Retry-After (ein Intervall von 2 Sekunden). Schnelleres Abfragen verschwendet Anfragen und provoziert einen 429. Begrenzen Sie den Wert auf ein vernünftiges Band, statt ihm blind zu vertrauen.
  • /result vor dem Abschluss ist ein 409. Rufen Sie den Ergebnis-Endpunkt erst auf, nachdem die Statusabfrage completed anzeigt. Ein 409 Conflict bedeutet, dass der Job nicht fertig ist; es ist kein Transportfehler.
  • Idempotency-Key verhindert doppelte Arbeit. Eine erneut gesendete Einreichung mit demselben Schlüssel gibt den ursprünglichen Job zurück (200 statt 201). Verwenden Sie einen stabilen Schlüssel pro Dokument, damit ein Netzwerk-Retry niemals einen zweiten Render startet. Ein wiederverwendeter Schlüssel mit einem anderen Body ist ein 409-Konflikt.
  • Jobs sind besitzergebunden. Ein unter einem API-Schlüssel eingereichter Job ist für einen anderen unsichtbar; ein GET über Besitzergrenzen hinweg gibt 404 zurück, nicht 403. Fragen Sie mit demselben Credential ab, mit dem Sie eingereicht haben.
  • Ein failed-Job trägt eine error-Nachricht. Lesen Sie data.error bei einem terminalen failed-Status und halten Sie ihn fest. Wiederholen Sie nicht blind.

Die Kosten eines Batches sind die Summe der Renders plus dem Overhead durch das Abfragen. Zwei Hebel steuern die Clientseite. Erstens: Begrenzen Sie die Nebenläufigkeit. Das maxInFlight-Limit legt fest, wie viele Jobs gleichzeitig verfolgt werden, was die Zahl offener Anfragen und den Speicher des Clients unabhängig von der Batch-Größe konstant hält. Setzen Sie es passend zur Worker-Anzahl des Servers, nicht höher; mehr Jobs in Bearbeitung als Worker verlängern nur die Warteschlangenzeit jedes Jobs. Zweitens: Respektieren Sie das Poll-Intervall. Jede Abfrage ist ein günstiger Status-Read, aber eine enge Schleife vervielfacht das Anfragevolumen und löst die Ratenbegrenzung aus. Der 2-Sekunden-Retry-After des Servers ist der richtige Standard, und der Runner begrenzt auf ein Band von 1 bis 30 Sekunden, damit ein einzelner langsamer Job das Fenster weder durch Busy-Looping belasten noch blockieren kann.

Verarbeiten Sie sehr große Batches in Fenstern (der Runner nutzt array_chunk), statt alles im Voraus einzureichen. Das begrenzt sowohl den verfolgten Zustand des Clients als auch die Warteschlangentiefe des Servers, sodass ein fehlerhafter oder überdimensionierter Batch innerhalb eines Fensters fehlschlägt statt erst nach Tausenden von Einreichungen.

  • Halten Sie den Bearer-Token aus Logs und URLs heraus. Der API-Schlüssel reist ausschließlich im Authorization-Header. Platzieren Sie ihn niemals in einem Query-String, einer Log-Zeile oder einem geschriebenen Artefakt. Der Runner protokolliert die job_id und den status, niemals das Credential.
  • Leiten Sie Ausgabepfade aus servergesteuerten Schlüsseln ab. Der Runner baut jeden Ausgabepfad aus dem Dokumentschlüssel, den Ihr Code gewählt hat, verbunden mit einem festen Ausgabeverzeichnis, niemals aus einem Wert in einer Serverantwort. Interpolieren Sie kein Job-Feld in einen Dateisystempfad, da dies Path-Traversal ermöglichen würde.
  • Validieren Sie die heruntergeladenen Bytes. Der Runner prüft bei einem 200 von /result den %PDF-Header, bevor er die Datei schreibt. Ein erfolgreicher Download-Status ist für sich allein noch kein Beweis, dass der Body ein PDF ist.
  • Behandeln Sie das Ergebnis als nicht vertrauenswürdig, bis es geprüft ist. Ein fertiger Job bedeutet, dass der Server Bytes gerendert hat, nicht, dass diese Bytes gefahrlos weitergeleitet werden können. Lassen Sie die Ergebnisse einen strukturellen Inspektionsschritt durchlaufen, bevor Sie sie an einen Client oder ein nachgelagertes System übergeben.
  • Verwenden Sie einen Schlüssel nach dem Least-Privilege-Prinzip. Die Fläche für asynchrone Jobs ist Rendering der Core-Stufe. Stellen Sie dem Batch einen Schlüssel aus, der genau auf die Operationen beschränkt ist, die er braucht, und rotieren Sie ihn nach dem Zeitplan, den Ihre Secret-Management-Policy vorgibt.
  • Begrenzen Sie das Poll-Budget. maxPolls verhindert, dass ein hängender Job den Client für immer festhält. Der Batch hält das Timeout als Ergebnis fest, statt zu blockieren, was verhindert, dass ein einziger schlechter Job dem Rest den Dienst verweigert.

Dieses Recipe erhebt keinen normativen Standards-Anspruch. Es konsumiert die REST-Endpunkte der asynchronen Jobs von NextPDF Connect (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) und liest die Felder des Job-Datensatzes, die der Server definiert (status, progress, error, result_url, poll_url). Die Prüfung des %PDF-Headers bei einem heruntergeladenen Ergebnis bestätigt nur, dass die Antwort mit der PDF-Markierung beginnt; sie ist keine Gültigkeits- oder Konformitätsfeststellung. Für eine Standards-Prüfung über eine Reihe von Dokumenten nutzen Sie das Enterprise-Batch-Compliance-Tool. Siehe Batch-Standards-Prüfung über Connect, eine andere Fläche als die hier behandelten Rendering-Jobs.