تخطَّ إلى المحتوى

توليد ملفات 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⁩ القياسي:

Terminal window
composer require nextpdf/server

يستخدم عميل ⁨PHP⁩ في المثال الإنتاجي أدناه عميل ⁨HTTP⁩ ومصانع رسائل متوافقة مع ⁨PSR-18⁩ و⁨PSR-17.⁩ ثبِّت الحزم التي يعتمدها مشروعك بالفعل، على سبيل المثال:

Terminal window
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 (يستخدم الخادم فاصلًا زمنيًا قدره ثانيتان) وحقل 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: موجود فقط ما دامت المهمة غير نهائية.

تتم المصادقة برمز حامل في ترويسة Authorization: Authorization: Bearer npk_live_{kid}_{secret}.

يشغِّل هذا المثال مهمة واحدة من البداية إلى النهاية على مستوى السلك حتى تتمكن من رؤية الاستدعاءات الثلاثة والحقول التي تُعيدها. يُرسِل المثال، ويستطلِع مرة واحدة، ويُنزِّل. يضيف المثال الإنتاجي أدناه حلقة المعالجة المُجمَّعة، وانتظار Retry-After، ومعالجة الأخطاء الكاملة.

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

يُرسِل هذا العميل المستقل بذاته دفعة من طلبات العرض، ويحدّ من عدد المهام قيد التنفيذ في الوقت نفسه، ويستطلِع كل مهمة وفق الوتيرة التي يضبطها الخادم عبر 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 (فاصل زمني قدره ثانيتان). الاستطلاع بوتيرة أسرع يُهدِر الطلبات ويؤدي إلى 429. قيِّد القيمة ضمن نطاق معقول بدلًا من الوثوق بها بشكل أعمى.
  • /result قبل الاكتمال هو 409. استدعِ نقطة نهاية النتيجة فقط بعد أن يُظهِر استطلاع الحالة completed. 409 Conflict يعني أن المهمة لم تنتهِ؛ وهو ليس خطأ نقل.
  • ⁨Idempotency-Key⁩ يمنع العمل المكرَّر. إرسال مكرَّر بالمفتاح نفسه يُعيد المهمة الأصلية (200 بدلًا من 201). استخدِم مفتاحًا ثابتًا لكل مستند حتى لا تبدأ إعادة المحاولة على الشبكة عملية عرض ثانية أبدًا. مفتاح مُعاد استخدامه مع جسم مختلف هو تعارض 409.
  • المهام محصورة بالمالك. مهمة مُرسَلة بمفتاح واجهة واحد تكون غير مرئية لمفتاح آخر؛ GET عبر مالك مختلف يُعيد 404، وليس 403. استطلِع ببيانات الاعتماد نفسها التي أرسلت بها.
  • مهمة failed تحمل رسالة error. اقرأ data.error على حالة failed نهائية وسجِّلها. لا تُعِد المحاولة بشكل أعمى.

تكلفة الدفعة هي مجموع عمليات العرض زائد عبء الاستطلاع. يتحكم عاملان في جانب العميل. أولًا، ضبط التزامن: يُثبِّت حدّ maxInFlight عددَ المهام المُتتبَّعة دفعةً واحدة، مما يُبقي عدد الطلبات المفتوحة لدى العميل والذاكرة ثابتين بصرف النظر عن حجم الدفعة. اضبطه ليطابق عدد العاملين لدى الخادم، لا أعلى منه؛ فالزيادة على عدد العاملين لا تفعل إلا إطالة انتظار طابور كل مهمة. ثانيًا، احترِم فاصل الاستطلاع: كل استطلاع هو قراءة حالة زهيدة التكلفة، لكن الحلقة المحكمة تزيد حجم الطلبات وتُطلِق مقيِّد المعدل. Retry-After ذو الثانيتين لدى الخادم هو الافتراضي الصحيح، ويُقيِّد المُنفِّذ القيمة ضمن نطاق من 1 إلى 30 ثانية حتى لا تتمكن مهمة بطيئة واحدة من إغراق الحلقة بالعمل أو إيقاف النافذة.

بالنسبة إلى الدفعات الكبيرة جدًا، عالِجها في نوافذ (يستخدم المُنفِّذ array_chunk) بدلًا من إرسال كل شيء مُسبقًا. هذا يحدّ من حالة التتبُّع لدى العميل وعمق طابور الخادم معًا، لذا فإن دفعة سيئة التكوين أو مُفرطة الحجم تفشل داخل نافذة واحدة بدلًا من بعد آلاف الإرسالات.

  • أبقِ الرمز الحامل بعيدًا عن السجلات وعناوين ⁨URL.⁩ ينتقل مفتاح الواجهة في ترويسة Authorization فقط. لا تضعه أبدًا في سلسلة استعلام، أو سطر سجل، أو أثر مكتوب. يُسجِّل المُنفِّذ job_id وstatus، وليس الاعتماد أبدًا.
  • اشتقّ مسارات المخرجات من مفاتيح يتحكم بها الخادم. يبني المُنفِّذ كل مسار مخرجات من مفتاح المستند الذي اختاره برنامجك، مضمومًا إلى دليل مخرجات ثابت، وليس أبدًا من قيمة في استجابة الخادم. لا تُدرِج حقل مهمة في مسار نظام ملفات، فذلك يفتح باب اجتياز المسارات.
  • تحقَّق من صحة البايتات المُنزَّلة. يتحقق المُنفِّذ من 200 من /result بحثًا عن ترويسة %PDF قبل أن يكتب الملف. لا تكفي حالة التنزيل الناجحة وحدها لإثبات أن الجسم هو ملف ⁨PDF.⁩
  • عامِل النتيجة على أنها غير موثوقة حتى تُفحص. المهمة المكتملة تعني أن الخادم عرض بايتات، لا أن تلك البايتات آمنة لإعادة توجيهها. مرِّر النتائج عبر خطوة فحص بنيوي قبل أن تُسلِّمها إلى عميل أو نظام لاحق.
  • استخدِم مفتاحًا بأقل امتياز. سطح المهام غير المتزامنة هو عرض من المستوى الأساسي. أصدِر للدفعة مفتاحًا محصورًا بالعمليات التي تحتاجها بالضبط، وأدِرْه دوريًا وفق الجدول الذي تضبطه سياسة إدارة الأسرار لديك.
  • حدِّد ميزانية الاستطلاع. 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⁩، فهو سطح مختلف عن مهام العرض المُغطّاة هنا.