Salta ai contenuti

Generazione batch di PDF in Connect con monitoraggio dell'avanzamento

Consente di completare un elenco di documenti da un singolo processo client tramite NextPDF Connect, la distribuzione del motore come servizio HTTP autonomo. Il flusso invia ogni richiesta di rendering all’endpoint di job asincroni POST /api/v1/jobs, esegue il polling di ogni job con GET /api/v1/jobs/{id} finché non raggiunge uno stato terminale, legge i campi status e progress del job riportati dal server e scarica ogni PDF completato da GET /api/v1/jobs/{id}/result.

Il ciclo di vita del job è fisso e contenuto. Un job è pending, poi running, quindi raggiunge esattamente uno stato terminale: completed, failed o cancelled. La risposta di stato include un intero progress da 0 a 100 quando il server lo monitora, e un’intestazione Retry-After su ogni polling non terminale che indica quanto attendere prima della richiesta successiva. Ogni invio va identificato con un Idempotency-Key in modo che un invio ripetuto restituisca lo stesso job anziché avviare un secondo rendering.

Questa ricetta segue il flusso a livello di protocollo, aderente al trasporto. Usa direttamente la superficie REST e non presuppone alcun software development kit (SDK) specifico per il linguaggio, quindi lo stesso flusso è portabile su qualsiasi client HTTP.

Il lato server è la distribuzione Connect standard:

Terminal window
composer require nextpdf/server

Il client PHP nell’esempio di produzione qui sotto usa un client Hypertext Transfer Protocol (HTTP) e factory di messaggi conformi a PSR-18 e PSR-17. Installare le implementazioni su cui il progetto si basa già, ad esempio:

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

La superficie dei job asincroni separa l’invio dal recupero. Non si mantiene aperta una lunga connessione HTTP per documento. Si invia invece un job, si riceve un identificatore e si esegue il polling di un endpoint di stato leggero finché il job non termina. Questo schema rende gestibile un batch: il client tiene traccia di N job indipendenti contemporaneamente senza N connessioni bloccate.

Tre endpoint gestiscono il flusso:

  • POST /api/v1/jobs accetta lo stesso corpo della richiesta di rendering dell’endpoint sincrono /api/v1/render: un page_size, un orientation e un array ordinato operations. Restituisce 201 Created per un nuovo job, oppure 200 OK quando un Idempotency-Key corrisponde a un job già inviato.
  • GET /api/v1/jobs/{id} restituisce il record corrente del job. Per un job non terminale imposta inoltre un’intestazione Retry-After (il server usa un intervallo di 2 secondi) e un campo poll_url. Rispettare l’intestazione anziché eseguire il polling in un ciclo serrato.
  • GET /api/v1/jobs/{id}/result trasmette in streaming il PDF finito come application/pdf. Restituisce 409 Conflict se il job non ha raggiunto completed, quindi va chiamato solo dopo che il polling di stato conferma lo stato terminale.

Ogni risposta riuscita condivide un unico involucro: un oggetto data con i campi del job e un oggetto meta con request_id, timestamp, duration_ms e api_version. I campi del job da leggere si trovano sotto data: data.status, data.progress, data.job_id e, per un job completato, data.result_url.

Una precisazione sulla release attuale. Il server elabora un job inviato in linea prima di rispondere al POST, quindi in pratica la risposta all’invio contiene già uno status terminale, e il risultato può essere pronto al primo polling. Il contratto di polling e avanzamento documentato qui è la forma stabile dell’API. Il server lo mantiene invariato man mano che il backend di elaborazione passa a un pool di worker in coda, quindi un client scritto per il polling è corretto oggi e rimane corretto dopo quel cambiamento. Va quindi scritto il ciclo di polling. Non bisogna presupporre che la prima risposta sia non terminale, e nemmeno che sia terminale.

