Set ENVLESS_TOKEN and the ready made client works anywhere, with no setup code:
from envless import envless
workspace = envless.workspace.get()envless is a live handle on the default client. Importing it reads nothing, and the client is built from ENVLESS_TOKEN and ENVLESS_API_ENDPOINT the first time you touch a namespace on it, so import order between your entrypoint and your modules does not matter.
It is a proxy rather than an Envless, so isinstance(envless, Envless) is False and with envless: is not available. When you need the object itself, to type-check against it, close it or hand it to rotate_workspace_passphrase, call get_client().
To configure it in code instead, call init once at startup. Every module that imports envless then uses that client.
import os
from envless import init
init(os.environ['ENVLESS_TOKEN'], timeout=10, max_retries=5)Three ways to build one
They differ only in who holds the instance and whether it is synchronous. Mix them freely; they do not interfere.
| Use when | |
|---|---|
init(...) | One workspace per process. The usual case. Installs the client envless resolves to, and returns it. |
Envless(...) | You want an explicit instance to pass around, or several clients at once. create_client is the same class under the TypeScript name. |
AsyncEnvless(...) | Your code runs on asyncio or trio. Same methods, awaited. See Async. |
from envless import envless, init
init()
envless.products.list()from envless import Envless
with Envless() as envless:
envless.products.list()import asyncio
from envless import AsyncEnvless
async def main() -> None:
async with AsyncEnvless() as envless:
await envless.products.list()
asyncio.run(main())All three fall back to ENVLESS_TOKEN and ENVLESS_API_ENDPOINT whenever you leave token or base_url out, and raise ValueError with An Envless API key is required. Pass token= or set ENVLESS_TOKEN. Create one in the dashboard under Keys. when no token can be found.
Working with more than one workspace
A key is bound to one workspace, so reaching two means two clients. init holds exactly one, so build the others with Envless.
import os
from envless import Envless, envless
staging = Envless(os.environ['ENVLESS_STAGING_TOKEN'])
live = envless.variables.list_all('api', 'production')
preview = staging.variables.list_all('api', 'production')The default client is shared by everything in the process. In a multi-tenant server that handles requests for different workspaces, do not init per request: build one Envless per tenant and pass it down.
The default client
| Export | Signature | Purpose |
|---|---|---|
envless | a live handle | Resolves to the installed client, building one from the environment on first use. |
init | (token=None, *, base_url=None, ...) -> Envless | Builds a client from the same options as Envless, installs it as the default, and returns it. |
get_client | () -> Envless | Returns the real default instance, building it from the environment if init was never called. |
reset_client | () -> None | Drops the installed client. The next access resolves afresh. Intended for tests. |
Replacing the default with a second init does not close the first client, the same as in TypeScript. Close it yourself if you hold a reference to it.
Options
Envless, AsyncEnvless and init take the same arguments. The token is positional, the rest are keyword only.
| Option | Default | Purpose |
|---|---|---|
token | ENVLESS_TOKEN | Your API key. Required. |
base_url | ENVLESS_API_ENDPOINT, then https://api.envless.cloud | Point at a self-hosted or local instance. Trailing slashes are trimmed. A bare host gets https://, except localhost, 127.0.0.1 and 0.0.0.0, which get http://, so localhost:2222 works as written. Write the scheme out for an IPv6 loopback, http://[::1]:2222. |
max_retries | 3 | Retries idempotent requests on a transient status. It counts retries and not attempts, so the default is four requests at most. |
timeout | 30.0 | Seconds each attempt may take, the response body included. 0 turns the limit off. Every method also takes timeout= for one call. |
http_client | a new httpx.Client | Bring your own httpx.Client, or httpx.AsyncClient for AsyncEnvless, for a proxy, custom TLS, instrumentation or tests. |
user_agent | envless-sdk-python/<version> | Sent as User-Agent. Set something identifiable for your own tooling. |
disable_update_notice | False | Silences the one-time check for a newer version on PyPI. ENVLESS_DISABLE_UPDATE_NOTICE does the same. |
import os
from envless import Envless
envless = Envless(
os.environ['ENVLESS_TOKEN'],
base_url='http://localhost:2222',
max_retries=5,
timeout=60,
user_agent='acme-provisioner/2.1',
)Retries
The client retries only when retrying is safe. Two conditions must both hold:
The method is idempotent
GET, HEAD, PUT and DELETE are retried. POST and PATCH never are, because a create or an update that timed out may well have landed.
The status is transient
408, 429, 500, 502, 503 and 504. Everything else, including every 4xx you can fix, fails on the first attempt.
Backoff doubles off a 250ms base, so the first retry waits 500ms, then 1s, then 2s, and every delay is capped at 30 seconds. A Retry-After header is read as a number of seconds and replaces the computed delay whenever it parses above zero, under that same 30 second cap. A date-form Retry-After does not parse and is ignored.
A request that produced no response at all, a refused connection or a DNS failure, raises EnvlessNetworkError on the spot. There is no status to classify, so it is never retried.
Timeouts and cancellation
timeout is a hard deadline for each attempt, covering the connection, the headers and the whole response body, so a server that trickles a body cannot hold a call open past it. When it passes, the call raises EnvlessNetworkError with is_timeout set to True. Retries each get a fresh deadline.
from envless import EnvlessNetworkError, envless
try:
products = envless.products.list_all(timeout=5)
except EnvlessNetworkError as error:
if not error.is_timeout:
raise
print('The API did not answer within 5 seconds')On Envless, each attempt with a deadline runs on a short-lived worker thread, which is what lets the deadline hold while a slow body trickles in. Two things follow. Event hooks, auth and transports on an http_client you pass run on that thread, without the caller's contextvars, so tracing or logging context set in the calling thread does not reach them. And an attempt that times out is abandoned, not interrupted: it finishes in the background before its connection goes back to the pool. timeout=0 keeps every attempt on the calling thread, with no deadline.
There is no cancellation argument, because Python has a better tool for it on the async client: cancel the task. asyncio.wait_for, asyncio.timeout on Python 3.11 and newer, anyio.fail_after and trio's cancel scopes all stop a call cleanly, connection included. See Async.
Bring your own httpx client
http_client takes any httpx.Client, which makes the whole SDK testable with no network and no mocking library: httpx.MockTransport answers every request in process.
import httpx
from envless import Envless
def handler(request: httpx.Request) -> httpx.Response:
data: dict[str, object] = {
'products': [],
'pagination': {'limit': 25, 'offset': 0, 'totalCount': 0, 'hasMore': False},
}
return httpx.Response(200, json={'success': True, 'code': 200, 'message': 'OK', 'error': None, 'data': data})
envless = Envless(
'ev_sk_test',
base_url='https://api.test',
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
disable_update_notice=True,
)
envless.products.list()A bare list() sends no query string at all: limit, offset, search and order are only appended when you pass them, and the API applies its own default of 25. Ask for list(limit=25) and the call becomes GET https://api.test/products?limit=25.
It is also the seam for a proxy, a corporate CA bundle, event hooks that log every request, or respx if you already use it. Redirects are followed whatever the client you pass says, so a moved endpoint behaves the same as with the default, and the SDK's timeout replaces the client's own on every request. Envless takes an httpx.Client and AsyncEnvless an httpx.AsyncClient; the wrong kind raises TypeError.
The SDK closes only the httpx client it created. One you pass in stays open when the SDK client closes, because it is yours: close it when you are done with it.
The raw escape hatch
envless.raw is the underlying transport. It unwraps the response envelope, applies the same retry policy, and raises the same typed errors, so you keep every guarantee while reaching something the namespaces do not cover yet.
from envless import envless
payload = envless.raw.request('/products', query={'limit': 5, 'order': 'desc'})| Argument | Type | Notes |
|---|---|---|
path | str | Appended to base_url. Encode your own path segments. |
method | str | Defaults to GET. |
query | Mapping[str, str | int | float | bool | None] | None and '' are dropped rather than sent empty, and booleans go out as true and false. |
body | Any | Serialised as JSON, and sets Content-Type. |
timeout | float | Overrides the client's timeout for this call. |
raw.request returns the data field of the envelope, not the envelope. There is no way to read success, message or _req.reqId on a successful response, by design: a non-success envelope becomes an EnvlessApiError that carries the request id for you.
Reuse one client
A client holds one httpx connection pool and no cursor or per-request state, so build one per process and share it. Envless is safe to share between threads. Close it with close() or a with block when you are done, and AsyncEnvless with await aclose() or async with. The default envless client lives for the whole process.