Przejdź do głównej zawartości

Wsadowe generowanie PDF przez Connect ze śledzeniem postępu

Przetwórz listę dokumentów od początku do końca z jednego procesu klienta za pomocą NextPDF Connect — samodzielnej dystrybucji silnika jako usługi HTTP. Ten przepis wysyła każde żądanie renderowania do punktu końcowego zadań asynchronicznych POST /api/v1/jobs, odpytuje każde zadanie przez GET /api/v1/jobs/{id} aż do osiągnięcia stanu końcowego, odczytuje zgłaszane przez serwer pola status i progress dla każdego zadania oraz pobiera każdy ukończony plik PDF z GET /api/v1/jobs/{id}/result.

Cykl życia zadania jest stały i prosty. Zadanie ma stan pending, następnie running, a potem dokładnie jeden stan końcowy: completed, failed lub cancelled. Odpowiedź ze stanem zawiera liczbę całkowitą progress od 0 do 100, gdy serwer ją śledzi, a przy każdym odpytaniu zadania w stanie niekońcowym także nagłówek Retry-After, który wskazuje, kiedy wysłać kolejne żądanie. Każde wysłanie oznacz nagłówkiem Idempotency-Key, aby ponowienie zwracało to samo zadanie zamiast rozpoczynać drugie renderowanie.

Ten przepis pokazuje wariant na poziomie protokołu. Wywołuje REST-owy interfejs bezpośrednio i nie zakłada zestawu narzędzi programistycznych (SDK) dla konkretnego języka, więc ten sam przepływ można przenieść do dowolnego klienta HTTP.

Po stronie serwera użyj standardowej dystrybucji Connect:

Okno terminala
composer require nextpdf/server

Klient PHP w przykładzie produkcyjnym poniżej używa klienta HTTP oraz fabryk wiadomości zgodnych z PSR-18 i PSR-17. Zainstaluj implementacje używane już w projekcie, na przykład:

Okno terminala
composer require psr/http-client psr/http-factory

Interfejs zadań asynchronicznych oddziela wysyłanie od pobierania. Nie utrzymujesz jednego długiego, otwartego połączenia HTTP dla każdego dokumentu. Zamiast tego wysyłasz zadanie, otrzymujesz identyfikator i odpytujesz lekki punkt końcowy stanu, aż zadanie się zakończy. Taki model ułatwia obsługę partii: klient śledzi N niezależnych zadań naraz bez N zablokowanych połączeń.

Przepływ obsługują trzy punkty końcowe:

  • POST /api/v1/jobs przyjmuje to samo ciało żądania renderowania, co synchroniczny punkt końcowy /api/v1/render: pole page_size, pole orientation oraz uporządkowaną tablicę operations. Zwraca 201 Created dla nowego zadania albo 200 OK, gdy Idempotency-Key pasuje do zadania, które już wysłano.
  • GET /api/v1/jobs/{id} zwraca bieżący rekord zadania. Dla zadania w stanie niekońcowym ustawia też nagłówek Retry-After (serwer stosuje interwał 2-sekundowy) oraz pole poll_url. Respektuj ten nagłówek, zamiast odpytywać w ciasnej pętli.
  • GET /api/v1/jobs/{id}/result przesyła strumieniowo gotowy plik PDF jako application/pdf. Zwraca 409 Conflict, jeśli zadanie nie osiągnęło jeszcze stanu completed, więc wywołuj ten punkt dopiero wtedy, gdy odpytanie stanu potwierdzi stan końcowy.

Każda pomyślna odpowiedź ma tę samą kopertę: obiekt data z polami zadania oraz obiekt meta z polami request_id, timestamp, duration_ms oraz api_version. Pola zadania, które odczytujesz, znajdują się pod data: data.status, data.progress, data.job_id oraz, w przypadku ukończonego zadania, data.result_url.

Jedno zastrzeżenie dotyczące bieżącego wydania: serwer przetwarza wysłane zadanie w trybie wbudowanym, zanim odpowie na POST. W praktyce odpowiedź na wysłanie może już zawierać końcowy status, a wynik może być gotowy przy pierwszym odpytaniu. Udokumentowany tutaj kontrakt odpytywania i postępu jest stabilnym kształtem API. Serwer utrzymuje go bez zmian, gdy zaplecze przetwarzania przechodzi na kolejkowaną pulę procesów roboczych, więc klient z pętlą odpytywania jest poprawny dziś i pozostanie poprawny po tej zmianie. Napisz pętlę odpytywania. Nie zakładaj, że pierwsza odpowiedź jest niekońcowa ani że jest końcowa.