La superficie REST dei job asincroni di Connect, come definita nel documento OpenAPI del server e nel routing di JobHandler:

  • POST /api/v1/jobs: invia un job di rendering. Intestazione di richiesta facoltativa Idempotency-Key. Il corpo è una richiesta di rendering (operations è obbligatorio e deve contenere almeno un’operazione). Risposte: 201 nuovo, 200 replay idempotente, 422 corpo non valido, 409 conflitto di idempotenza, 429 limite di frequenza superato.
  • GET /api/v1/jobs/{id}: esegue il polling dello stato. Risposta 200 con il record del job; intestazione Retry-After presente finché il job non è terminale; 404 se il job non esiste o appartiene a un altro client.
  • GET /api/v1/jobs/{id}/result: scarica il PDF. 200 application/pdf quando completed; 409 quando il job non è ancora completato; 404 se sconosciuto.
  • DELETE /api/v1/jobs/{id}: annulla un job pending o running, oppure elimina un job completed (204).

Il record del job sotto data contiene questi campi, esattamente come il server li serializza.

  • job_id: l’identificatore (un prefisso job_ e 24 caratteri esadecimali).
  • status: uno tra pending, running, completed, failed, cancelled. I primi due sono non terminali; gli ultimi tre sono terminali.
  • created_at e, una volta impostati, started_at e completed_at: timestamp ISO-8601.
  • progress: un intero da 0 a 100, presente solo quando il server lo monitora per il job; altrimenti assente (da trattare come sconosciuto).
  • error: una stringa di messaggio, presente solo su un job failed.
  • result_url: presente solo su un job completed; il percorso per scaricare il risultato.
  • poll_url: presente solo finché il job non è terminale.

L’autenticazione è un bearer token nell’intestazione Authorization: Authorization: Bearer npk_live_{kid}_{secret}.

Questo esempio porta un singolo job dall’inizio alla fine a livello di protocollo, così da mostrare le tre chiamate e i campi che restituiscono. Esegue l’invio, effettua un polling e scarica. L’esempio di produzione qui sotto aggiunge il ciclo batch, l’attesa Retry-After e la gestione completa degli errori.

Terminal window
# 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

Questo esempio è un client autonomo. Invia un batch di richieste di rendering, limita il numero di job in elaborazione contemporaneamente, esegue il polling di ogni job alla cadenza richiesta dal server tramite Retry-After, registra il progress restituito dal server, scarica ogni PDF completato e registra gli errori. Usa un client HTTP PSR-18 e factory PSR-17 (il contratto di trasporto su cui si basano le ricette Connect) e cattura l’eccezione più specifica che ogni chiamata può sollevare: Psr\Http\Client\ClientExceptionInterface per un errore di trasporto, e una BatchJobException tipizzata per una risposta del server che impedisce al batch di proseguire. Nessun blocco catch è vuoto. Ciascuno registra e rilancia, oppure registra un esito definito.

Sostituire l’elenco $documents in linea con i propri input. Iniettare il client HTTP e le factory concrete del progetto dove il costruttore si aspetta le interfacce PSR.

<?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";
}

STDOUT atteso, una riga per documento; i percorsi dipendono dalla directory di output configurata:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Leggere i campi del job sotto data, non al livello superiore. Ogni risposta riuscita è racchiusa in un involucro { "data": ..., "meta": ... }. data.status e data.progress sono i campi su cui operare; meta contiene request_id per la correlazione con il supporto.
  • progress può essere assente. Il server include progress solo quando lo monitora per quel job. Trattare un campo mancante come “sconosciuto”, non come zero, e guidare il ciclo in base a status, che è sempre presente.
  • L’invio può già essere terminale. Nella release attuale il server esegue il rendering in linea prima di rispondere al POST, quindi la risposta all’invio può contenere status: completed e il risultato può essere pronto al primo polling. Il ciclo di polling deve accettare uno stato terminale al tentativo zero anziché insistere su un primo pending.
  • Rispettare Retry-After. Le risposte di stato non terminali impostano Retry-After (un intervallo di 2 secondi). Un polling più frequente spreca richieste e provoca un 429. Limitare il valore a una banda ragionevole anziché fidarsi ciecamente.
  • /result prima del completamento è un 409. Chiamare l’endpoint del risultato solo dopo che il polling di stato mostra completed. Un 409 Conflict significa che il job non è terminato; non è un errore di trasporto.
  • Idempotency-Key previene il lavoro duplicato. Un invio ripetuto con la stessa chiave restituisce il job originale (200 anziché 201). Usare una chiave per documento stabile in modo che un nuovo tentativo di rete non avvii mai un secondo rendering. Una chiave riutilizzata con un corpo diverso è un conflitto 409.
  • I job hanno un ambito legato al proprietario. Un job inviato con una chiave API è invisibile a un’altra; un GET tra proprietari diversi restituisce 404, non 403. Eseguire il polling con la stessa credenziale usata per l’invio.
  • Un job failed contiene un messaggio error. Leggere data.error su uno stato terminale failed e registrarlo. Non riprovare alla cieca.

