Пакетная генерация 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/serverPHP-клиент в рабочем примере ниже использует 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. Сервер сохраняет её неизменной по мере перевода обработки на пул рабочих процессов с очередью, поэтому клиент с опросом корректен сегодня и останется корректным после этого изменения. Реализуйте цикл опроса. Не предполагайте, что первый ответ незавершённый, и не предполагайте, что он конечный.
Поверхность API
Заголовок раздела «Поверхность 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.200application/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.pdfinvoice-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 — это другой интерфейс, не задачи отрисовки, рассмотренные здесь.
См. также
Заголовок раздела «См. также»- Hello world через Connect: минимальная одиночная отрисовка перед пакетной обработкой.
- Соглашения по рецептам Connect: контракт транспорта, аутентификации и соответствия, общий для каждого рецепта Connect.
- Обработка ошибок с учётом исключений через Connect: как сервер сообщает об ошибках и как должен реагировать клиент.
- Пакетная проверка по стандартам через Connect: интерфейс соответствия уровня Enterprise, отличный от этих задач отрисовки.