haven-client
The Python client for Haven, Rodmena's Vault-compatible secret manager.
pip install haven-client
It needs Python 3.12 or newer. Its only dependencies are httpx and resilient-circuit.
Five lines
from haven_client import HavenClient
with HavenClient(address="https://haven.rodmena.co.uk", token="hvn.0...") as haven:
haven.kv.write("billing/db", {"username": "svc", "password": "s3cr3t"})
print(haven.kv.read("billing/db").data["username"])
Configuration from the environment
HavenClient() with no arguments reads the environment. The VAULT_* names are accepted too, so existing deployment manifests keep working.
| Variable | Meaning |
|---|---|
HAVEN_ADDR / VAULT_ADDR |
Base URL, e.g. https://haven.rodmena.co.uk |
HAVEN_TOKEN / VAULT_TOKEN |
A token, if you already hold one |
HAVEN_TOKEN_FILE |
A file holding the token. Read once at start |
HAVEN_NAMESPACE / VAULT_NAMESPACE |
Tenant slug. Haven uses it to confirm a tenant, never to select one |
HAVEN_CACERT / VAULT_CACERT |
CA bundle used to verify Haven's certificate |
HAVEN_CLIENT_CERT, HAVEN_CLIENT_KEY |
Client certificate for mTLS (the VAULT_* names are read too) |
TLS verification is always on. You can turn it off only in code, with TlsConfig(verify=False); no environment variable can switch it off, so a typo in a manifest can never quietly disable it.
Logging in
Kubernetes
The pod's service-account token is the credential. Nothing long-lived is stored in the pod.
haven = HavenClient.with_kubernetes(role="billing-api")
haven.start_lifecycle()
password = haven.kv.read("billing/db").data["password"]
- The client reads
/var/run/secrets/kubernetes.io/serviceaccount/tokenagain at every login. Kubernetes rotates projected tokens, so a cached copy would expire. - The Haven role must name an
audience, and the pod's projected token must carry that same audience:
volumes:
- name: haven-token
projected:
sources:
- serviceAccountToken:
path: token
audience: haven
expirationSeconds: 3600
haven = HavenClient.with_kubernetes(role="billing-api", token_path="/var/run/secrets/haven/token")
AppRole
For a service that does not run on Kubernetes. Keep the secret id in a file readable only by the service user (mode 0400). The client reads it at each login and never keeps it.
haven = HavenClient.with_approle(role_id="...", secret_id_file="/etc/billing/haven-secret-id")
haven.start_lifecycle()
If the role sets secret_id_bound_cidrs, that file is useless off the hosts it was issued for.
A wrapped secret id
When the secret id is delivered response-wrapped, unwrap it once, with the wrapping token itself as the credential:
bootstrap = HavenClient(address=addr)
secret_id = bootstrap.wrapping.unwrap(wrapping_token).data["secret_id"]
Token lifecycle
start_lifecycle() logs in, then runs a background thread that:
- renews the token at two thirds of the
lease_durationHaven returned, with jitter; - logs in again when a renewal is refused or comes back too short (the token reached its maximum TTL);
- records the failure when both renewal and re-login fail, in
haven.last_auth_error, and callson_auth_error. It never fails silently:
def alarm(exc: Exception) -> None:
metrics.increment("haven_auth_failures")
haven = HavenClient.with_kubernetes(role="billing-api", on_auth_error=alarm)
Engines
haven.kv.read(path, version=None) # KV v2 at "secret"; haven.kv_v2("other") for another mount
haven.kv.write(path, data, cas=0) # cas=0: only if the path does not exist yet
haven.kv.patch(path, data)
haven.kv.metadata(path)
haven.kv.list(prefix)
haven.kv.delete_versions(path, [1]) # soft delete, can be undeleted
haven.kv.destroy_versions(path, [1]) # permanent
haven.kv_v1("kv").read(path)
haven.transit.encrypt("orders", b"...")
haven.transit.decrypt("orders", ciphertext)
haven.transit.sign("jws", payload, hash_algorithm="sha2-256", signature_algorithm="pkcs1v15") # RS256
haven.transit.verify("jws", payload, signature, signature_algorithm="pkcs1v15")
haven.pki.issue("internal", "billing.internal", ttl="72h")
haven.database.credentials("reader") # a fresh user and password, with a lease
haven.leases.renew(lease_id, increment="1h")
haven.sys.is_sealed()
Anything not covered by a helper is still reachable with haven.request(method, path, json_body=..., params=...).
Retries: what the client will and will not repeat
- Retried: idempotent requests only (GET, LIST, HEAD and the explicitly idempotent reads such as
transit.verify), on a network error, a timeout, 429, 502, 504, or a 503 for storage being unavailable. Waits use exponential backoff with jitter, and honourRetry-Afterup to 60 s. Retries go throughresilient-circuit. - Never retried: writes (POST, PUT, PATCH, DELETE) are sent exactly once, because a repeated write can issue a second certificate or create a second database user. A write that fails raises, and you decide.
- Not retried by default: a sealed Haven. A sealed Haven needs an operator to unseal it, and retrying cannot change that. Raise
Sealedstraight away, or opt in withRetryConfig(retry_sealed=True).
Errors
| Exception | When |
|---|---|
HavenClientError |
Base of everything below |
TransportError |
No HTTP answer: DNS, TLS, connect, timeout. The cause is chained |
HavenApiError |
Haven answered with an error. Carries .status_code, .code, .errors and .request_id |
BadRequest |
400 |
InvalidCredentials |
401 |
PermissionDenied |
403 |
NotFound |
404 |
Conflict (409), CasMismatch |
A check-and-set lost to another writer |
RateLimited |
429 |
Unavailable (503) |
Subclasses: Sealed, StorageUnavailable |
Uninitialized |
501 |
ServerError |
Other 5xx |
AuthenticationFailed |
A login, or the lifecycle, could not obtain a token |
Quote .request_id when reporting a problem: it is the same id Haven's audit log records.
Asynchronous use
from haven_client import AsyncHavenClient
async with AsyncHavenClient(address=addr, token=token) as haven:
secret = await haven.kv.read("billing/db")
The async client runs the same code as the synchronous one in a worker thread, so their behaviour cannot drift apart.
Secrets never leak through repr
Tokens, secret ids, database passwords and private keys print as <redacted> in repr(), str() and log records. The value is still there: call .reveal() on a SecretString when you mean to use it.
Compatibility
- Tested against Haven 0.1.0 with the server change from 2026-10 (machine-readable error
code). - Older servers work too. Without
codethe client tells sealed from storage-down by the message text. - See CHANGELOG.md for what changed from 0.1.
Metadata
Release files for haven-client 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| haven_client-1.0.0.tar.gz | 24.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| haven_client-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 47.8 kB
Release files / haven_client-1.0.0.tar.gz
| Download URL | haven_client-1.0.0.tar.gz |
|---|---|
| Size | 24.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
374baa7cd62a142e3066f889773dabefac5b32504bd14346cffc110584fbf59c
|
|
BLAKE2b-256 checksum How to use checksums |
f1bb1126353bb53e7c584a092f364679e8fb8de31435325c0aab8d65d986f662
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / haven_client-1.0.0-py3-none-any.whl
| Download URL | haven_client-1.0.0-py3-none-any.whl |
|---|---|
| Size | 23.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5629f8be30e68bde421dbb2231ed5103a82f157cd11f24b0238ec8c69057f02c
|
|
BLAKE2b-256 checksum How to use checksums |
6c3f7c890cc2a038b157cb7edf07b443af41e419f5394a570c30d12b23d1a768
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|