Retry and Idempotency with the SDK

The SDK handles both concerns automatically. You get a sane default: transient failures are retried with exponential backoff, and every mutating call gets a UUID Idempotency-Key so an internal retry never produces two side effects. Both behaviors are configurable when the default is not exactly what you need.

Default retry policy

The SDK retries three kinds of transient failure:

  • 5xx responses — the panel encountered an internal error.
  • 429 responses — the panel rate-limited you.
  • Network errors — connect timeout, DNS failure, dropped connection mid-request, TLS handshake failure.

For 5xx and network errors, the SDK sleeps with exponential backoff (starting around 400 ms, roughly doubling each attempt, capped at 10 s). Default is up to 3 additional attempts after the original request. If none succeed, the underlying exception surfaces to your code — ServerException for 5xx, NetworkException for connection-level failures.

For 429, the SDK reads the Retry-After header the panel sent and sleeps for exactly that many seconds before the next attempt. The header is capped at 60 seconds — a hostile or misconfigured value cannot stall your process for hours.

Any 4xx other than 429 is never retried. 400, 401, 403, 404, 409, 422 all mean the request itself is wrong (bad body, missing scope, unknown line ID, conflicting idempotency key, invalid field). Retrying without changing the request would produce the same failure. That is your bug to fix, not a transient condition to wait out.

Configuring retries

Both the per-request timeout and the retry count are set once at construction. Defaults: timeout=30.0 seconds, maxRetries=3.

<?php
$client = new PanelApiClient(
    baseUrl:    'https://<your-panel-domain>',
    token:      '<your-api-key>',
    timeout:    10.0,   // fail fast if the panel is slow
    maxRetries: 5,      // more attempts on flaky networks
);
client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
    timeout=10.0,
    max_retries=5,
)

Raise maxRetries for jobs running from a warehouse or a cell network where transient blips are frequent. Lower it (to 0 or 1) for interactive endpoints where the user is waiting on the response and a stalled 30-second retry loop is worse than a fast failure.

To DISABLE retries entirely, pass maxRetries: 0. Every failure will surface immediately on the first response.

Automatic idempotency

Every mutating call (create, update, enable, disable, renew, reset, delete, adjust billing) generates a random UUID v4 and sends it as the Idempotency-Key header. If the SDK has to retry that call internally (because of a 5xx, a 429, or a network blip), it reuses the same key. The panel remembers the result of the first successful attempt and replays it verbatim on the retry. You never create two lines from one call, even when the underlying transport fails and recovers.

This is the default for every write. No configuration needed. It is the behavior you want when the call is triggered synchronously by a human clicking a button.

Overriding with your own key

The auto-generated UUID protects against SDK-internal retries. It does NOT protect against retries that come from OUTSIDE the SDK — a payment webhook that fires twice, a job queue with at-least-once delivery, a WHMCS invoice hook that runs on every cron tick until it succeeds. To make those safe too, override the key with a value derived from a stable business identifier.

<?php
require __DIR__ . '/api-panel-php-sdk-1.0.0/autoload.php';

use XtreamAI\PanelApi\PanelApiClient;

$client = new PanelApiClient(
    baseUrl: 'https://<your-panel-domain>',
    token:   '<your-api-key>',
);

$first  = $client->lines->create(
    packageId: 76, memberId: 260595, username: 'newcustomer',
    idempotencyKey: 'invoice-INV-2026-00814',
);
$second = $client->lines->create(
    packageId: 76, memberId: 260595, username: 'newcustomer',
    idempotencyKey: 'invoice-INV-2026-00814',
);

echo "first  id=", $first->id,  PHP_EOL;
echo "second id=", $second->id, PHP_EOL;
echo "same result? ", ($first->id === $second->id ? "YES" : "NO"), PHP_EOL;
from xtream_ai_panel_api import PanelApiClient

client = PanelApiClient(
    base_url="https://<your-panel-domain>",
    token="<your-api-key>",
)

first = client.lines.create(
    package_id=76, member_id=260595, username="newcustomer",
    idempotency_key="invoice-INV-2026-00814",
)
second = client.lines.create(
    package_id=76, member_id=260595, username="newcustomer",
    idempotency_key="invoice-INV-2026-00814",
)

