Skip to content

Errors, retries and rate limits

Exception hierarchy

Everything the SDK throws derives from Pennylane\Sdk\Exception\PennylaneException:

PennylaneException
├── ConfigurationException      the SDK is not usable as configured (token, baseUrl, HTTP client)
├── InvalidRequestBodyException a request body or filter value is unsafe to send (e.g. a float)
├── InvalidResponseException    a success status but the body is not usable JSON
├── ApiConnectionException      the request never got an HTTP response
│   └── ApiTimeoutException
└── ApiStatusException          the API answered 4xx/5xx
    ├── BadRequestException       400  malformed JSON, wrong types
    ├── AuthenticationException   401  missing/invalid/expired token
    ├── PermissionDeniedException 403  missing scope
    ├── NotFoundException         404
    ├── ConflictException         409  e.g. duplicate document
    ├── ValidationException       422  business validation failed
    ├── RateLimitException        429  adds ->retryAfter
    └── ServerException           5xx

Every ApiStatusException carries:

  • statusCode: the HTTP status;
  • errorCode: the machine-readable code when the API sends one;
  • getMessage(): the best human-readable message available;
  • details: structured details when present (e.g. which totals do not balance);
  • rawBody: the raw response body, truncated to 2000 characters for safety;
  • body: the decoded JSON error body as an array, or null when the body is not a JSON object.
use Pennylane\Sdk\Exception\ValidationException;

try {
    $client->ledgerEntries->create(/* ... */);
} catch (ValidationException $e) {
    echo $e->getMessage();  // "Entry lines are not balanced"
    print_r($e->details);   // ['debit_total' => '100.00', 'credit_total' => '80.00']
}

ApiStatusException deliberately does not keep a reference to the PSR-7 request or response, so it can never end up logging the Authorization header by accident. Log statusCode, errorCode and getMessage() instead.

Why every field is nullable

The API's error body format varies by endpoint (sometimes {code, message, details}, sometimes {error, status}, sometimes {message, field}). Every property on ApiStatusException is therefore optional and parsed defensively, with getMessage() falling back from the body's message to its error code to the raw response text, so you always get the most information available.

Automatic retries

The SDK retries transparently, with exponential backoff and jitter, up to maxRetries times (3 by default, starting at half a second and capped at 8 seconds between attempts). The policy is deliberately conservative because the Pennylane API has no server-side idempotency: re-sending a create request that the server may have already processed could produce a duplicate invoice or ledger entry.

Situation GET / PUT / DELETE POST
429 rate limited retried (honors retry-after) retried (the request was rejected unprocessed, safe)
500 / 502 / 503 / 504 retried not retried (duplicate risk)
Connection failed before sending retried retried
Timeout / connection lost after sending retried not retried

The "connection failed before sending" row only applies when the transport failure is provably safe (DNS resolution, connection refused, TLS handshake failure and similar); any other transport failure is treated as possibly sent and is retried only for idempotent methods.

If you need at-least-once semantics on creations, deduplicate on your side (for instance with external_reference fields) as Pennylane officially recommends.

Rate limits

API Limit
Company API v2 25 requests per 5 seconds, per token
Firm API v1 5 requests per second

Two layers keep you safe:

  1. Client-side throttle (autoThrottle: true by default): requests are paced so bursts, auto-pagination and mass imports stay under the limit. Disable with new Pennylane(autoThrottle: false) if you orchestrate limits yourself.
  2. 429 handling: if a 429 still happens (e.g. several processes sharing one token), the SDK waits for retry-after (capped at 60 seconds) and retries, for every HTTP method.

The API reports the remaining budget on every response; the SDK exposes the latest values:

$client->me->retrieve();
$info = $client->lastRateLimit();
echo $info->limit . ' ' . $info->remaining . ' ' . $info->reset;  // 25 24 1751980800

lastRateLimit() returns null until the client has made at least one request, or if the API response carried no ratelimit-* headers.

One token per integration

Limits apply per token. Give each integration its own token and they will not starve each other.