Перейти к содержимому

Пакетная генерация PDF через Connect с отслеживанием прогресса

Обрабатывайте список документов до завершения в одном клиентском процессе через NextPDF Connect — автономный дистрибутив HTTP-сервиса движка. В этом рецепте каждый запрос на отрисовку отправляется в конечную точку асинхронных задач POST /api/v1/jobs; каждая задача опрашивается через GET /api/v1/jobs/{id}, пока не достигнет конечного состояния; клиент читает поля status и progress, которые сервер сообщает для каждой задачи, и загружает каждый готовый PDF из GET /api/v1/jobs/{id}/result.

Жизненный цикл задачи фиксирован и прост. Задача переходит из pending в running, а затем ровно в одно конечное состояние: completed, failed или cancelled. Ответ на запрос статуса содержит целое число progress от 0 до 100, если сервер отслеживает прогресс, а при каждом опросе незавершённой задачи — заголовок Retry-After, который сообщает, когда отправлять следующий запрос. Добавляйте к каждой отправке ключ Idempotency-Key, чтобы повторная отправка возвращала ту же задачу вместо запуска второй отрисовки.

Этот рецепт использует низкоуровневый путь: он вызывает REST-интерфейс напрямую и не требует SDK для конкретного языка. Поэтому вы можете перенести тот же поток на любой HTTP-клиент.

На сервере используйте стандартный дистрибутив Connect:

Окно терминала
composer require nextpdf/server

PHP-клиент в рабочем примере ниже использует HTTP-клиент и фабрики сообщений, совместимые с PSR-18 и PSR-17. Установите реализации, которые уже приняты в вашем проекте, например:

Окно терминала
composer require psr/http-client psr/http-factory

Интерфейс асинхронных задач отделяет отправку от получения. Вы не держите отдельное длительное HTTP-соединение открытым для каждого документа. Вместо этого вы отправляете задачу, получаете идентификатор и опрашиваете легковесную конечную точку статуса, пока задача не завершится. Такая форма делает пакет управляемым: клиент одновременно отслеживает N независимых задач без N заблокированных соединений.

Поток обеспечивают три конечные точки:

  • POST /api/v1/jobs принимает то же тело запроса на отрисовку, что и синхронная конечная точка /api/v1/render: page_size, orientation и упорядоченный массив 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, поэтому вызывайте эту конечную точку только после того, как опрос статуса подтвердит конечное состояние.

Все успешные ответы используют одну общую оболочку: объект data с полями задачи и объект meta с полями request_id, timestamp, duration_ms и api_version. Поля задачи, которые вы читаете, находятся в data: data.status, data.progress, data.job_id и, для завершённой задачи, data.result_url.

Есть одна оговорка для текущего выпуска: сервер обрабатывает отправленную задачу синхронно, прежде чем ответить на POST. На практике ответ на отправку уже может содержать конечный status, а результат может быть готов при первом опросе. Описанный здесь контракт опроса и прогресса — стабильная форма API. Сервер сохраняет её неизменной по мере перевода обработки на пул рабочих процессов с очередью, поэтому клиент с опросом корректен сегодня и останется корректным после этого изменения. Реализуйте цикл опроса. Не предполагайте, что первый ответ незавершённый, и не предполагайте, что он конечный.

Серверный документ OpenAPI и маршрутизация JobHandler определяют REST-интерфейс асинхронных задач Connect:

  • POST /api/v1/jobs: отправить задачу на отрисовку. Необязательный заголовок запроса Idempotency-Key. Тело — это запрос на отрисовку (operations обязателен и должен содержать хотя бы одну операцию). Ответы: 201 — новая задача, 200 — идемпотентный повтор, 422 — недопустимое тело, 409 — конфликт идемпотентности, 429 — превышен лимит запросов.
  • GET /api/v1/jobs/{id}: опросить статус. Ответ 200 с записью задачи; заголовок Retry-After присутствует, пока задача незавершённая; 404, если задача не существует или принадлежит другому клиенту.
  • GET /api/v1/jobs/{id}/result: загрузить PDF. 200 application/pdf, когда задача completed; 409, если она ещё не завершена; 404, если неизвестна.
  • DELETE /api/v1/jobs/{id}: отменить задачу в состоянии pending или running либо удалить задачу в состоянии completed (204).

