Strongly-consistent distributed key-value store for Rodmena services.
What it is for. Coordination: leader election, distributed locks, service discovery, feature flags, live configuration — anything where several processes must agree and the answer must be correct rather than fast.
What it is not. A general-purpose database, a cache, a queue, or a blob store. Values are small, the whole keyspace lives in memory, and every write costs a quorum round trip.
Everything on this page was verified against the live service through its public HTTPS interface. The machine-readable version is at /llms.txt.
| Interface | Endpoint | Use when |
|---|---|---|
| gRPC native etcd protocol |
consensus.rodmena.co.uk:443 |
You have a real etcd client — etcdctl, Go, Java, Rust.
Best performance and native streaming. |
| HTTP/JSON etcd gRPC-gateway |
https://consensus.rodmena.co.uk/v3/… |
You want plain HTTPS from any language, no SDK. |
Both reach the same cluster and the same data. TLS is an ordinary publicly-trusted certificate — no custom CA, no client certificates.
Exchange credentials for a token, then send that token on every request.
TOKEN=$(curl -s -X POST https://consensus.rodmena.co.uk/v3/auth/authenticate \
-d '{"name":"YOUR_USER","password":"YOUR_PASSWORD"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
Bearer prefix. Send the raw token:
Authorization: $TOKEN. Adding Bearer returns
{"error":"etcdserver: invalid auth token"}. This is the single most
common integration mistake.
Tokens are JWTs with a 30 minute TTL, signed by the cluster.
Treat HTTP 401 or invalid auth token as
“re-authenticate once and retry”, not as fatal — a long-lived process
that authenticates only at startup will start failing after 30 minutes. An
unauthenticated request fails with
{"error":"etcdserver: user name is empty"}.
In the HTTP/JSON API every key and value is base64-encoded, in requests
and responses — etcd stores arbitrary bytes and JSON cannot carry
bytes. Forget it and you will successfully store a key literally named
L215YXBwL2tleQ==. gRPC clients are unaffected.
All 64-bit fields are rendered as JSON strings, because JSON numbers cannot hold int64 safely:
{"header":{"revision":"13","raft_term":"2"},"count":"1",
"kvs":[{"create_revision":"3","mod_revision":"3","version":"1"}]}
Cast explicitly. "13" > "9" is false as a string
comparison, and lease IDs overflow a JavaScript number — keep them as strings.
Examples use b64() { printf "%s" "$1" | base64 -w0; }.
curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/put \
-H "Authorization: $TOKEN" \
-d "{\"key\":\"$(b64 /myapp/config/timeout)\",\"value\":\"$(b64 30s)\"}"
curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/range \
-H "Authorization: $TOKEN" \
-d "{\"key\":\"$(b64 /myapp/config/timeout)\"}"
kvs array and no count field
at all — not an empty list, not count: 0. Always use
response.get("kvs", []).
There is no prefix flag. Pass range_end = the prefix with its last
byte incremented. For /myapp/ that is /myapp0.
curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/range \
-H "Authorization: $TOKEN" \
-d "{\"key\":\"$(b64 /myapp/)\",\"range_end\":\"$(b64 /myapp0)\"}"
curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/deleterange \
-H "Authorization: $TOKEN" \
-d "{\"key\":\"$(b64 /myapp/config/timeout)\"}"
A transaction is compare → success or
failure, applied atomically across the cluster. This is the
compare-and-swap primitive that makes locks and leader election possible.
Create a key only if nobody else has (version = 0 means
“does not exist”):
curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/txn \
-H "Authorization: $TOKEN" \
-d "{
\"compare\":[{\"key\":\"$(b64 /myapp/leader)\",\"target\":\"VERSION\",
\"result\":\"EQUAL\",\"version\":\"0\"}],
\"success\":[{\"requestPut\":{\"key\":\"$(b64 /myapp/leader)\",
\"value\":\"$(b64 node-a)\"}}],
\"failure\":[]
}"
succeeded is omitted entirely rather
than being false. Test with
resp.get("succeeded") is True. Reading
resp["succeeded"] raises on exactly the branch you care about.
Compare targets: VERSION, CREATE,
MOD, VALUE. Comparing on MOD gives you
optimistic concurrency — read a key, note its mod_revision, write
only if it has not changed.
A lease is a TTL handle; keys attached to it vanish when it lapses. This is how you get liveness without cleanup code: if your process dies, its registration disappears by itself.
# grant a 10 second lease
LID=$(curl -s -X POST https://consensus.rodmena.co.uk/v3/lease/grant \
-H "Authorization: $TOKEN" -d '{"TTL":10}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["ID"])')
# attach a key to it
curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/put \
-H "Authorization: $TOKEN" \
-d "{\"key\":\"$(b64 /myapp/nodes/node-a)\",\"value\":\"$(b64 10.0.0.5:8080)\",\"lease\":\"$LID\"}"
# hold it open -- send at roughly TTL/3
curl -s -X POST https://consensus.rodmena.co.uk/v3/lease/keepalive \
-H "Authorization: $TOKEN" -d "{\"ID\":\"$LID\"}"
Verified: the key persists while keepalives continue and disappears within a few seconds of them stopping.
Watch is a long-lived streaming response, one JSON object per line.
curl -sN -X POST https://consensus.rodmena.co.uk/v3/watch \
-H "Authorization: $TOKEN" \
-d "{\"create_request\":{\"key\":\"$(b64 /myapp/config/)\",
\"range_end\":\"$(b64 /myapp/config0)\"}}"
The first object confirms creation; later objects carry events.
Each event has a type (absent means PUT) and a
kv. Use curl -N so nothing is buffered.
After a reconnect, pass start_revision — your last processed
revision plus one — so you miss nothing. Without it there is a gap.
This client is run against the live service as part of the deployment's test suite, so it is known to work as written.
import base64, httpx
BASE = "https://consensus.rodmena.co.uk"
def b64(s: str) -> str: return base64.b64encode(s.encode()).decode()
def unb64(s: str) -> str: return base64.b64decode(s).decode()
class Consensus:
def __init__(self, user: str, password: str):
self.c = httpx.Client(base_url=BASE, timeout=10.0)
self.token = self.c.post(
"/v3/auth/authenticate", json={"name": user, "password": password}
).json()["token"]
self.c.headers["Authorization"] = self.token # no "Bearer"
def put(self, key: str, value: str) -> int:
r = self.c.post("/v3/kv/put", json={"key": b64(key), "value": b64(value)})
r.raise_for_status()
return int(r.json()["header"]["revision"])
def get(self, key: str) -> str | None:
r = self.c.post("/v3/kv/range", json={"key": b64(key)})
r.raise_for_status()
kvs = r.json().get("kvs", []) # absent on a miss, not an empty list
return unb64(kvs[0]["value"]) if kvs else None
def list_prefix(self, prefix: str) -> dict[str, str]:
end = prefix[:-1] + chr(ord(prefix[-1]) + 1)
r = self.c.post("/v3/kv/range",
json={"key": b64(prefix), "range_end": b64(end)})
r.raise_for_status()
return {unb64(kv["key"]): unb64(kv["value"])
for kv in r.json().get("kvs", [])}
def create_if_absent(self, key: str, value: str) -> bool:
"""Atomic create. True if we won the race, False if it already existed."""
r = self.c.post("/v3/kv/txn", json={
"compare": [{"key": b64(key), "target": "VERSION",
"result": "EQUAL", "version": "0"}],
"success": [{"requestPut": {"key": b64(key), "value": b64(value)}}],
"failure": [],
})
r.raise_for_status()
return r.json().get("succeeded") is True # absent means False
import (
"context"
"crypto/tls"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
)
cli, _ := clientv3.New(clientv3.Config{
Endpoints: []string{"consensus.rodmena.co.uk:443"},
DialTimeout: 5 * time.Second,
Username: "YOUR_USER",
Password: "YOUR_PASSWORD",
TLS: &tls.Config{}, // zero value uses the system roots
})
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, err := cli.Put(ctx, "/myapp/config/timeout", "30s")
cancel()
etcdctl --endpoints=https://consensus.rodmena.co.uk:443 \
--user USER:PASSWORD get --prefix /myapp/
"serializable": true to a range request for a fast local
read that may be stale.revision is a global logical clock over the whole keyspace, so
it orders writes to unrelated keys against each other.| Limit | Value | Past it |
|---|---|---|
| Request body | 1.5 MB | Rejected |
| General API rate | 100 req/s per IP, burst 200 | HTTP 503 |
| Authenticate rate | 10 req/s per IP, burst 20 | HTTP 503 |
| Stream lifetime | 1 hour | Closed; reconnect with start_revision |
| History retention | compacted hourly | Watch from an older revision fails |
These return 403 even with valid admin credentials. They are
operator actions performed on the host, not over the internet.
/v3/maintenance/snapshot /v3/cluster/member/add
/v3/maintenance/defragment /v3/cluster/member/remove
/v3/maintenance/downgrade /v3/cluster/member/update
/v3/maintenance/alarm /v3/cluster/member/promote
/v3/maintenance/transfer-leadership
/v3/auth/disable
The equivalent gRPC methods are blocked too. A full-database snapshot over the public internet is total data exfiltration in one request, so it is refused regardless of who asks.
curl -s https://consensus.rodmena.co.uk/health
# {"health":"true","reason":""}
Unauthenticated and safe to poll from a load balancer.
Credentials are issued per application by the operator. Ask
farshid@rodmena.co.uk for a user
scoped to your prefix. Use one credential per service — a prefix-scoped user
means a leak is contained to that prefix. Namespace your keys as
/yourapp/… from day one so that stays possible.