Lewati ke konten

Pembuatan PDF batch melalui Connect dengan pelacakan progres

Jalankan daftar dokumen sampai selesai dari satu proses klien melalui NextPDF Connect, distribusi layanan HTTP mandiri untuk engine ini. Resep ini mengirim setiap permintaan render ke endpoint async-job POST /api/v1/jobs, melakukan polling setiap job dengan GET /api/v1/jobs/{id} sampai mencapai status terminal, membaca bidang status dan progress yang dilaporkan server untuk setiap job, lalu mengunduh setiap PDF yang selesai dari GET /api/v1/jobs/{id}/result.

Siklus hidup job tetap dan ringkas. Sebuah job berstatus pending, lalu running, kemudian tepat satu status terminal: completed, failed, atau cancelled. Respons status membawa bilangan bulat progress dari 0 hingga 100 ketika server melacaknya, serta header Retry-After pada setiap polling non-terminal yang memberi tahu kapan Anda harus mengirim permintaan berikutnya. Berikan Idempotency-Key pada setiap pengiriman agar pengiriman ulang mengembalikan job yang sama, bukan memulai render kedua.

Resep ini bekerja di level wire. Resep ini memanggil permukaan REST secara langsung dan tidak mengasumsikan adanya software development kit (SDK) khusus bahasa, sehingga Anda dapat memindahkan alur yang sama ke klien HTTP apa pun.

Sisi server menggunakan distribusi Connect standar:

Terminal window
composer require nextpdf/server

Klien PHP dalam contoh produksi di bawah ini menggunakan klien Hypertext Transfer Protocol (HTTP) dan message factory yang sesuai dengan PSR-18 dan PSR-17. Pasang implementasi yang sudah menjadi standar di proyek Anda, misalnya:

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

Permukaan async-job memisahkan pengiriman dari pengambilan. Anda tidak perlu mempertahankan satu koneksi HTTP panjang yang terus terbuka untuk tiap dokumen. Sebaliknya, Anda mengirim sebuah job, menerima sebuah pengidentifikasi, dan melakukan polling ke endpoint status yang murah sampai job selesai. Pola ini membuat batch mudah dikelola: klien melacak N job independen sekaligus tanpa N koneksi yang tertahan.

Tiga endpoint membawa alur ini:

  • POST /api/v1/jobs menerima badan permintaan render yang sama dengan endpoint sinkron /api/v1/render: sebuah page_size, sebuah orientation, dan sebuah larik operations yang berurutan. Endpoint ini mengembalikan 201 Created untuk job baru, atau 200 OK ketika sebuah Idempotency-Key cocok dengan job yang sudah Anda kirim sebelumnya.
  • GET /api/v1/jobs/{id} mengembalikan rekaman job saat ini. Untuk job non-terminal, endpoint ini juga menyetel header Retry-After (server menggunakan interval 2 detik) dan bidang poll_url. Patuhi header tersebut, bukan melakukan polling dalam loop yang rapat.
  • GET /api/v1/jobs/{id}/result mengalirkan PDF yang sudah selesai sebagai application/pdf. Endpoint ini mengembalikan 409 Conflict jika job belum mencapai completed, jadi panggil hanya setelah polling status mengonfirmasi status terminal.

Setiap respons yang berhasil berbagi satu amplop: objek data berisi bidang job, dan objek meta berisi request_id, timestamp, duration_ms, dan api_version. Bidang job yang Anda baca berada di bawah data: data.status, data.progress, data.job_id, dan pada job yang sudah selesai data.result_url.

Satu catatan untuk rilis saat ini: server memproses job yang dikirim secara inline sebelum menjawab POST. Dalam praktiknya, respons pengiriman mungkin sudah membawa status terminal, dan hasilnya mungkin sudah siap pada polling pertama. Kontrak polling-dan-progres yang didokumentasikan di sini adalah bentuk Application Programming Interface (API) yang stabil. Server mempertahankannya tanpa perubahan saat backend pemrosesan beralih ke pool worker antrean, sehingga klien yang melakukan polling sudah benar hari ini dan tetap benar setelah perubahan tersebut. Tulis loop polling. Jangan berasumsi respons pertama bersifat non-terminal, dan jangan pula berasumsi ia bersifat terminal.

