コンテンツにスキップ

進捗追跡付きの Connect 経由バッチ PDF 生成

エンジンのスタンドアロン HTTP サービス配布版である NextPDF Connect を介し、1 つのクライアントプロセスからドキュメント一覧を完了まで処理します。このレシピでは、各レンダリングリクエストを非同期ジョブエンドポイント POST /api/v1/jobs に送信し、各ジョブが終端状態に達するまで GET /api/v1/jobs/{id} でポーリングします。さらに、サーバーが報告するジョブごとの statusprogress フィールドを読み取り、完了したすべての PDF を GET /api/v1/jobs/{id}/result からダウンロードします。

ジョブのライフサイクルは固定されており、状態数も多くありません。ジョブはまず pending となり、次に running となった後、completedfailedcancelled のいずれか 1 つの終端状態のみになります。ステータスレスポンスには、サーバーが追跡している場合に 0 から 100 までの整数 progress が含まれ、終端でないすべてのポーリングには、次のリクエストまで待つ時間を示す Retry-After ヘッダーが含まれます。各送信に Idempotency-Key を付けると、再試行された送信は 2 回目のレンダリングを開始せず、同じジョブを返します。

このレシピは、ワイヤーレベルでトランスポートに忠実な手法を採用します。REST サーフェスを直接使用し、言語固有のソフトウェア開発キット(SDK)を前提にしないため、同じフローをどの HTTP クライアントにも移植できます。

サーバー側には標準の Connect 配布版を使用します。

Terminal window
composer require nextpdf/server

この後の本番サンプルにある PHP クライアントは、PSR-18 および PSR-17 に準拠した Hypertext Transfer Protocol(HTTP)クライアントとメッセージファクトリーを使用します。プロジェクトですでに標準化している実装をインストールしてください。たとえば次のとおりです。

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

非同期ジョブのサーフェスでは、送信取得 を分離します。ドキュメントごとに 1 つの長時間の HTTP 接続を開いたままにする必要はありません。代わりに、ジョブを送信して識別子を受け取り、ジョブが終了するまで軽量なステータスエンドポイントをポーリングします。この形だからこそ、バッチを扱いやすくできます。クライアントは N 個のブロックされた接続を持たずに、N 個の独立したジョブを同時に追跡します。

このフローは 3 つのエンドポイントで構成されます。

  • POST /api/v1/jobs は、同期版の /api/v1/render エンドポイントと同じレンダリングリクエストボディ、つまり page_sizeorientation、および順序付けされた operations 配列を受け付けます。新しいジョブの場合は 201 Created を返します。200 OK は、すでに送信済みのジョブと Idempotency-Key が一致する場合に返ります。
  • GET /api/v1/jobs/{id} は現在のジョブレコードを返します。終端でないジョブの場合は、Retry-After ヘッダー(サーバーは 2 秒間隔を使用します)と poll_url フィールドも設定されます。密なループでポーリングせず、このヘッダーに従ってください。
  • GET /api/v1/jobs/{id}/result は、完成した PDF を application/pdf としてストリーミングします。409 Conflict を返すのはジョブが completed に達していない場合であるため、ステータスのポーリングで終端状態を確認してから呼び出してください。

成功レスポンスはすべて 1 つのエンベロープを共有します。つまり、ジョブフィールドを含む data オブジェクトと、request_idtimestampduration_msapi_version を含む meta オブジェクトです。読み取るジョブフィールドは data の下にあります。data.statusdata.progressdata.job_id、完了したジョブでは data.result_url です。

現在のリリースには、明示しておくべき注意点が 1 つあります。サーバーは POST に応答する前に、送信されたジョブをインラインで処理します。そのため実際には、送信レスポンスがすでに終端の status を含んでおり、最初のポーリングで結果が準備できていることもあります。ここで文書化されているポーリングと進捗のコントラクトは、安定した API の形です。処理バックエンドがキューイングされたワーカープールへ移行しても、サーバーはこれを変更しないため、ポーリングするように書かれたクライアントは現時点で正しく、その変更後も正しいままです。ポーリングループを記述してください。最初のレスポンスが終端でないと決めつけてはならず、終端であると決めつけてもなりません。

