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.context deadline exceeded,
but it may still commit once quorum returns. Re-read, or use a txn that
compares on mod_revision, before retrying.| 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. HEAD /health returns the same status and headers with no body.
Survives: the loss of any one of the three members — restart, upgrade, crash, or the loss of its whole site. Members run in three separate datacentres (London, Gravelines, Limburg) and quorum is 2, so the remaining two keep serving linearizable reads and writes.
Does not survive: the loss of two members at once. A single survivor has no quorum and refuses writes rather than risk divergence. It is unavailable, not corrupted, and recovers when a second member returns.
Public edge: the HTTPS endpoint currently runs on one host (London). If that host is down, the endpoint is down even though the cluster itself still has quorum. Member ports are not reachable from outside the cluster, so there is no alternative address to fail over to yet. A second edge is planned.
Replication is not backup: a mistaken delete reaches every member in milliseconds. Keep your own copy of anything whose loss you could not tolerate.
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.