Dokumen OpenAPI server dan perutean JobHandler mendefinisikan permukaan REST async-job Connect:

  • POST /api/v1/jobs: mengirim job render. Header permintaan Idempotency-Key bersifat opsional. Badan adalah permintaan render (operations wajib ada dan harus memuat setidaknya satu operasi). Respons: 201 baru, 200 replay idempoten, 422 badan tidak valid, 409 konflik idempotensi, 429 pembatasan laju.
  • GET /api/v1/jobs/{id}: polling status. Respons 200 dengan rekaman job; header Retry-After ada selama non-terminal; 404 jika job tidak ada atau milik klien lain.
  • GET /api/v1/jobs/{id}/result: mengunduh PDF. 200 application/pdf ketika completed; 409 ketika belum selesai; 404 jika tidak dikenal.
  • DELETE /api/v1/jobs/{id}: membatalkan job pending atau running, atau menghapus job completed (204).

Rekaman job di bawah data membawa bidang-bidang ini, persis seperti yang diserialisasi server.

  • job_id: pengidentifikasi (awalan job_ dan 24 karakter heksadesimal).
  • status: salah satu dari pending, running, completed, failed, cancelled. Dua yang pertama bersifat non-terminal; tiga yang terakhir bersifat terminal.
  • created_at, dan setelah disetel, started_at dan completed_at: stempel waktu ISO-8601.
  • progress: bilangan bulat 0 hingga 100, hanya ada ketika server melacaknya untuk job tersebut; jika tidak, bidang ini tidak ada (perlakukan sebagai tidak diketahui).
  • error: string pesan, hanya ada pada job failed.
  • result_url: hanya ada pada job completed; jalur menuju unduhan hasil.
  • poll_url: hanya ada selama job bersifat non-terminal.

Autentikasi menggunakan bearer token di header Authorization: Authorization: Bearer npk_live_{kid}_{secret}.

Contoh ini menjalankan satu job dari ujung ke ujung di level wire sehingga Anda dapat melihat ketiga panggilan dan bidang yang dikembalikannya. Contoh ini mengirim, melakukan polling sekali, dan mengunduh. Contoh produksi di bawah ini menambahkan loop batch, penantian Retry-After, dan penanganan kesalahan lengkap.

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

Klien standalone ini mengirim sebuah batch permintaan render, membatasi jumlah job yang berjalan sekaligus, melakukan polling setiap job pada irama yang disetel server melalui Retry-After, melaporkan nilai progress yang dikembalikan server, mengunduh setiap PDF yang selesai, dan mencatat kegagalan. Klien ini menggunakan klien HTTP PSR-18 dan factory PSR-17, yaitu kontrak transport standar untuk resep Connect. Klien ini juga menangkap eksepsi paling spesifik yang dapat dilemparkan oleh tiap panggilan: Psr\Http\Client\ClientExceptionInterface untuk kegagalan transport, dan BatchJobException bertipe untuk respons server yang menghentikan batch agar tidak berlanjut. Tidak ada blok catch yang kosong. Masing-masing mencatat dan melempar ulang, atau merekam hasil yang terdefinisi.

Ganti daftar $documents inline dengan input Anda sendiri. Suntikkan klien HTTP konkret dan factory milik proyek Anda pada bagian konstruktor yang mengharapkan antarmuka 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 yang diharapkan adalah satu baris per dokumen. Jalurnya bergantung pada direktori keluaran Anda:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Baca bidang job di bawah data, bukan di tingkat atas. Setiap respons yang berhasil dibungkus dalam amplop { "data": ..., "meta": ... }. data.status dan data.progress adalah bidang yang Anda gunakan; meta membawa request_id untuk korelasi dukungan.
  • progress bisa tidak ada. Server menyertakan progress hanya ketika server melacaknya untuk job tersebut. Perlakukan bidang yang hilang sebagai “tidak diketahui”, bukan sebagai nol, dan kendalikan loop Anda dari status, yang selalu ada.
  • Pengiriman mungkin sudah bersifat terminal. Pada rilis saat ini, server merender secara inline sebelum menjawab POST, sehingga respons pengiriman dapat membawa status: completed dan hasilnya mungkin sudah siap pada polling pertama. Loop polling Anda harus menerima status terminal pada percobaan nol, bukan bersikeras menunggu pending terlebih dahulu.
  • Patuhi Retry-After. Respons status non-terminal menyetel Retry-After (interval 2 detik). Polling lebih cepat menyia-nyiakan permintaan dan dapat memicu 429. Batasi nilainya ke rentang yang wajar, bukan mempercayainya secara membabi buta.
  • /result sebelum selesai adalah 409. Panggil endpoint hasil hanya setelah polling status menunjukkan completed. 409 Conflict berarti job belum selesai; ini bukan kesalahan transport.
  • Idempotency-Key mencegah pekerjaan ganda. Pengiriman ulang dengan kunci yang sama mengembalikan job asli (200 alih-alih 201). Gunakan kunci per dokumen yang stabil agar percobaan ulang jaringan tidak pernah memulai render kedua. Kunci yang digunakan ulang dengan badan yang berbeda adalah konflik 409.
  • Job memiliki cakupan pemilik. Job yang dikirim di bawah satu kunci API tidak terlihat oleh kunci lain; GET lintas pemilik mengembalikan 404, bukan 403. Lakukan polling dengan kredensial yang sama dengan yang Anda gunakan saat mengirim.
  • Job failed membawa pesan error. Baca data.error pada status terminal failed dan rekam. Jangan mencoba ulang secara membabi buta.