サーバーの OpenAPI ドキュメントと JobHandler のルーティングで定義されている Connect 非同期ジョブの REST サーフェスは、次のとおりです。

  • POST /api/v1/jobs: レンダリングジョブを送信します。任意で Idempotency-Key リクエストヘッダーを指定できます。ボディはレンダリングリクエストです(operations は必須であり、少なくとも 1 つの操作を含む必要があります)。レスポンス: 201 は新規、200 は冪等な再実行、422 は無効なボディ、409 は冪等性の競合、429 はレート制限です。
  • GET /api/v1/jobs/{id}: ステータスをポーリングします。レスポンスはジョブレコードを伴う 200 です。終端でない間は Retry-After ヘッダーが存在します。ジョブが存在しない場合、または別のクライアントに属する場合は 404 です。
  • GET /api/v1/jobs/{id}/result: PDF をダウンロードします。ジョブが completed の場合は 200 application/pdf を返し、まだ完了していない場合は 409、不明な場合は 404 です。
  • DELETE /api/v1/jobs/{id}: pending または running のジョブをキャンセルするか、completed のジョブを削除します(204)。

ジョブレコードには、サーバーがシリアライズした次のフィールドが data の下に含まれます。

  • job_id: 識別子(job_ プレフィックスと 24 文字の 16 進数)。
  • status: pendingrunningcompletedfailedcancelled のいずれか。最初の 2 つは非終端で、残りの 3 つは終端です。
  • created_at、および設定されている場合の started_atcompleted_at: ISO-8601 タイムスタンプ。
  • progress: 0 から 100 までの整数。サーバーがそのジョブで追跡している場合にのみ存在し、そうでなければ存在しません(不明として扱います)。
  • error: メッセージ文字列。failed のジョブでのみ存在します。
  • result_url: completed のジョブでのみ存在します。結果ダウンロード用のパスです。
  • poll_url: ジョブが終端でない間のみ存在します。

認証には、Authorization ヘッダー内のベアラートークンを使用します。Authorization: Bearer npk_live_{kid}_{secret}

これは 1 つのジョブをワイヤーレベルで最初から最後まで処理する例で、3 回の呼び出しとそれぞれが返すフィールドを確認できます。送信し、1 回ポーリングして、ダウンロードします。以下の本番サンプルでは、バッチループ、Retry-After の待機、完全なエラー処理を追加しています。

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

これは自己完結型のクライアントです。レンダリングリクエストのバッチを送信し、同時に処理中のジョブ数に上限を設け、サーバーが Retry-After で要求する間隔で各ジョブをポーリングし、サーバーが報告する progress を出力します。さらに、完了したすべての PDF をダウンロードし、失敗を記録します。PSR-18 HTTP クライアントと PSR-17 ファクトリー(Connect レシピが標準化しているトランスポートコントラクト)を使用し、各呼び出しが送出しうる最も具体的な例外をキャッチします。つまり、トランスポート障害には Psr\Http\Client\ClientExceptionInterface を、バッチを続行できないサーバーレスポンスには型付きの BatchJobException をキャッチします。空の catch ブロックは 1 つもありません。いずれもログを記録して再送出するか、定義された結果として記録します。

インラインの $documents リストを、独自の入力に置き換えてください。コンストラクターが PSR インターフェイスを期待する箇所には、プロジェクトで使用している具体的な HTTP クライアントとファクトリーを注入してください。

<?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 は次のとおりです。ドキュメントごとに 1 行で、パスは出力ディレクトリに依存します。

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • ジョブフィールドはトップレベルではなく data の下から読み取ってください。 成功レスポンスはすべて { "data": ..., "meta": ... } エンベロープでラップされています。data.statusdata.progress が処理対象のフィールドです。meta には、サポート対応時の突き合わせに使う request_id が含まれます。
  • progress は存在しないことがあります。 サーバーは、そのジョブで追跡している場合にのみ progress を含めます。フィールドが欠落している場合は、ゼロではなく「不明」として扱い、常に存在する status に基づいてループを制御してください。
  • 送信時にすでに終端になっていることがあります。 現在のリリースでは、サーバーは POST に応答する前にインラインでレンダリングするため、送信レスポンスが status: completed を含むことがあり、最初のポーリングで結果が準備できていることもあります。ポーリングループは、最初に pending であることを要求するのではなく、試行 0 回目で終端状態を受け入れる必要があります。
  • Retry-After に従ってください。 終端でないステータスレスポンスは Retry-After(2 秒間隔)を設定します。これより速くポーリングするとリクエストが無駄になり、429 を招きます。この値を盲目的に信頼せず、妥当な範囲に制限してください。
  • 完了前の /result409 になります。 ステータスのポーリングで completed が表示された後にのみ、結果エンドポイントを呼び出してください。409 Conflict はジョブが完了していないことを意味します。トランスポートエラーではありません。
  • Idempotency-Key は重複作業を防ぎます。 同じキーで再試行された送信は、元のジョブを返します(200 を返し、201 ではありません)。ネットワークの再試行が 2 回目のレンダリングを決して開始しないよう、ドキュメントごとに安定したキーを使用してください。異なる ボディで再利用されたキーは 409 競合になります。
  • ジョブは所有者スコープです。 ある API キーで送信されたジョブは別のキーからは見えません。所有者をまたぐ GET404 を返し、403 ではありません。送信時と同じ認証情報でポーリングしてください。
  • failed のジョブは error メッセージを含みます。 data.error は、終端の failed ステータスで読み取って記録してください。盲目的に再試行しないでください。