Запись задачи в data содержит следующие поля — именно так, как их сериализует сервер.

  • job_id: идентификатор (префикс job_ и 24 шестнадцатеричных символа).
  • status: одно из pending, running, completed, failed, cancelled. Первые два — незавершённые; последние три — конечные.
  • created_at, а после установки — started_at и completed_at: временные метки ISO-8601.
  • progress: целое число от 0 до 100, присутствует только тогда, когда сервер отслеживает его для задачи; в противном случае отсутствует (считайте неизвестным).
  • error: строка сообщения, присутствует только для задачи в состоянии failed.
  • result_url: присутствует только для задачи в состоянии completed; путь для загрузки результата.
  • poll_url: присутствует только пока задача незавершённая.

Аутентификация выполняется bearer-токеном в заголовке Authorization: Authorization: Bearer npk_live_{kid}_{secret}.

Этот пример проводит одну задачу от начала до конца на низком уровне, чтобы вы могли увидеть три вызова и поля, которые они возвращают. Он отправляет задачу, один раз опрашивает статус и загружает результат. Рабочий пример ниже добавляет пакетный цикл, ожидание по Retry-After и полную обработку ошибок.

Окно терминала
# 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 и записывает сбои. Он использует HTTP-клиент PSR-18 и фабрики PSR-17 — транспортный контракт, принятый во всех рецептах Connect. Он также перехватывает наиболее конкретное исключение, которое может вызвать каждый вызов: Psr\Http\Client\ClientExceptionInterface для сбоя транспорта и типизированное BatchJobException для ответа сервера, из-за которого пакет не может продолжаться. Ни один блок catch не пуст. Каждый из них логирует и повторно выбрасывает исключение либо записывает конкретный исход.

Замените встроенный список $documents своими данными. Передайте конкретные HTTP-клиент и фабрики вашего проекта там, где конструктор ожидает интерфейсы 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 — по одной строке на документ. Пути зависят от вашего выходного каталога:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Читайте поля задачи в data, а не на верхнем уровне. Каждый успешный ответ обёрнут в оболочку { "data": ..., "meta": ... }. data.status и data.progress — это поля, на которые вы реагируете; meta содержит request_id для сопоставления при обращении в поддержку.
  • progress может отсутствовать. Сервер включает progress только тогда, когда отслеживает его для этой задачи. Считайте отсутствующее поле «неизвестным», а не нулём, и ведите цикл по status, который присутствует всегда.
  • Ответ на отправку уже может быть конечным. В текущем выпуске сервер выполняет отрисовку синхронно, прежде чем ответить на POST, поэтому ответ на отправку может содержать status: completed, а результат может быть готов при первом опросе. Ваш цикл опроса должен принимать конечное состояние на нулевой попытке, а не настаивать на том, что сначала должно быть pending.
  • Соблюдайте Retry-After. Ответы со статусом незавершённой задачи устанавливают Retry-After (интервал в 2 секунды). Более частый опрос расходует запросы впустую и провоцирует 429. Ограничьте значение разумным диапазоном, а не доверяйте ему слепо.
  • Запрос /result до завершения — это 409. Вызывайте конечную точку результата только после того, как опрос статуса покажет completed. 409 Conflict означает, что задача не завершена; это не ошибка транспорта.
  • Idempotency-Key предотвращает дублирование работы. Повторная отправка с тем же ключом возвращает исходную задачу (200 вместо 201). Используйте стабильный ключ для каждого документа, чтобы сетевой повтор никогда не запускал вторую отрисовку. Повторно использованный ключ с другим телом — это конфликт 409.
  • Задачи привязаны к владельцу. Задача, отправленная под одним ключом API, невидима для другого; GET от другого владельца возвращает 404, а не 403. Опрашивайте теми же учётными данными, с которыми отправляли.
  • Задача в состоянии failed содержит сообщение error. Читайте data.error при конечном статусе failed и записывайте его. Не выполняйте повтор вслепую.

