Pular para o conteúdo

Geração em lote no Connect com acompanhamento de progresso

Processe uma lista de documentos até a conclusão a partir de um único processo cliente usando o NextPDF Connect, a distribuição autônoma de serviço HTTP do motor. Esta receita envia cada requisição de renderização para o endpoint de job assíncrono POST /api/v1/jobs, consulta cada job com GET /api/v1/jobs/{id} até alcançar um estado terminal, lê os campos status e progress reportados pelo servidor para cada job e baixa cada PDF concluído de GET /api/v1/jobs/{id}/result.

O ciclo de vida do job é fixo e enxuto. Um job fica pending, depois running e então passa para exatamente um estado terminal: completed, failed ou cancelled. A resposta de status traz um inteiro progress de 0 a 100 quando o servidor acompanha esse valor, além de um cabeçalho Retry-After em cada consulta não terminal, indicando quando enviar a próxima requisição. Identifique cada envio com um Idempotency-Key para que uma repetição retorne o mesmo job em vez de iniciar uma segunda renderização.

Esta receita usa o caminho de baixo nível (wire-level). Ela chama a superfície REST diretamente e não pressupõe nenhum software development kit (SDK) específico de linguagem, então você pode portar o mesmo fluxo para qualquer cliente HTTP.

O lado servidor usa a distribuição padrão do Connect:

Terminal window
composer require nextpdf/server

O cliente PHP no exemplo de produção abaixo usa um cliente Hypertext Transfer Protocol (HTTP) e factories de mensagem compatíveis com PSR-18 e PSR-17. Instale as implementações que o projeto já adota como padrão, por exemplo:

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

A superfície de jobs assíncronos separa o envio da recuperação. Você não mantém uma conexão HTTP longa aberta por documento. Em vez disso, envia um job, recebe um identificador e consulta um endpoint de status barato até o job terminar. Esse formato torna o lote gerenciável: o cliente acompanha N jobs independentes ao mesmo tempo, sem N conexões bloqueadas.

Três endpoints conduzem o fluxo:

  • POST /api/v1/jobs aceita o mesmo corpo de requisição de renderização que o endpoint síncrono /api/v1/render: um page_size, uma orientation e um array ordenado operations. Ele retorna 201 Created para um job novo, ou 200 OK quando um Idempotency-Key corresponde a um job que você já enviou.
  • GET /api/v1/jobs/{id} retorna o registro atual do job. Para um job não terminal, ele também define um cabeçalho Retry-After (o servidor usa um intervalo de 2 segundos) e um campo poll_url. Respeite o cabeçalho em vez de consultar em um laço muito fechado.
  • GET /api/v1/jobs/{id}/result transmite o PDF finalizado como application/pdf. Ele retorna 409 Conflict se o job não tiver alcançado completed, então chame-o apenas quando a consulta de status confirmar o estado terminal.

Toda resposta bem-sucedida compartilha um único envelope: um objeto data com os campos do job e um objeto meta com o request_id, o timestamp, o duration_ms e o api_version. Os campos do job que você lê ficam sob data: data.status, data.progress, data.job_id e, em um job concluído, data.result_url.

Uma observação sobre a versão atual: o servidor processa um job enviado de forma inline antes de responder ao POST. Na prática, a resposta de envio já pode trazer um status terminal, e o resultado pode estar pronto na primeira consulta. O contrato de consulta e progresso documentado aqui é o formato estável da Application Programming Interface (API). O servidor o mantém inalterado conforme o backend de processamento migra para um pool de workers em fila, então um cliente que consulta está correto hoje e continuará correto depois dessa mudança. Escreva o laço de consulta. Não pressuponha que a primeira resposta seja não terminal nem pressuponha que ela seja terminal.