バッチのコストは、レンダリングの合計にポーリングのオーバーヘッドを加えたものです。クライアント側で調整できるレバーは 2 つあります。第 1 に、同時実行数を制限します。maxInFlight の上限は同時に追跡されるジョブ数を固定するため、バッチサイズに関係なく、クライアントのオープンリクエスト数とメモリ使用量を一定に保ちます。これはサーバーのワーカー数に合わせ、それを超えないように設定してください。ワーカー数を超える処理中ジョブは、各ジョブのキュー待ち時間を延ばすだけです。第 2 に、ポーリング間隔を尊重します。各ポーリングは軽量なステータス読み取りですが、密なループはリクエスト量を増やし、レートリミッターを作動させます。サーバーの 2 秒の Retry-After が適切な既定値であり、ランナーは 1 〜 30 秒の範囲に制限するため、1 つの遅いジョブがビジーループに陥ったりウィンドウを停滞させたりすることはありません。

非常に大きなバッチでは、すべてを最初に送信するのではなく、ウィンドウ単位で処理してください(ランナーは array_chunk を使用します)。これにより、クライアントの追跡状態とサーバーのキュー深度の両方が制限されるため、不正な形式または過大なバッチは、数千件の送信後ではなくウィンドウ内で失敗します。

  • ベアラートークンをログや URL に含めないでください。 API キーは Authorization ヘッダーでのみ送信されます。クエリ文字列、ログ行、書き出された成果物には絶対に含めないでください。ランナーは job_idstatus をログに記録し、認証情報は決して記録しません。
  • 出力パスは、サーバー制御のキーから導出してください。 ランナーは各出力パスを、コードが選択したドキュメントキーと固定の出力ディレクトリを結合して構築し、サーバーレスポンス内の値からは決して構築しません。ジョブフィールドをファイルシステムパスに展開しないでください。パストラバーサルの余地が生まれます。
  • ダウンロードしたバイトを検証してください。 ランナーは、ファイルを書き込む前に、200 を返した /result レスポンスに %PDF ヘッダーがあるかを確認します。ダウンロード成功のステータスだけでは、ボディが PDF である証拠にはなりません。
  • 検査するまで、結果は信頼できないものとして扱ってください。 ジョブの完了は、サーバーがバイトをレンダリングしたことを意味するのであって、それらのバイトを転送しても安全であることを意味するものではありません。結果をクライアントや下流のシステムに渡す前に、構造的な検査ステップを通してください。
  • 最小権限のキーを使用してください。 非同期ジョブのサーフェスは core ティアのレンダリングです。バッチには、必要な操作のみにスコープされたキーを発行し、シークレット管理ポリシーで定めたスケジュールに従ってローテーションしてください。
  • ポーリングのバジェットを制限してください。 maxPolls は、スタックしたジョブがクライアントを永遠に占有するのを防ぎます。バッチはブロックするのではなく、タイムアウトを結果として記録するため、1 つの不良ジョブが残りのジョブへのサービスを妨げることを防ぎます。

このレシピは規範的な標準への適合を主張しません。NextPDF Connect の非同期ジョブ REST エンドポイント(POST /api/v1/jobsGET /api/v1/jobs/{id}GET /api/v1/jobs/{id}/result)を利用し、サーバーが定義するジョブレコードのフィールド(statusprogresserrorresult_urlpoll_url)を読み取ります。ダウンロードした結果に対する %PDF ヘッダーのチェックは、レスポンスが PDF マーカーで始まることを確認するだけであり、有効性や適合性を判定するものではありません。ドキュメント一式にわたる標準チェックには、Enterprise のバッチコンプライアンスツールを使用してください。ここで扱うレンダリングジョブとは異なるサーフェスである Connect 経由のバッチ標準チェック を参照してください。