Стоимость пакета — это сумма отрисовок плюс накладные расходы на опрос. На стороне клиента есть два рычага управления. Во-первых, ограничивайте параллелизм: предел maxInFlight фиксирует, сколько задач отслеживается одновременно, что удерживает количество открытых запросов клиента и потребление памяти на постоянном уровне независимо от размера пакета. Задавайте его по числу рабочих процессов сервера и не выше: больше одновременных задач, чем рабочих процессов, лишь увеличит время ожидания каждой задачи в очереди. Во-вторых, соблюдайте интервал опроса: каждый опрос — это дешёвое чтение статуса, но плотный цикл увеличивает объём запросов и может включить ограничитель частоты. Серверный интервал Retry-After в 2 секунды — правильное значение по умолчанию, а исполнитель ограничивает его диапазоном от 1 до 30 секунд, чтобы одна медленная задача не могла создать холостой цикл или застопорить окно.

Для очень больших пакетов обрабатывайте документы окнами (исполнитель использует array_chunk), а не отправляйте всё сразу. Это ограничивает как отслеживаемое состояние клиента, так и глубину очереди сервера, поэтому некорректный или слишком большой пакет завершится с ошибкой внутри одного окна, а не после тысяч отправок.

  • Держите bearer-токен вне журналов и URL. Ключ API передаётся только в заголовке Authorization. Никогда не помещайте его в строку запроса, строку журнала или записываемый артефакт. Исполнитель логирует job_id и status, но никогда — учётные данные.
  • Формируйте выходные пути из ключей, контролируемых вашим кодом. Исполнитель строит каждый выходной путь из выбранного вашим кодом ключа документа и фиксированного выходного каталога, а не из значения в ответе сервера. Не подставляйте поле задачи в путь файловой системы — это создало бы возможность обхода каталога.
  • Проверяйте загруженные байты. Исполнитель проверяет ответ 200 от /result на наличие сигнатуры %PDF, прежде чем записать файл. Успешный статус загрузки сам по себе не доказывает, что тело — это PDF.
  • Считайте результат недоверенным, пока он не проверен. Завершённая задача означает, что сервер отрисовал байты, а не то, что эти байты безопасно передавать дальше. Пропускайте результаты через этап структурной проверки, прежде чем передавать их клиенту или нижестоящей системе.
  • Используйте ключ с минимальными привилегиями. Интерфейс асинхронных задач — это отрисовка уровня core. Выдавайте пакету ключ, ограниченный ровно теми операциями, которые ему нужны, и меняйте его по графику, который задаёт ваша политика управления секретами.
  • Ограничивайте бюджет опроса. maxPolls не позволяет зависшей задаче удерживать клиент вечно. Пакет записывает истечение времени ожидания как исход, а не блокируется, что не даёт одной проблемной задаче лишить обслуживания остальные.

Этот рецепт не делает нормативных заявлений о соответствии стандартам. Он использует REST-конечные точки асинхронных задач NextPDF Connect (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) и читает поля записи задачи, которые определяет сервер (status, progress, error, result_url, poll_url). Проверка заголовка %PDF для загруженного результата подтверждает только то, что ответ начинается с маркера PDF; это не определение валидности или соответствия. Для проверки соответствия стандартам по набору документов используйте инструмент пакетной проверки соответствия уровня Enterprise. См. Пакетная проверка по стандартам через Connect — это другой интерфейс, не задачи отрисовки, рассмотренные здесь.