Skip to main content

consul-http

Lightweight Consul KV client built on httpx.

中文说明见下方。

Why this package?

Unlike full-featured clients such as py-consul / python-consul, this library focuses on:

  • In scope: KV (get / put / delete / prefix), Session, distributed lock, blocking watch_kv
  • Out of scope (for now): Agent / Catalog / Health / Txn / Connect — use a full Consul SDK if you need those
  • First-class sync + async with the same API shape
  • Async-friendly async with client.lock(...) for leader election
  • Optional injection of an existing httpx.Client / AsyncClient (shared pools, proxies, OTel)
  • Small dependency surface (httpx only)
  • Explicit typing (py.typed)

Compatibility

Targeted at Consul 1.10+ HTTP API (KV + Session).

URL / port rules (intentional):

Address Port used
http://127.0.0.1 / localhost (no port) 8500
http://consul.example.com (no port) scheme default 80/443 (reverse-proxy friendly)
http://host:8500 explicit 8500
http://host:8500/prefix path prefix kept → .../prefix/v1

Install

pip install consul-http
# or
uv add consul-http

Editable (from this monorepo):

uv add --editable ./packages/consul_http

Requires Python 3.10+.

Tests

# unit (default; skips live Consul)
uv run pytest

# live agent — needs write ACL; loads CONSUL_HTTP_* from repo .env when present
$env:CONSUL_INTEGRATION = "1"   # PowerShell
uv run pytest -m integration

Quick start

from consul_http import ConsulClient

# Uses CONSUL_HTTP_ADDR / CONSUL_HTTP_TOKEN when set
with ConsulClient() as c:
    c.put_kv("app/demo/key", "hello")
    res = c.get_kv("app/demo/key")
    if res is None:
        print("missing")
    else:
        print(res.raw_value, res.index)
    c.delete_kv("app/demo/key")

Async + blocking query

import asyncio
from consul_http import AsyncConsulClient

async def watch(key: str) -> None:
    async with AsyncConsulClient() as c:
        res = await c.get_kv(key)
        assert res is not None
        index = res.index
        while True:
            res = await c.get_kv(key, index=index, wait="30s")
            if res is None:
                continue
            if res.index != index:
                print("changed:", res.raw_value)
                index = res.index

# asyncio.run(watch("app/demo/key"))

Prefer the built-in watcher (handles index regression after leader change / snapshot restore — waiting on a stale index would hang forever otherwise):

async def watch(key: str) -> None:
    async with AsyncConsulClient() as c:
        async for res in c.watch_kv(key, wait="30s"):
            if res is None:
                print("missing")
                continue
            print("changed:", res.raw_value)

Session + distributed lock

from consul_http import AsyncConsulClient

async def on_lost() -> None:
    print("lock lost — stop critical work")

async def run_leader() -> None:
    async with AsyncConsulClient() as c:
        lock = c.lock(
            "service/demo/leader",
            ttl="15s",
            value="instance-1",
            on_lost=on_lost,  # sync or async callable
        )
        async with lock as acquired:
            if not acquired:
                return
            while lock.is_held:
                # do leader work; exit early if renew failed
                ...

# Low-level: create_session / put_kv(..., acquire=...) / renew_session / destroy_session
# Read lock holder: c.get_kv("service/demo/leader", raw=False) → res.session, res.lock_index

Sync mirror: with client.lock(...) as acquired: (on_lost must be sync there).

Exit skips KV release when the lock was already lost (lock.lost is True).

Injected httpx client / per-request token

import httpx
from consul_http import ConsulClient

# Share pools / proxies / custom transport; ConsulClient will NOT close `http`.
# Default headers (incl. X-Consul-Token) and connect settings stay on `http` —
# constructing ConsulClient(client=...) does not merge token into that client.
# Blocking / watch reads still apply ConsulClient.timeout via per-request timeout.
with httpx.Client(
    base_url="http://127.0.0.1:8500/v1",
    headers={"X-Consul-Token": "default-tok"},
    timeout=30.0,
) as http:
    with ConsulClient(client=http, timeout=10.0) as c:
        c.get_kv("app/demo/key", token="one-shot-acl-token")

Binary KV

