Every exception the SDK throws implements Envless\Exception\EnvlessException, so one catch (EnvlessException $error) catches all of them. A mistake in the call itself, such as an empty slug, a negative timeout: or binary data passed as plaintext, throws Envless\Exception\InvalidArgumentException before anything is sent, and since that extends PHP's own InvalidArgumentException, code that already catches the built-in one keeps working.
| Exception | Thrown when |
|---|---|
Envless\Exception\ApiException | The API answered and the answer was a failure. Carries status, errorCode and the request id. Also thrown before sending when a variable name is unusable. |
Envless\Exception\NetworkException | Nothing came back to classify: connection refused, DNS failure, a TLS error, or the timeout passing. |
Envless\Exception\DecryptionException | A value cannot be decrypted with the passphrase or key you passed, or is not an Envless envelope at all. |
Envless\Exception\WebhookVerificationException | A webhook delivery fails signature, timestamp or payload checks in Envless::verifyWebhookSignature(). |
Envless\Exception\InvalidArgumentException | The call itself is wrong, and nothing was sent. |
use Envless\Envless;
use Envless\Exception\ApiException;
use Envless\Exception\NetworkException;
$envless = new Envless();
try {
$envless->projects->create(['name' => 'Billing', 'slug' => 'billing']);
} catch (ApiException $error) {
if ($error->needsUpgrade()) {
echo 'Upgrade needed: ', $error->getMessage(), PHP_EOL;
} elseif ($error->isScopeMissing()) {
echo 'Key needs: ', $error->resource, PHP_EOL;
} elseif ($error->isConflict()) {
echo 'Slug already taken', PHP_EOL;
} else {
echo $error->errorCode, ' ', $error->requestId, PHP_EOL;
}
} catch (NetworkException $error) {
echo 'Could not reach the API: ', $error->getMessage(), PHP_EOL;
echo $error->isTimeout() ? 'Timed out' : 'No answer', PHP_EOL;
}Never branch on getMessage(). It is written for humans and can be reworded at any time. errorCode is a stable contract.
ApiException
| Property | Type | Notes |
|---|---|---|
status | int or null | The HTTP status. A variable name refused before sending carries 400, the status the API would answer with. getCode() returns the same number, or 0. |
errorCode | string | The stable code, such as variable.ciphertext_invalid. Always present, falling back to server.error. It is errorCode rather than code because PHP's Exception already owns code. |
getMessage() | string | The API's human readable message. |
resource | string or null | What the failure relates to. On a scope failure this is the exact scope needed, such as variables:write. |
field | string or null | The request field that caused it. |
requestId | string or null | Read from the response's _req.reqId, or its Request-Id header. null on a refusal made before anything was sent. This is what support needs. Log it. |
retryAfterSeconds | int, float or null | From the Retry-After header, on a 429 or a transient 5xx. |
Every property is readonly.
Predicates
| Method | True when |
|---|---|
isAuth() | errorCode starts with auth. |
isScopeMissing() | errorCode is exactly auth.scope_missing |
isValidation() | status is 400 |
needsUpgrade() | status is 402 |
isNotFound() | status is 404 |
isConflict() | status is 409 |
isRateLimited() | status is 429 |
They cover the branches most callers need. For anything finer, match on errorCode, which reads well in a match:
use Envless\Envless;
use Envless\Exception\ApiException;
$envless = new Envless();
try {
$envless->variables->get('api', 'production', 'STRIPE_SECRET_KEY');
} catch (ApiException $error) {
$advice = match ($error->errorCode) {
'auth.scope_missing' => 'Mint a key with ' . $error->resource,
'project.ambiguous_product' => 'Pass product: to pick the project',
default => throw $error,
};
fwrite(STDERR, $advice . PHP_EOL);
}NetworkException
| Member | Type | Notes |
|---|---|---|
getMessage() | string | What went wrong, without the token or the request body. |
isTimeout() | bool | true when the timeout passed before the response finished. |
getPrevious() | Throwable or null | The underlying failure, when the HTTP client reported one. |
The codes worth handling
Full tables live on each endpoint page. These are the ones that come up when you drive the API from a script.
| Code | Status | What to do |
|---|---|---|
auth.api_key_not_found | 401 | The key is invalid, inactive or revoked. Stop, do not retry. |
auth.scope_missing | 403 | Mint a key with the scope in resource. |
auth.scope_ceiling_exceeded | 403 | You asked for scopes the minting key does not hold. |
auth.key_project_restricted | 403 | The key is fenced to other projects. |
auth.product_restricted | 403 | The key owner has no grant on a private product. |
request.body_invalid | 400 | Fix the payload. field names the offender. |
request.invalid_pagination | 400 | limit outside 1 to 100. |
variable.ciphertext_invalid | 400 | You sent plaintext. See Encryption. |
variable.baseline_drifted | 409 | Somebody edited since you read the baseline. Re-read and retry. |
project.ambiguous_product | 409 | Pass product:. |
plan.upgrade_required | 402 | A tier limit or a paid feature, such as an older version or a rollback on Free. An upgrade fixes it. Not retryable. |
plan.limit_reached | 409 | The 1,000-variables-per-environment fair-use cap. No plan raises it; contact support. Not retryable. |
Retrying safely
The client already retries transient failures on idempotent methods. What it deliberately does not do is retry a POST or a PATCH, because a create that timed out may have landed. If you want to retry a write, make it safe first.
For variables->apply() the baseline makes this genuinely safe: a retry either applies cleanly or fails with variable.baseline_drifted and writes nothing, so the loop below re-reads the environment and tries again.
use Envless\Envless;
use Envless\Exception\ApiException;
$envless = new Envless();
for ($attempt = 1; ; $attempt++) {
$baseline = [];
$deletes = [];
foreach ($envless->variables->listAll('api', 'production') as $variable) {
$baseline[] = [
'uniqueId' => $variable['id'],
'name' => $variable['name'],
'updatedAt' => $variable['updatedAt'] ?? $variable['createdAt'],
];
if ($variable['name'] === 'LEGACY_API_URL') {
$deletes[] = ['uniqueId' => $variable['id']];
}
}
try {
$envless->variables->apply('api', 'production', ['baseline' => $baseline, 'deletes' => $deletes]);
break;
} catch (ApiException $error) {
if ($error->errorCode !== 'variable.baseline_drifted' || $attempt === 3) {
throw $error;
}
}
}Logging
Log the code and the request id, never the payload. Variable values are ciphertext, but names, member emails and workspace slugs are not.
use Envless\Envless;
use Envless\Exception\ApiException;
$envless = new Envless();
try {
$envless->variables->delete('api', 'production', 'LEGACY_API_URL');
} catch (ApiException $error) {
error_log(sprintf(
'envless request failed code=%s status=%s resource=%s field=%s request_id=%s',
$error->errorCode,
$error->status ?? 'none',
$error->resource ?? 'none',
$error->field ?? 'none',
$error->requestId ?? 'none',
));
throw $error;
}