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, ornullwhen 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:
- Client-side throttle (
autoThrottle: trueby default): requests are paced so bursts, auto-pagination and mass imports stay under the limit. Disable withnew Pennylane(autoThrottle: false)if you orchestrate limits yourself. - 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.