Getting started

Errors

Every failure is a typed exception carrying the stable machine-readable code from the API. Branch on the code, never on the message.

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.

ExceptionRaised when
EnvlessApiErrorThe 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.
EnvlessNetworkErrorNothing came back to classify: connection refused, DNS failure, a TLS error, or the timeout passing.
DecryptionErrorA 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.
WebhookVerificationErrorA webhook delivery fails signature, timestamp or payload checks in verify_webhook_signature.
python
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

AttributeTypeNotes
statusint | NoneThe HTTP status. Typed as optional but always set: a variable name refused before sending carries 400, the status the API would answer with.
codestrThe stable code, such as variable.ciphertext_invalid. Always present, falling back to server.error.
messagestrThe API's human readable message.
resourcestr | NoneWhat the failure relates to. On a scope failure this is the exact scope needed, such as variables:write.
fieldstr | NoneThe request field that caused it.
request_idstr | NoneRead 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_secondsfloat | NoneFrom 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

PropertyTrue when
is_authcode starts with auth.
is_scope_missingcode is exactly auth.scope_missing
is_validationstatus is 400
needs_upgradestatus is 402
is_not_foundstatus is 404
is_conflictstatus is 409
is_rate_limitedstatus 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

AttributeTypeNotes
messagestrWhat went wrong, without the token or the request body.
is_timeoutboolTrue when the client's timeout passed before the response finished.
causeBaseException | NoneThe 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.

CodeStatusWhat to do
auth.api_key_not_found401The key is invalid, inactive or revoked. Stop, do not retry.
auth.scope_missing403Mint a key with error.resource.
auth.scope_ceiling_exceeded403You asked for scopes the minting key does not hold.
auth.key_project_restricted403The key is fenced to other projects.
auth.product_restricted403The key owner has no grant on a private product.
request.body_invalid400Fix the payload. error.field names the offender.
request.invalid_pagination400limit outside 1 to 100, or a non-integer.
variable.ciphertext_invalid400You sent plaintext. See Encryption.
variable.baseline_drifted409Somebody edited since you read the baseline. Re-read and retry.
project.ambiguous_product409Pass product=.
plan.upgrade_required402A tier limit or a paid feature, such as an older version or a rollback on Free. An upgrade fixes it. Not retryable.
plan.limit_reached409The 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.

python
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 += 1

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.

python
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:
                raise

Logging

Log the code and the request id, never the payload. Variable values are ciphertext, but names, member emails and workspace slugs are not.

python
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,
        },
    )

    raise

Refused 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:

MistakeRaises
No token at all, or one with a space, a line break or a curly quote in itValueError, without echoing the token
A base_url that is not an http or https originValueError
A user_agent that is not printable ASCII on one lineValueError
A timeout below zero or not a number, an http_client of the wrong kindValueError, TypeError
An empty slug, name or id, or one made only of dotsValueError
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.