Biaya sebuah batch adalah total render ditambah overhead polling. Dua hal mengendalikan sisi klien. Pertama, batasi konkurensi: batas maxInFlight menetapkan berapa banyak job yang dilacak sekaligus, sehingga jumlah permintaan terbuka dan memori klien tetap datar tanpa memandang ukuran batch. Setel sesuai jumlah worker server, jangan lebih tinggi; lebih banyak job berjalan daripada worker hanya memperpanjang waktu antrean setiap job. Kedua, hormati interval polling: setiap polling adalah pembacaan status yang murah, tetapi loop yang rapat meningkatkan volume permintaan dan memicu pembatasan laju. Retry-After 2 detik dari server adalah default yang tepat, dan runner membatasinya ke rentang 1 hingga 30 detik sehingga satu job lambat tidak dapat melakukan busy-loop atau menghentikan window.

Untuk batch yang sangat besar, proses dalam window (runner menggunakan array_chunk) alih-alih mengirim semuanya di muka. Itu membatasi status yang dilacak klien sekaligus kedalaman antrean server, sehingga batch yang tidak valid atau terlalu besar gagal di dalam satu window, bukan setelah ribuan pengiriman.

  • Jaga token bearer agar tidak masuk ke log dan URL. Kunci API hanya dikirim melalui header Authorization. Jangan pernah menempatkannya di query string, baris log, atau artefak tertulis. Runner mencatat job_id dan status, tidak pernah kredensial.
  • Turunkan jalur keluaran dari kunci yang dikendalikan kode Anda. Runner membangun setiap jalur keluaran dari kunci dokumen yang dipilih kode Anda, digabungkan ke direktori keluaran tetap, dan tidak pernah dari nilai dalam respons server. Jangan menyisipkan bidang job ke dalam jalur sistem berkas karena itu akan membuka path traversal.
  • Validasi byte yang diunduh. Runner memeriksa 200 dari /result untuk header %PDF sebelum menulis berkas. Status unduhan yang berhasil saja tidak membuktikan bahwa badan tersebut adalah PDF.
  • Perlakukan hasil sebagai tidak tepercaya hingga diperiksa. Job yang selesai berarti server merender byte, bukan berarti byte tersebut aman untuk diteruskan. Jalankan hasil melalui langkah inspeksi struktural sebelum Anda menyerahkannya ke klien atau sistem hilir.
  • Gunakan kunci dengan hak istimewa minimum. Permukaan async-job adalah render tier inti. Berikan batch kunci yang dicakupkan secara tepat pada operasi yang dibutuhkannya, dan rotasikan sesuai jadwal yang ditetapkan kebijakan manajemen rahasia Anda.
  • Batasi anggaran polling. maxPolls menghentikan job yang macet agar tidak menahan klien selamanya. Batch merekam batas waktu sebagai sebuah hasil, bukan memblokir, sehingga satu job buruk tidak menghalangi layanan bagi yang lain.

Resep ini tidak membuat klaim standar normatif. Resep ini mengonsumsi endpoint REST async-job NextPDF Connect (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) dan membaca bidang rekaman job yang didefinisikan server (status, progress, error, result_url, poll_url). Pemeriksaan header %PDF pada hasil yang diunduh hanya mengonfirmasi bahwa respons diawali dengan penanda PDF; ini bukan penilaian validitas atau konformansi. Untuk pemeriksaan standar di sekumpulan dokumen, gunakan alat kepatuhan batch Enterprise. Lihat Pemeriksaan standar batch melalui Connect, permukaan yang berbeda dari job rendering yang dibahas di sini.