Dokument OpenAPI serwera oraz routing JobHandler definiują REST-ową powierzchnię zadań asynchronicznych Connect:

  • POST /api/v1/jobs: wyślij zadanie renderowania. Opcjonalny nagłówek żądania Idempotency-Key. Ciało jest żądaniem renderowania (operations jest wymagane i musi zawierać co najmniej jedną operację). Odpowiedzi: 201 nowe, 200 idempotentne powtórzenie, 422 nieprawidłowe ciało, 409 konflikt idempotencji, 429 ograniczenie liczby żądań.
  • GET /api/v1/jobs/{id}: odpytaj stan. Odpowiedź 200 z rekordem zadania; nagłówek Retry-After obecny, gdy stan jest niekońcowy; 404, jeśli zadanie nie istnieje lub należy do innego klienta.
  • GET /api/v1/jobs/{id}/result: pobierz plik PDF. 200 application/pdf, gdy completed; 409, gdy jeszcze nieukończone; 404, jeśli zadanie jest nieznane.
  • DELETE /api/v1/jobs/{id}: anuluj zadanie w stanie pending lub running albo usuń zadanie w stanie completed (204).

Rekord zadania pod data zawiera następujące pola, dokładnie w postaci serializowanej przez serwer.

  • job_id: identyfikator (przedrostek job_ oraz 24 znaki szesnastkowe).
  • status: jeden z pending, running, completed, failed, cancelled. Pierwsze dwa są niekońcowe; ostatnie trzy są końcowe.
  • created_at, a po ustawieniu także started_at oraz completed_at: znaczniki czasu ISO-8601.
  • progress: liczba całkowita od 0 do 100, obecna tylko wtedy, gdy serwer ją śledzi dla danego zadania; w przeciwnym razie nieobecna (traktuj jako nieznaną).
  • error: ciąg z komunikatem, obecny tylko w zadaniu w stanie failed.
  • result_url: obecne tylko dla zadania w stanie completed; ścieżka do pobrania wyniku.
  • poll_url: obecne tylko wtedy, gdy zadanie jest w stanie niekońcowym.

Uwierzytelnianie odbywa się tokenem okaziciela w nagłówku Authorization: Authorization: Bearer npk_live_{kid}_{secret}.

Przeprowadza jedno zadanie od początku do końca na poziomie protokołu, aby pokazać trzy wywołania i zwracane pola. Wysyła zadanie, odpytuje raz i pobiera wynik. Przykład produkcyjny poniżej dodaje pętlę wsadową, oczekiwanie według Retry-After oraz pełną obsługę błędów.

Okno terminala
# 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

Ten samodzielny klient wysyła partię żądań renderowania, ogranicza liczbę zadań przetwarzanych jednocześnie, odpytuje każde zadanie w tempie narzuconym przez serwer nagłówkiem Retry-After, zgłasza wartość progress zwróconą przez serwer, pobiera każdy ukończony plik PDF i rejestruje niepowodzenia. Używa klienta HTTP zgodnego z PSR-18 oraz fabryk PSR-17, czyli kontraktu transportu ujednoliconego w przepisach Connect. Przechwytuje także najbardziej szczegółowy wyjątek, jaki może zgłosić każde wywołanie: Psr\Http\Client\ClientExceptionInterface dla niepowodzenia transportu oraz typowany BatchJobException dla odpowiedzi serwera, która uniemożliwia kontynuację partii. Żaden blok catch nie jest pusty. Każdy z nich rejestruje i ponownie zgłasza wyjątek albo zapisuje zdefiniowany wynik.

Zastąp osadzoną listę $documents własnymi danymi wejściowymi. Wstrzyknij konkretnego klienta HTTP i fabryki z projektu tam, gdzie konstruktor oczekuje interfejsów 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";
}

Oczekiwane wyjście STDOUT zawiera jeden wiersz dla każdego dokumentu. Ścieżki zależą od katalogu wyjściowego:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Odczytuj pola zadania pod data, a nie na najwyższym poziomie. Każda pomyślna odpowiedź jest opakowana w kopertę { "data": ..., "meta": ... }. data.status i data.progress to pola, na których działasz; meta zawiera request_id do korelacji w obsłudze zgłoszeń.
  • Pole progress może być nieobecne. Serwer dołącza progress tylko wtedy, gdy śledzi je dla danego zadania. Traktuj brakujące pole jako „nieznane”, a nie jako zero, i opieraj pętlę na polu status, które jest zawsze obecne.
  • Odpowiedź na wysłanie może już wskazywać stan końcowy. W bieżącym wydaniu serwer renderuje w trybie wbudowanym, zanim odpowie na POST, więc odpowiedź na wysłanie może zawierać status: completed, a wynik może być gotowy przy pierwszym odpytaniu. Pętla odpytywania musi akceptować stan końcowy już przy próbie zerowej, zamiast wymagać najpierw stanu pending.
  • Respektuj Retry-After. Odpowiedzi ze stanem niekońcowym ustawiają Retry-After (interwał 2-sekundowy). Szybsze odpytywanie marnuje żądania i prowokuje 429. Ogranicz wartość do rozsądnego zakresu, zamiast ufać jej bezkrytycznie.
  • Wywołanie /result przed ukończeniem to 409. Wywołuj punkt końcowy wyniku dopiero wtedy, gdy odpytanie stanu pokaże completed. 409 Conflict oznacza, że zadanie nie jest ukończone; nie jest to błąd transportu.
  • Idempotency-Key zapobiega zduplikowanej pracy. Ponowione wysłanie z tym samym kluczem zwraca pierwotne zadanie (200 zamiast 201). Używaj stabilnego klucza dla każdego dokumentu, aby ponowienie sieciowe nigdy nie rozpoczynało drugiego renderowania. Ponowne użycie klucza z innym ciałem powoduje konflikt 409.
  • Zadania są ograniczone do właściciela. Zadanie wysłane z użyciem jednego klucza API jest niewidoczne dla innego; GET między właścicielami zwraca 404, a nie 403. Odpytuj przy użyciu tego samego poświadczenia, którym wysłano zadanie.
  • Zadanie w stanie failed zawiera komunikat error. Przy końcowym stanie failed odczytaj data.error i zapisz go. Nie ponawiaj bezkrytycznie.

