Reliability · PHP

Reliable PDF API requests: three retries without duplicate jobs

Handle PDF API timeouts and busy queues with three bounded retries, backoff, Retry-After, and a stable Idempotency-Key in PHP.

By the RenderLark team · 8 min read · Updated

A timeout is not proof that no PDF was created

A render request can outlive the HTTP connection waiting for it. If your application immediately submits a new job after a timeout, both jobs might finish. RenderLark supports an Idempotency-Key header for authenticated creation requests, including the synchronous /v1/generate endpoint.

Choose one key for one logical invoice revision, persist it in your application, and reuse it with the same API key and exactly the same request body across retries. Changing the body with that key produces HTTP 409. A changed invoice is a new operation and needs a new key. Deduplication is temporary, not a permanent invoice archive: after the server's idempotency record expires, the request can create a new job.

Retry transient failures, not every error

The example below makes one initial attempt plus at most three retries: four requests total. It retries selected transport failures and HTTP 408, 429, 500, 502, 503, and 504. It does not retry invalid input, authentication failures, insufficient credits, conflicting idempotency keys, or a terminal render failure.

Use exponential backoff with a little random jitter, and respect Retry-After when the server provides it. If Retry-After is too long for this worker's budget, stop and reschedule the job instead of retrying sooner than requested. Each attempt has its own timeout, so run this routine in a background worker rather than relying on a short browser or PHP gateway timeout.

PHP · bounded synchronous retry helper

<?php
declare(strict_types=1);

function renderPdf(string $source, string $apiKey, string $operationKey): string
{
    if ($apiKey === '' || !preg_match('/^[A-Za-z0-9._:-]{1,160}$/', $operationKey)) {
        throw new InvalidArgumentException('Missing key or invalid operation ID');
    }
    $body = json_encode(['html' => $source, 'apiKey' => $apiKey], JSON_THROW_ON_ERROR);
    for ($attempt = 0; $attempt <= 3; $attempt++) {
        $retryAfter = null;
        $ch = curl_init('https://renderlark.com/v1/generate');
        curl_setopt_array($ch, [
            CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 90,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Idempotency-Key: ' . $operationKey,
            ],
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$retryAfter): int {
                if (stripos($line, 'Retry-After:') === 0) {
                    $value = trim(substr($line, 12));
                    $date = strtotime($value);
                    $retryAfter = ctype_digit($value) ? (int) $value
                        : ($date === false ? null : max(0, $date - time()));
                }
                return strlen($line);
            },
        ]);
        $pdf = curl_exec($ch);
        $errno = curl_errno($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
        curl_close($ch);

        if ($errno === 0 && $status === 200 &&
            stripos($type, 'application/pdf') === 0 &&
            is_string($pdf) && str_starts_with($pdf, '%PDF-')) return $pdf;

        $transportRetry = in_array($errno, [
            CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT,
            CURLE_OPERATION_TIMEDOUT, CURLE_PARTIAL_FILE,
            CURLE_RECV_ERROR, CURLE_SEND_ERROR,
        ], true);
        $httpRetry = $errno === 0 && in_array($status, [408, 429, 500, 502, 503, 504], true);
        if ($attempt === 3 || (!$transportRetry && !$httpRetry)) {
            throw new RuntimeException('PDF failed; HTTP ' . $status . ', cURL ' . $errno);
        }
        if ($retryAfter !== null && $retryAfter > 60) {
            throw new RuntimeException('Reschedule after ' . $retryAfter . ' seconds');
        }
        $seconds = max(2 ** $attempt, $retryAfter ?? 0);
        usleep(($seconds * 1000000) + random_int(0, 500000));
    }
    throw new RuntimeException('Retry budget exhausted');
}

// Persist this operation ID in your job record; do not randomize per attempt.
$key = getenv('RENDERLARK_API_KEY');
if (!$key) throw new RuntimeException('Missing RENDERLARK_API_KEY');
$pdf = renderPdf('https://renderlark.com/samples/invoice.html', $key, 'invoice-001-revision-1');
// Validate/save atomically as in the PHP invoice tutorial, then mark success.

Plan for a queue instead of unlimited simultaneous requests

RenderLark has account concurrency limits and shared worker capacity. Too many simultaneous requests can receive a busy or rate-limit response. Use a queue in your application, limit parallel jobs, and let retries rejoin that queue. Opening hundreds of waiting HTTP connections does not create more rendering capacity.

After your local retry budget is exhausted, preserve the operation key, source revision, and sanitized error details for an operator or a later worker run. Do not mark the invoice as generated or email an error response as an attachment. Never log an API key, a raw sensitive HTML body, or a signed private document link.

For larger batches, the asynchronous API lets you retain job IDs and poll existing work. A failed job replay remains failed while its idempotency record exists; correct the underlying source problem before deliberately creating a new operation. Test retries with a safe fixture before enabling automated customer emails.

Further reading

Keep building