with ConsulClient() as c:
    c.put_kv("app/demo/blob", b"\x00\xffprotobuf-or-msgpack")
    res = c.get_kv("app/demo/blob", raw=False)
    assert res is not None
    data = res.raw_bytes  # exact bytes; raw_value may use U+FFFD on bad UTF-8

Retry / backoff

By default clients retry transient failures up to 3 attempts with exponential backoff + jitter (RetryConfig).

What Retried?
Transport / timeout (httpx.RequestError) Yes
HTTP 429 / 502 / 503 / 504 Yes (except CAS / acquire / release / create_session — see below)
404, other 4xx, CAS body false No

CAS / lock / session-create caveat: For put_kv / delete_kv with cas= / acquire= / release=, and for create_session, status-code retries are disabled so a lost 502/504 after a successful apply is not re-issued (which could look like a false conflict / failed acquire, or orphan extra sessions). Transport-error retries still run; if a request may have been applied but the response was lost, a later retry can still return false / ConsulCASConflictError, or create an extra session (TTL eventually reaps orphans). When that happens and you suspect a retry race, re-get_kv (prefer raw=False) and compare value / session before treating it as a real concurrent write.

from consul_http import ConsulClient, RetryConfig

# Custom policy
with ConsulClient(retry=RetryConfig(max_attempts=5, backoff_factor=0.2)) as c:
    c.get_kv("app/demo/key")

# Disable retries
with ConsulClient(retry=RetryConfig(max_attempts=1)) as c:
    c.get_kv("app/demo/key")
# or: ConsulClient(retry=None)

Writes do not retry transport errors by default because the server may have applied a write before the response was lost. Such cases raise ConsulRequestOutcomeUnknown; reconcile the key before deciding whether to retry. RetryConfig(respect_retry_after=True) honors a server Retry-After header for retryable status codes.

Production watch lifecycle

from threading import Event

stop = Event()
with ConsulClient() as c:
    for update in c.watch_kv(
        "app/config",
        stop_event=stop,
        max_consecutive_failures=5,
    ):
        if update is not None:
            apply_config(update.value)

Use raise_if_lost() during long lock-protected work. Lock loss cannot fence an already-running process from writing an external system; use a fencing token at the downstream storage layer when that guarantee is required.

For observability, pass an implementation of EventSink to the client. Hook exceptions are isolated from request execution and events never contain ACL tokens or KV values.

Environment variables

Variable Meaning
CONSUL_HTTP_ADDR e.g. http://127.0.0.1:8500
CONSUL_HTTP_TOKEN ACL token

Errors

  • ConsulConnectionError — transport / timeout failures
  • ConsulPermissionError — HTTP 401/403 (invalid token / ACL deny); subclass of ConsulAPIError
  • ConsulAPIError — other non-success HTTP (body truncated in message)
  • ConsulCASConflictErrorput_kv/delete_kv with cas= returned false when raise_on_cas_conflict=True

中文

轻量 Consul KV 客户端(httpx Sync/Async),含 Session / 分布式锁。默认指数退避重试;cas/acquire/release/create_session 不重试 HTTP 状态码。目标 API:Consul 1.10+

from consul_http import ConsulClient

with ConsulClient.from_base_url("http://127.0.0.1:8500") as c:
    print(c.get_kv("app/demo/key"))

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

consul_http-0.1.0.tar.gz (33.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

consul_http-0.1.0-py3-none-any.whl (26.9 kB view details)

Uploaded Python 3

File details

Details for the file consul_http-0.1.0.tar.gz.

File metadata

  • Download URL: consul_http-0.1.0.tar.gz
  • Upload date:
  • Size: 33.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for consul_http-0.1.0.tar.gz
Algorithm Hash digest
SHA256 fb10930ccd682ce7534935ac2ba50095c911e55aee4bec6080e8dff4d354d575
MD5 6234c484ee7eccd561dd605a819f9c80
BLAKE2b-256 8239fea6dc7980cc74475db58364e2aceac9d95d4cfc878e0ac4663e02509c19

See more details on using hashes here.

File details

Details for the file consul_http-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: consul_http-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 26.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for consul_http-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 548806ca0142e090cb0110503c93a5e427b7fcfa8ed3316081c3b05eff95fe61
MD5 c41ca1905c0b874ebcebbbe9c6a10232
BLAKE2b-256 c52caf7994849e4db8a925ed47503e701fe6a23d9e62efb1b351a059cd02c703

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page