O documento OpenAPI do servidor e o roteamento do JobHandler definem a superfície REST de jobs assíncronos do Connect:

  • POST /api/v1/jobs: envia um job de renderização. Cabeçalho de requisição Idempotency-Key opcional. O corpo é uma requisição de renderização (o campo operations é obrigatório e deve conter pelo menos uma operação). Respostas: 201 novo, 200 repetição idempotente, 422 corpo inválido, 409 conflito de idempotência, 429 limite de taxa atingido.
  • GET /api/v1/jobs/{id}: consulta o status. Resposta 200 com o registro do job; cabeçalho Retry-After presente enquanto o job não for terminal; 404 se o job não existir ou pertencer a outro cliente.
  • GET /api/v1/jobs/{id}/result: baixa o PDF. 200 application/pdf quando completed; 409 quando ainda não concluído; 404 se o job for desconhecido.
  • DELETE /api/v1/jobs/{id}: cancela um job pending ou running, ou exclui um completed (204).

O registro do job sob data traz estes campos, exatamente como o servidor os serializa.

  • job_id: o identificador (um prefixo job_ e 24 caracteres hexadecimais).
  • status: um de pending, running, completed, failed, cancelled. Os dois primeiros são não terminais; os três últimos são terminais.
  • created_at e, uma vez definidos, started_at e completed_at: timestamps ISO-8601.
  • progress: um inteiro de 0 a 100, presente apenas quando o servidor o acompanha para o job; ausente (trate como desconhecido) caso contrário.
  • error: uma string de mensagem, presente apenas em um job failed.
  • result_url: presente apenas em um job completed; o caminho para baixar o resultado.
  • poll_url: presente apenas enquanto o job não é terminal.

A autenticação é um bearer token no cabeçalho Authorization: Authorization: Bearer npk_live_{kid}_{secret}.

Este exemplo conduz um job de ponta a ponta no nível wire para que você veja as três chamadas e os campos que elas retornam. Ele envia, consulta uma vez e baixa. O exemplo de produção abaixo adiciona o laço de lote, a espera do Retry-After e o tratamento completo de erros.

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

Este cliente autossuficiente envia um lote de requisições de renderização, limita quantos jobs ficam em andamento ao mesmo tempo, consulta cada job na cadência que o servidor define por meio de Retry-After, informa o valor de progress que o servidor retorna, baixa cada PDF concluído e registra as falhas. Ele usa um cliente HTTP PSR-18 e factories PSR-17, o contrato de transporte que as receitas do Connect adotam como padrão. Ele também captura a exceção mais específica que cada chamada pode lançar: Psr\Http\Client\ClientExceptionInterface para uma falha de transporte e uma BatchJobException tipada para uma resposta do servidor que impede o lote de continuar. Nenhum bloco catch fica vazio. Cada um registra em log e relança, ou registra um resultado definido.

Substitua a lista $documents embutida pelas suas próprias entradas. Injete o cliente HTTP e as factories concretas do projeto onde o construtor espera as interfaces 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";
}

O STDOUT esperado é uma linha por documento. Os caminhos dependem do diretório de saída:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Leia os campos do job sob data, não no nível superior. Toda resposta bem-sucedida vem envolvida em um envelope { "data": ..., "meta": ... }. data.status e data.progress são os campos sobre os quais você atua; meta traz request_id para correlação de suporte.
  • progress pode estar ausente. O servidor inclui progress apenas quando acompanha esse valor para aquele job. Trate um campo ausente como “desconhecido”, não como zero, e conduza o laço pelo status, que está sempre presente.
  • O envio pode já ser terminal. Na versão atual, o servidor renderiza de forma inline antes de responder ao POST, então a resposta de envio pode trazer status: completed e o resultado pode estar pronto na primeira consulta. O laço de consulta deve aceitar um estado terminal na tentativa zero em vez de exigir um pending primeiro.
  • Respeite o Retry-After. Respostas de status não terminais definem Retry-After (um intervalo de 2 segundos). Consultar mais rápido desperdiça requisições e provoca um 429. Limite o valor a uma faixa sensata em vez de confiar nele sem validação.
  • /result antes da conclusão é um 409. Chame o endpoint de resultado apenas depois que a consulta de status mostrar completed. Um 409 Conflict significa que o job não está concluído; não é um erro de transporte.
  • O Idempotency-Key evita trabalho duplicado. Um envio repetido com a mesma chave retorna o job original (200 em vez de 201). Use uma chave estável por documento para que uma nova tentativa de rede nunca inicie uma segunda renderização. Uma chave reutilizada com um corpo diferente é um conflito 409.
  • Os jobs têm escopo de proprietário. Um job enviado sob uma chave de API é invisível para outra; um GET de outro proprietário retorna 404, não 403. Consulte com a mesma credencial com que você enviou.
  • Um job failed traz uma mensagem error. Leia data.error quando o status terminal for failed e registre-a. Não tente novamente às cegas.