Koszt partii to suma renderowań plus narzut odpytywania. Po stronie klienta liczą się dwa mechanizmy. Po pierwsze, ogranicz współbieżność: limit maxInFlight ustala, ile zadań jest śledzonych naraz, co utrzymuje liczbę otwartych żądań i zużycie pamięci klienta na stałym poziomie niezależnie od rozmiaru partii. Ustaw go tak, aby odpowiadał liczbie procesów roboczych serwera, nie wyżej; liczba zadań przetwarzanych jednocześnie większa niż liczba procesów roboczych tylko wydłuża czas oczekiwania każdego zadania w kolejce. Po drugie, respektuj interwał odpytywania: każde odpytanie to tani odczyt stanu, ale ciasna pętla zwiększa liczbę żądań i wyzwala mechanizm ograniczania liczby żądań. 2-sekundowa wartość Retry-After serwera to właściwa wartość domyślna, a runner ogranicza ją do zakresu od 1 do 30 sekund, aby pojedyncze wolne zadanie nie mogło wymusić jałowej pętli ani zablokować okna.

W przypadku bardzo dużych partii przetwarzaj dokumenty w oknach (runner używa array_chunk), zamiast wysyłać wszystko z góry. Ogranicza to zarówno śledzony stan klienta, jak i głębokość kolejki serwera, więc wadliwa lub zbyt duża partia kończy się niepowodzeniem wewnątrz jednego okna, a nie po tysiącach wysłań.

  • Trzymaj token okaziciela z dala od logów i adresów URL. Klucz API jest przesyłany wyłącznie w nagłówku Authorization. Nigdy nie umieszczaj go w ciągu zapytania, w wierszu logu ani w zapisanym artefakcie. Runner loguje job_id oraz status, nigdy poświadczenia.
  • Wyprowadzaj ścieżki wyjściowe z kluczy kontrolowanych po stronie serwera. Runner buduje każdą ścieżkę wyjściową z klucza dokumentu wybranego przez Twój kod, połączonego ze stałym katalogiem wyjściowym, nigdy z wartości w odpowiedzi serwera. Nie wstawiaj pola zadania do ścieżki w systemie plików, ponieważ umożliwiłoby to atak typu path traversal.
  • Weryfikuj pobrane bajty. Runner sprawdza, czy odpowiedź 200 z /result zaczyna się od nagłówka %PDF, zanim zapisze plik. Sam pomyślny status pobrania nie dowodzi, że ciało jest plikiem PDF.
  • Traktuj wynik jako niezaufany do czasu sprawdzenia. Ukończone zadanie oznacza, że serwer wyrenderował bajty, a nie że można je bezpiecznie przekazać dalej. Przepuść wyniki przez krok inspekcji strukturalnej, zanim przekażesz je klientowi lub dalszemu systemowi.
  • Używaj klucza o najmniejszych uprawnieniach. Interfejs zadań asynchronicznych to renderowanie warstwy core. Dla partii wystaw klucz o zakresie obejmującym dokładnie te operacje, których potrzebuje, i rotuj go zgodnie z harmonogramem ustalonym przez politykę zarządzania kluczami tajnymi.
  • Ogranicz budżet odpytywania. maxPolls powstrzymuje zablokowane zadanie przed utrzymywaniem klienta w nieskończoność. Partia rejestruje przekroczenie limitu czasu jako wynik zamiast blokować się, co zapobiega odmowie usługi pozostałym zadaniom przez jedno wadliwe zadanie.

Ten przepis nie formułuje żadnych normatywnych twierdzeń o zgodności ze standardami. Korzysta z REST-owych punktów końcowych zadań asynchronicznych NextPDF Connect (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) i odczytuje pola rekordu zadania, które definiuje serwer (status, progress, error, result_url, poll_url). Sprawdzenie nagłówka %PDF w pobranym wyniku potwierdza tylko, że odpowiedź zaczyna się znacznikiem PDF; nie rozstrzyga o ważności ani zgodności. Do sprawdzania zgodności ze standardami w partii dokumentów użyj narzędzia do wsadowej kontroli zgodności w edycji Enterprise. Zobacz Wsadowa kontrola standardów przez Connect, czyli inny interfejs niż omawiane tutaj zadania renderowania.