Il costo di un batch è la somma dei rendering più l’overhead del polling. Sul lato client, due leve consentono di controllarlo. Primo, limitare la concorrenza: il limite maxInFlight stabilisce quanti job vengono tracciati contemporaneamente, mantenendo costante il numero di richieste aperte e la memoria del client indipendentemente dalla dimensione del batch. Impostarlo in modo che corrisponda al numero di worker del server, non più alto; avere più job in elaborazione rispetto ai worker non fa che allungare il tempo di attesa in coda di ogni job. Secondo, rispettare l’intervallo di polling: ogni polling è una lettura di stato leggera, ma un ciclo serrato moltiplica il volume di richieste e attiva il limitatore di frequenza. Il valore predefinito di 2 secondi del Retry-After del server è quello giusto, e il runner limita a una banda da 1 a 30 secondi in modo che un singolo job lento non possa entrare in busy-loop né bloccare la finestra.

Per batch molto grandi, elaborare a finestre (il runner usa array_chunk) anziché inviare tutto in anticipo. Questo limita sia lo stato tracciato dal client sia la profondità della coda del server, così un batch malformato o sovradimensionato fallisce all’interno di una finestra anziché dopo migliaia di invii.

  • Tenere il bearer token fuori da log e URL. La chiave API viaggia solo nell’intestazione Authorization. Non collocarla mai in una query string, una riga di log o un artefatto scritto. Il runner registra job_id e status, mai la credenziale.
  • Derivare i percorsi di output da chiavi controllate dal codice. Il runner costruisce ogni percorso di output dalla chiave del documento scelta dal codice applicativo, unita a una directory di output fissa, mai da un valore in una risposta del server. Non interpolare un campo del job in un percorso del filesystem, perché aprirebbe un path traversal.
  • Convalidare i byte scaricati. Il runner verifica un 200 da /result per il prefisso %PDF prima di scrivere il file. Uno stato di download riuscito non è di per sé la prova che il corpo sia un PDF.
  • Trattare il risultato come non attendibile finché non è ispezionato. Un job completato significa che il server ha prodotto byte, non che quei byte siano sicuri da inoltrare. Far passare i risultati attraverso uno step di ispezione strutturale prima di consegnarli a un client o a un sistema a valle.
  • Usare una chiave con privilegi minimi. La superficie dei job asincroni riguarda il rendering di livello core. Assegnare al batch una chiave con ambito limitato esattamente alle operazioni di cui ha bisogno, e ruotarla secondo la pianificazione stabilita dalla policy di gestione dei segreti.
  • Limitare il budget di polling. maxPolls impedisce a un job bloccato di trattenere il client per sempre. Il batch registra il timeout come esito anziché bloccarsi, il che impedisce a un job difettoso di negare il servizio agli altri.

Questa ricetta non formula alcuna pretesa normativa di conformità agli standard. Usa gli endpoint REST per job asincroni di NextPDF Connect (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) e legge i campi del record del job definiti dal server (status, progress, error, result_url, poll_url). Il controllo del prefisso %PDF su un risultato scaricato conferma soltanto che la risposta inizia con il marcatore PDF; non è una determinazione di validità o conformità. Per un controllo di conformità agli standard su un insieme di documenti, usare lo strumento Enterprise di conformità batch. Vedi Controllo di conformità batch su Connect, una superficie diversa dai job di rendering trattati qui.