nanocached (Python)
asyncio client SDK for nanocached,
a tiny distributed cache. Talks to either a single nanocached-node or a
nanocached-discovery-fronted cluster — the SDK figures out which from the
server's own handshake, so the calling code is identical either way.
Requires Python 3.11+. No runtime dependencies.
Install
pip install nanocached
Quick start
import asyncio
from nanocached import NanocachedClient
async def main():
# Point at a single node, or at a discovery server fronting a
# cluster — same call either way. `addresses` always takes a list;
# a one-element list is the single-target case.
client = await NanocachedClient.connect([("127.0.0.1", 8357)])
await client.set("greeting", "hello", ttl_seconds=60)
value = await client.get("greeting") # str | None
print(value) # hello
existed = await client.delete("greeting") # bool
await client.close()
asyncio.run(main())
Or use it as an async context manager, which closes the client for you:
async with await NanocachedClient.connect([("127.0.0.1", 8357)]) as client:
await client.set("greeting", "hello")
print(await client.get("greeting"))
Keys may be str (encoded as UTF-8) or bytes; values may likewise be
str or bytes on the way in. get(key) strictly decodes the stored
value as UTF-8 and returns str | None — a value that isn't valid UTF-8
raises UnicodeDecodeError rather than silently mangling it. Use
get_bytes(key) -> bytes | None for the raw bytes.
Discovery replicas
When the cluster runs more than one discovery server, pass them all in
addresses; both the initial connect and every node-list refresh try them
in order. An address that is warming up after a restart (answers B) is
skipped like an unreachable one; if every address is warming up, connect()
raises DiscoveryBusyError — retry shortly.
client = await NanocachedClient.connect([("10.0.0.1", 8357), ("10.0.0.2", 8357)])
Replication
The cluster's replication factor R rides along with the node list, so the
SDK needs no configuration: set/delete fan out to all R owners of a
key (the primary's result decides; a dead replica never fails a write),
and get asks the primary, falling over to the next owner only when the
holder is unreachable. client.replication exposes the factor in use.
Fire-and-forget replica writes
Off by default. set/delete normally wait for every replica leg to
finish, same as the primary. Enabling fire_and_forget_replicas returns
as soon as the primary acks, letting replica legs finish in the
background (fire-and-forget replica writes):
client = await NanocachedClient.connect(
[("cache.internal", 8357)],
fire_and_forget_replicas=True,
)
Unlike compress, this is a pure latency/durability trade for this
client's own writes — it carries no wire format, and different clients
may use different settings freely. At most 32 replica writes across the
whole client run in the background at once; past that cap, further
replica legs run synchronously exactly as with the option off (a
graceful degrade, not a queue or a drop). close() gives any
still-in-flight background replica writes a chance to finish before
tearing down their connections.
Read repair
Off by default. A clean miss (the key's first-reached owner reports it
missing) is normally accepted as-is. Enabling read_repair probes the
remaining owners before accepting that, and repairs the primary in the
background if one still has the value (read repair):
client = await NanocachedClient.connect(
[("cache.internal", 8357)],
read_repair=True,
)
Closes the narrow window after a primary restart where a replica still
holds a key its (fresh) primary doesn't, at the cost of extra reads only
on the misses that hit that window. The repair write carries a fixed 60-second TTL — the wire protocol's G response never returns the original one to preserve, and no TTL at all would immortalize already-expired keys — and,
unlike fire-and-forget replica writes, is uncapped and not drained on
close(): this only fires on an already-rare clean miss, and losing one
costs nothing beyond staying in the window for one more read.
Hedged reads
Off by default. A read goes to the key's primary owner and moves on to
the next owner only when the primary fails — so one slow-but-alive
node (a saturated host, a bad link) makes every read that touches it
wait out its full round trip, and with R copies on N nodes that is
roughly R/N of all reads. Setting read_hedge_after (seconds) sends
the same read to the next owner as well once the primary has been
silent for that long, and takes the first answer:
client = await NanocachedClient.connect(
[("cache.internal", 8357)],
read_hedge_after=0.01, # hedge after 10 ms
)
A hit from any owner is final. A miss is only final from the primary: a
replica's miss is provisional (it may simply lack the copy), so the
primary's answer is still waited for and hedging never turns a hit into
a miss — a genuine miss on a slow primary still pays its round trip. Pick
a value a few times the healthy p99 so a fast cluster hedges rarely: each
hedge costs one extra read on another owner. Needs R >= 2; with a
single copy there is nobody to hedge to. Writes are unaffected — every
copy must be written, so a slow owner bounds writes to it regardless
(fire_and_forget_replicas moves only the replica legs off the caller's
path). The losing leg of a hedge is left to finish and is drained by
close().
Reconnect and keep-alive
nanocached-node closes connections idle for 60 seconds; the SDK keeps
its connections warm automatically, pinging any connection that real
traffic has left idle for 30 seconds — so an idle timeout never severs a
healthy client, and a request that does find its connection dead (a node
restart, a network blip) redials and retries once transparently (all
operations are idempotent).
connect() itself tolerates a node that discovery still lists but that
can't be reached — typically one that just died and hasn't been evicted
yet (a window of seconds): the node is kept in the ring without a
connection, requests for its keys fail over per request exactly as they
would after a mid-life death, and it is redialed after the cooldown.
Only a cluster with no reachable node at all fails connect().
An address whose redial just failed is treated as still down for
reconnect_cooldown seconds (default 1.0, a connect() keyword
argument): requests routed to it during that window fail immediately with
the original dial error instead of each paying another full 5-second
connect timeout. Keep it short — a node that genuinely recovers is shut
out for at most this long.
Authentication and TLS
client = await NanocachedClient.connect(
[("cache.internal", 8357)],
auth_secret="change-me", # NANOCACHED_AUTH_SECRET on the server
tls=True, # verifies against the platform trust store
)
For a self-signed or private-CA server, pass ca — a PEM file of trusted
root certificate(s), which replaces the default trust store:
client = await NanocachedClient.connect(
[("cache.internal", 8357)],
tls=True,
ca="cluster-ca.pem",
)
ca is only meaningful when tls=True; if tls=False it is silently
ignored. An unreadable or unparseable CA file is a connect-time error.
Value compression
Off by default. When enabled, values at or above compression_threshold
bytes are transparently DEFLATE-compressed on set and decompressed on
get/get_bytes (value compression):
client = await NanocachedClient.connect(
[("cache.internal", 8357)],
compress=True,
compression_threshold=256, # default; bytes, below which values are stored as-is
)
Every client that reads or writes a given set of keys must agree on
compress. This is a per-keyspace format decision, not a per-client
preference — enabling it prefixes every value this client writes with a
one-byte marker, so a client with compress=False reading one of those
values gets the marker byte back as if it were part of the value (wrong,
silently), and a client with compress=True reading a value written
before compression was enabled anywhere risks misreading that value's
first byte as the marker (a DecompressionError, or — if that byte
happens to be the "uncompressed" marker by chance — a silently wrong
read). There is no dual-mode migration path: only turn this on for a
fresh keyspace, or only after every client touching an existing one has
upgraded and enabled it together. Incompressible data (already-compressed
media, random bytes) is passed through unchanged rather than bloated.
Notes
- Requests are pipelined per connection (request pipelining), matching the TypeScript SDK: concurrent callers on the same connection each pay only their own network latency, not everyone else's ahead of them.
- This SDK speaks the current wire protocol (rendezvous hashing,
replication-aware
L/W); it requires an up-to-date server. close()is a coroutine (like aiohttp'sClientSession.close): it returns only after any in-flight background replica writes finish and every connection is torn down. It is idempotent, but calling it again on an already-closed client prints a warning to stderr — usually a sign the client's lifecycle was mismanaged. Likewise, callingconnect()again for the same single address while a previous connection to it is still open prints a warning ("was close() forgotten?"); this check is skipped for multi-address configs, where concurrent clients sharing an address list are legitimate.- Caller mistakes (a negative
ttl_seconds, an empty address list) raise host-language builtins (ValueError), notNanocachedError— the SDK's error family covers failures the server or network produced, a convention shared across the SDKs (issue #47). Authentication failure isAuthenticationError, aNanocachedErrorsubclass.
License
MIT
Metadata
Release files for nanocached 0.3.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 | |
|---|---|---|---|
| nanocached-0.3.0.tar.gz | 58.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nanocached-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 96.2 kB
Release files / nanocached-0.3.0.tar.gz
| Download URL | nanocached-0.3.0.tar.gz |
|---|---|
| Size | 58.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2e58bd0229a27c705a69c932409d8ea079e03015b77be320b8332ee285a6025f
|
|
BLAKE2b-256 checksum How to use checksums |
3dac7a217c7d076d3313adb30c98535501c34baff7389546020c7474bd990ac9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / nanocached-0.3.0-py3-none-any.whl
| Download URL | nanocached-0.3.0-py3-none-any.whl |
|---|---|
| Size | 38.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4fae48efb43eb9a75b2aa909b7e73683350da2f1c26596fffb8970310ed9e395
|
|
BLAKE2b-256 checksum How to use checksums |
367f06c0234cd86b08a7a258cf676ea31febb21ec5f96be52f1fd2ff7f191290
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|