Geração em lote no Connect com acompanhamento de progresso
Visão geral
Seção intitulada “Visão geral”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.
Instalação
Seção intitulada “Instalação”O lado servidor usa a distribuição padrão do Connect:
composer require nextpdf/serverO 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:
composer require psr/http-client psr/http-factoryVisão conceitual
Seção intitulada “Visão conceitual”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/jobsaceita o mesmo corpo de requisição de renderização que o endpoint síncrono/api/v1/render: umpage_size, umaorientatione um array ordenadooperations. Ele retorna201 Createdpara um job novo, ou200 OKquando umIdempotency-Keycorresponde 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çalhoRetry-After(o servidor usa um intervalo de 2 segundos) e um campopoll_url. Respeite o cabeçalho em vez de consultar em um laço muito fechado.GET /api/v1/jobs/{id}/resulttransmite o PDF finalizado comoapplication/pdf. Ele retorna409 Conflictse o job não tiver alcançadocompleted, 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.
Superfície da API
Seção intitulada “Superfície da API”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çãoIdempotency-Keyopcional. O corpo é uma requisição de renderização (o campooperationsé obrigatório e deve conter pelo menos uma operação). Respostas:201novo,200repetição idempotente,422corpo inválido,409conflito de idempotência,429limite de taxa atingido.GET /api/v1/jobs/{id}: consulta o status. Resposta200com o registro do job; cabeçalhoRetry-Afterpresente enquanto o job não for terminal;404se o job não existir ou pertencer a outro cliente.GET /api/v1/jobs/{id}/result: baixa o PDF.200application/pdfquandocompleted;409quando ainda não concluído;404se o job for desconhecido.DELETE /api/v1/jobs/{id}: cancela um jobpendingourunning, ou exclui umcompleted(204).
O registro do job sob data traz estes campos, exatamente como o servidor os serializa.
job_id: o identificador (um prefixojob_e 24 caracteres hexadecimais).status: um depending,running,completed,failed,cancelled. Os dois primeiros são não terminais; os três últimos são terminais.created_ate, uma vez definidos,started_atecompleted_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 jobfailed.result_url: presente apenas em um jobcompleted; 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}.
Exemplo de código — Início rápido
Seção intitulada “Exemplo de código — Início rápido”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.
# 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.pdfExemplo de código — Produção
Seção intitulada “Exemplo de código — Produção”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.pdfinvoice-0002 -> completed, written to /tmp/invoice-0002.pdfCasos extremos e pegadinhas
Seção intitulada “Casos extremos e pegadinhas”- 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.statusedata.progresssão os campos sobre os quais você atua;metatrazrequest_idpara correlação de suporte. progresspode estar ausente. O servidor incluiprogressapenas quando acompanha esse valor para aquele job. Trate um campo ausente como “desconhecido”, não como zero, e conduza o laço pelostatus, 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 trazerstatus: completede 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 umpendingprimeiro. - Respeite o
Retry-After. Respostas de status não terminais definemRetry-After(um intervalo de 2 segundos). Consultar mais rápido desperdiça requisições e provoca um429. Limite o valor a uma faixa sensata em vez de confiar nele sem validação. /resultantes da conclusão é um409. Chame o endpoint de resultado apenas depois que a consulta de status mostrarcompleted. Um409 Conflictsignifica 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 (
200em vez de201). 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 conflito409. - Os jobs têm escopo de proprietário. Um job enviado sob uma chave de API é invisível para outra; um
GETde outro proprietário retorna404, não403. Consulte com a mesma credencial com que você enviou. - Um job
failedtraz uma mensagemerror. Leiadata.errorquando o status terminal forfailede registre-a. Não tente novamente às cegas.
Desempenho
Seção intitulada “Desempenho”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.
Notas de segurança
Seção intitulada “Notas de segurança”- 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 ojob_ide ostatus, 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
200de/resultem busca do marcador%PDFantes 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.
maxPollsimpede 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.
Conformidade
Seção intitulada “Conformidade”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.
Veja também
Seção intitulada “Veja também”- Hello world pelo Connect: a menor renderização individual antes de fazer lotes.
- Convenções de receita do Connect: o contrato de transporte, autenticação e conformidade que toda receita do Connect compartilha.
- Tratamento de erros com reconhecimento de exceções pelo Connect: como o servidor reporta erros e como um cliente deve reagir.
- Verificação de padrões em lote pelo Connect: a superfície de conformidade Enterprise, distinta destes jobs de renderização.