print(f"first  id={first.id}")
print(f"second id={second.id}")
print("same result?", "YES" if first.id == second.id else "NO")

Running the PHP version against a test panel produced:

first  id=172511984 username=docsdemo_706819
second id=172511984 username=docsdemo_706819
same result? YES

The second call did not create a new line. The panel recognised the key from the first call and replayed the exact same response, including the same numeric id. If you had used a fresh random UUID for the second call (or let the SDK generate one), you would have gotten a second line and a second credit deduction.

What makes a good business key

The best keys are ones your system already computes for other reasons, and that are stable across retries for the same logical event:

Trigger Good key
Payment webhook payment-<gateway>-<transaction_id>
Invoice fulfillment invoice-<invoice_number>
Job queue message queue-<queue_name>-<message_id>
Manual admin action random UUID — safe to let the SDK default handle it
Cron reconciliation recon-<cron_name>-<yyyymmdd>

Bad keys are ones that vary between retries of the same event — timestamps at millisecond resolution, microtime(), uuid4() regenerated on every attempt. If the key changes, the panel treats the retry as a new request and produces a second side effect.

Handling conflicts

If you retry the same key with a different body, the panel raises 409 idempotency_conflict and the SDK maps that to ConflictException. The .existing (Python) / ->existing() (PHP) property holds the payload the panel remembers from the first request — inspect it to see whether the first attempt actually succeeded (in which case your local state was wrong) or whether the two requests genuinely disagreed (in which case you have a bug).

<?php
use XtreamAI\PanelApi\Exceptions\ConflictException;

try {
    $line = $client->lines->create(
        packageId: 42, memberId: 260595, username: 'johndoe',
        idempotencyKey: 'invoice-INV-42',
    );
} catch (ConflictException $e) {
    $prev = $e->existing();
    error_log("idempotency conflict: previous body was " . json_encode($prev));
    // Reconcile — usually means: trust the panel's existing state, update yours.
}
from xtream_ai_panel_api.exceptions import ConflictException

try:
    line = client.lines.create(
        package_id=42, member_id=260595, username="johndoe",
        idempotency_key="invoice-INV-42",
    )
except ConflictException as e:
    print("idempotency conflict: previous body was", e.existing)
    # Reconcile — usually means: trust the panel's existing state, update yours.

If you retry the same key while the first request is still in flight, the panel returns 409 idempotency_in_flight. Same exception class, .slug == "idempotency_in_flight". Wait a short moment and try again — the first request will complete and you can either observe its result via a follow-up read, or reissue the write once it has cleared.

For the full server-side contract, see Rate limits and Idempotency.

When NOT to retry

The SDK handles retries for the transient case. Do not add another layer of retry on top of that unless you fully understand what you are doing.

  • Do NOT wrap SDK calls in your own retry loop for 5xx. The SDK already does this. A second layer doubles the effective back-off exponent and makes user-facing latency explode.
  • Do NOT retry AuthenticationException. A wrong token is not going to become a right one in the next 5 seconds.
  • Do NOT retry AuthorizationException. Missing scope is a config problem — going back to it does not add the scope.
  • Do NOT retry ValidationException. Bad payload stays bad. Fix the input.
  • DO retry RateLimitException if for some reason maxRetries=0 and you got the exception directly. Read .retry_after and sleep before the next attempt.
  • DO implement your own OUTER retry for at-least-once event sources — but combine it with a stable idempotency key so the outer retry is safe.

Retry budget under async workloads

If you dispatch many parallel writes from a job queue, the retry logic compounds. 100 concurrent writes each retrying 3 times against a briefly-500-ing panel means 300 attempts in a window that was already unhealthy. Consider:

  • Set maxRetries=1 in workers that already have external retry (the queue will re-deliver anyway).
  • Add a circuit breaker in front of the SDK client that opens on repeated 5xx bursts.
  • Space out writes with jitter — the SDK does not add jitter to its back-off by default.

See also

  • Errors with the SDK. The exception hierarchy, including RateLimitException, ConflictException, ServerException, NetworkException.
  • Rate limits and Idempotency. Server-side contract for 429, 409, and the Retry-After header.
  • Common tasks. Worked recipes for at-least-once webhook fulfillment.