Every failed call, decryption or webhook check raises a subclass of EnvlessError, so one except EnvlessError catches all of them, and each one pickles, so it crosses a multiprocessing or Celery boundary intact. A bad argument is different: it raises ValueError or TypeError before anything is sent, as the last section of this page lists.
| Exception | Raised when |
|---|---|
EnvlessApiError | The API answered and the answer was a failure. Carries status, code and the request id. Also raised before sending when a variable name is unusable. |
EnvlessNetworkError | Nothing came back to classify: connection refused, DNS failure, a TLS error, or the timeout passing. |
DecryptionError | A value cannot be decrypted with the passphrase or key you passed, is not an Envless envelope at all, or is a v2: value pinned to a cost other than 200,000 handed to decrypt_with_key. |
WebhookVerificationError | A webhook delivery fails signature, timestamp or payload checks in verify_webhook_signature. |
from envless import EnvlessApiError, EnvlessNetworkError, envless
try:
envless.projects.create({'name': 'Billing', 'slug': 'billing'})
except EnvlessApiError as error:
if error.needs_upgrade:
print('Upgrade needed:', error.message)
elif error.is_scope_missing:
print('Key needs:', error.resource)
elif error.is_conflict:
print('Slug already taken')
else:
print(error.code, error.request_id)
except EnvlessNetworkError as error:
print('Could not reach the API:', error.message, error.is_timeout)Never branch on error.message or on str(error). They are written for humans and can be reworded at any time. error.code is a stable contract.
EnvlessApiError
| Attribute | Type | Notes |
|---|---|---|
status | int | None | The HTTP status. Typed as optional but always set: a variable name refused before sending carries 400, the status the API would answer with. |
code | str | The stable code, such as variable.ciphertext_invalid. Always present, falling back to server.error. |
message | str | The API's human readable message. |
resource | str | None | What the failure relates to. On a scope failure this is the exact scope needed, such as variables:write. |
field | str | None | The request field that caused it. |
request_id | str | None | Read from the response's _req.reqId, or its Request-Id header. None on a refusal made before anything was sent, and on a response that carries neither, such as an error page from a proxy. This is what support needs. Log it. |
retry_after_seconds | float | None | From a numeric Retry-After header on the failing response, usually a 429 or a 503. None when the header is absent or is a date, and 0.0 when it is empty. |
Convenience properties
| Property | True when |
|---|---|
is_auth | code starts with auth. |
is_scope_missing | code is exactly auth.scope_missing |
is_validation | status is 400 |
needs_upgrade | status is 402 |
is_not_found | status is 404 |
is_conflict | status is 409 |
is_rate_limited | status is 429 |
They are properties, not methods, so write error.is_not_found without parentheses. They cover the branches most callers need. For anything finer, match on code.
EnvlessNetworkError
| Attribute | Type | Notes |
|---|---|---|
message | str | What went wrong, without the token or the request body. |
is_timeout | bool | True when the client's timeout passed before the response finished. |
cause | BaseException | None | The underlying exception, usually from httpx, also chained as __cause__. On a deadline the client enforces itself it is the timeout the client raised. It is not pickled, so it is None on the far side of a process boundary. |
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 error.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. error.field names the offender. |
request.invalid_pagination | 400 | limit outside 1 to 100, or a non-integer. |
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. |
A failure with nothing more specific to say carries the generic code for its status: request.invalid for a 400, request.not_found for a 404, request.conflict for a 409 and server.error for a 500. Each method page names the specific codes it can answer with.
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.
import time
from collections.abc import Callable
from typing import TypeVar
from envless import EnvlessApiError, EnvlessNetworkError
T = TypeVar('T')
def retried(run: Callable[[], T], attempts: int = 3) -> T:
attempt = 1
while True:
try:
return run()
except (EnvlessApiError, EnvlessNetworkError) as error:
transient = isinstance(error, EnvlessNetworkError) or (
error.status is not None and error.status >= 500
)
if not transient or attempt >= attempts:
raise
time.sleep(2**attempt * 0.25)
attempt += 1For 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.
from envless import EnvlessApiError, envless
from envless.types import ApplyBaselineEntry
def drop_legacy(attempts: int = 3) -> None:
for attempt in range(1, attempts + 1):
current = envless.variables.list_all('api', 'production')
baseline: list[ApplyBaselineEntry] = [
{
'uniqueId': variable['id'],
'name': variable['name'],
'updatedAt': variable['updatedAt'] or variable['createdAt'],
}
for variable in current
]
legacy = [variable['id'] for variable in current if variable['name'] == 'LEGACY_API_URL']
try:
envless.variables.apply(
'api',
'production',
{'baseline': baseline, 'deletes': [{'uniqueId': unique_id} for unique_id in legacy]},
)
return
except EnvlessApiError as error:
if error.code != 'variable.baseline_drifted' or attempt == attempts:
raiseLogging
Log the code and the request id, never the payload. Variable values are ciphertext, but names, member emails and workspace slugs are not.
import logging
from envless import EnvlessApiError, envless
logger = logging.getLogger(__name__)
try:
envless.variables.delete('api', 'production', 'LEGACY_API_URL')
except EnvlessApiError as error:
logger.error(
'envless request failed',
extra={
'code': error.code,
'status': error.status,
'resource': error.resource,
'field': error.field,
'request_id': error.request_id,
},
)
raiseRefused before sending
Some mistakes never reach the network. They raise ValueError or TypeError from the call itself rather than an EnvlessError, because there is no response to describe:
| Mistake | Raises |
|---|---|
| No token at all, or one with a space, a line break or a curly quote in it | ValueError, without echoing the token |
A base_url that is not an http or https origin | ValueError |
A user_agent that is not printable ASCII on one line | ValueError |
A timeout below zero or not a number, an http_client of the wrong kind | ValueError, TypeError |
| An empty slug, name or id, or one made only of dots | ValueError |
A single string where a list belongs, as in delete_many('api', 'production', 'OLD_TOKEN') | TypeError |
A raw.request path that does not begin with a single / | ValueError |
A body value JSON cannot carry, such as bytes, a datetime or float('nan') | TypeError, ValueError |
A variable name that breaks the naming rule is the one local refusal that looks like the API's: it raises EnvlessApiError with code set to request.body_invalid, field set to 'name' and request_id set to None.