O custo de um lote é a soma das renderizações mais a sobrecarga de consulta. Duas alavancas controlam o lado cliente. Primeiro, limite a concorrência: o limite maxInFlight fixa quantos jobs são acompanhados ao mesmo tempo, o que mantém a contagem de requisições abertas e a memória do cliente estáveis independentemente do tamanho do lote. Defina-o para corresponder à contagem de workers do servidor, não acima disso; mais jobs em andamento do que workers apenas alonga a espera em fila de cada job. Segundo, respeite o intervalo de consulta: cada consulta é uma leitura de status barata, mas um laço apertado aumenta o volume de requisições e aciona o limitador de taxa. O Retry-After de 2 segundos do servidor é o padrão correto, e o runner limita a uma faixa de 1 a 30 segundos para que um único job lento não gere busy-loop nem bloqueie a janela.

Para lotes muito grandes, processe em janelas (o runner usa array_chunk) em vez de enviar tudo de uma vez. Isso limita tanto o estado que o cliente precisa acompanhar quanto a profundidade da fila do servidor, então um lote malformado ou grande demais falha dentro de uma janela em vez de depois de milhares de envios.

  • Mantenha o bearer token fora de logs e URLs. A chave de API trafega apenas no cabeçalho Authorization. Nunca a coloque em uma query string, em uma linha de log nem em um artefato gravado. O runner registra em log o job_id e o status, nunca a credencial.
  • Derive os caminhos de saída de chaves controladas pelo servidor. O runner constrói cada caminho de saída a partir da chave de documento que o seu código escolheu, unida a um diretório de saída fixo, nunca a partir de um valor em uma resposta do servidor. Não interpole um campo do job em um caminho de sistema de arquivos, o que abriria uma path traversal.
  • Valide os bytes baixados. O runner verifica um 200 de /result em busca do marcador %PDF antes de gravar o arquivo. Um status de download bem-sucedido não é, por si só, prova de que o corpo é um PDF.
  • Trate o resultado como não confiável até ser inspecionado. Um job concluído significa que o servidor renderizou bytes, não que esses bytes sejam seguros para encaminhar. Passe os resultados por uma etapa de inspeção estrutural antes de entregá-los a um cliente ou sistema a jusante.
  • Use uma chave de menor privilégio. A superfície de jobs assíncronos é renderização de nível core. Emita para o lote uma chave com escopo exatamente nas operações de que ele precisa e rotacione-a na cadência que a política de gerenciamento de segredos define.
  • Limite o orçamento de consultas. maxPolls impede que um job travado prenda o cliente para sempre. O lote registra o timeout como um resultado em vez de bloquear, o que impede que um job ruim negue serviço aos demais.

Esta receita não faz nenhuma afirmação normativa de conformidade com padrões. Ela consome os endpoints REST de jobs assíncronos do NextPDF Connect (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) e lê os campos do registro do job definidos pelo servidor (status, progress, error, result_url, poll_url). A verificação do marcador %PDF em um resultado baixado confirma apenas que a resposta começa com o marcador de PDF; não é uma determinação de validade nem de conformidade. Para verificar padrões em um conjunto de documentos, use a ferramenta Enterprise de conformidade em lote. Consulte Verificação de padrões em lote pelo Connect, uma superfície diferente dos jobs de renderização abordados aqui.