Backends¶
A backend is where Traffik keeps score. Every increment, every counter check, every lock acquisition goes through the backend. Choosing the right one is usually the first architectural decision you'll make when adding Traffik to a project, and for Redis and Memcached specifically, choosing which client backs it is the second.
Choosing a backend family¶
| Feature | In-Memory | MultiProcess (experimental) | Redis | Memcached |
|---|---|---|---|---|
| Best for | Dev, tests, single process | Multiple workers, one machine | Production, distributed | Existing Memcached stacks |
| Persistence | No | No | Optional (persistent=True) | Optional (persistent=True & track_keys=True(enables resets)) |
| Distributed | No | Same machine only | Yes | Yes |
| Lock type | asyncio RLock | multiprocessing.Semaphore | Redis Lua / Redlock / client-native lock | Memcached add CAS |
| Overhead | Lowest | Low (shared memory, no network) | Low (Lua scripts, pipelining) | Low |
| Requires extra dep | No | No (stdlib multiprocessing) | See Redis client backends | See Memcached client backends |
reset() / clear() | Full | Full | Full (Lua / native SCAN) | Only when track_keys=True |
Lock TTL defaults to no expiry - set one
Every backend's lock_ttl defaults to None unless you configure traffik.config.set_lock_ttl(...) globally or pass ttl= explicitly to backend.lock(...). For Memcached specifically, None means the lock key is stored with exptime=0, which is Memcached's protocol for never expires - not "expires immediately". If a process dies between acquiring a lock and releasing it (a crash, kill -9, OOM), an un-expiring lock stays held forever, and everything waiting on it deadlocks. Set a real TTL, sized comfortably longer than your slowest realistic critical section - see Configuration for the global default, and pair it with enforce_ttl_locally=True so a critical section that runs longer than the TTL gets cancelled instead of silently losing exclusivity.
In-Memory Backend¶
The InMemoryBackend stores everything in process memory using sharded OrderedDict stores. It is not suitable for multi-process or distributed deployments, but it is perfect for development and testing.
from traffik.backends.inmemory import InMemoryBackend
backend = InMemoryBackend(
namespace="myapp", # Key prefix for all throttle keys
persistent=False, # Clear data on context exit (default)
on_error="throttle", # "allow" | "throttle" | "raise" | callable
number_of_shards=3, # Shard count for concurrent access (default 3)
cleanup_frequency=10.0, # Seconds between expired-key sweeps (default 10.0)
lock_kind="unfair", # "fair" | "unfair" (default "unfair")
lock_blocking=True, # Block when acquiring locks
lock_ttl=None, # Lock TTL in seconds (None = no expiry)
lock_blocking_timeout=None, # Max wait for locks in seconds
)
Characteristics:
- Lock striping via shards reduces contention significantly, especially when hitting multiple keys simultaneously.
- A background cleanup task (configurable via
cleanup_frequency) sweeps expired keys on a schedule. Set it toNoneto disable background cleanup; Traffik will still lazily evict expired entries on reads. lock_kind="fair"gives strict FIFO ordering across tasks at the cost of slightly higher overhead.
When to use: Local development, unit tests, or genuinely single-process deployments where you never need to share state between workers.
Always use In-Memory in tests
Swap out your production backend for InMemoryBackend in your test suite. It requires no external services, resets itself cleanly between test runs, and adds no I/O latency. Your tests will be fast enough to make you smile.
Multi-Process In-Memory Backend¶
Experimental
Works, and is tested, but the setup is unforgiving if you skip the constraints below. Read this whole section before using it in production.
Runs multiple worker processes on one machine, sharing rate-limit state without standing up Redis. Data lives in a multiprocessing.shared_memory segment; locking uses multiprocessing.Semaphore.
That second detail is what drives every constraint here: shared memory can be attached to by name from any process, but semaphores can't. They only work when created once and inherited through fork(). Hence, there is exactly one correct way to use this backend:
# main.py - imported once by your process manager's parent, before it forks
from fastapi import FastAPI
from traffik.backends.multiprocess import MultiProcessInMemoryBackend
backend = MultiProcessInMemoryBackend(namespace="myapp")
backend.start() # synchronous, no event loop needed
app = FastAPI(lifespan=backend.lifespan)
Run it with gunicorn, preload_app=True, and a fork-based worker class:
# gunicorn_config.py
preload_app = True
workers = 4
worker_class = "uvicorn_worker.UvicornWorker" # Needs `uvicorn-worker` installed
Every forked worker inherits a fully-working copy automatically - shared memory, semaphores, all of it. Workers don't need to call anything to "join" it; lifespan=backend.lifespan running in each worker's own event loop is enough.
Don't use uvicorn --workers N with this backend
Uvicorn's native multi-worker mode spawns workers with Python's spawn start method, not fork. Each worker re-imports your app fresh, with no shared memory inheritance at all. You'd get one independent, uncoordinated instance per worker, silently. Use gunicorn (or another genuinely fork-based process manager) instead.
Why start() and initialize()¶
start() does the actual setup (shared memory, semaphores, lock pools) and is fully synchronous, so it's safe to call from gunicorn's preload_app phase, which doesn't have an event loop running. initialize() calls start() (a no-op if the parent already did it) and additionally makes sure this process's background cleanup task is running in this process's event loop, which is something start() genuinely can't do without a loop, and something each worker needs done independently regardless of what the parent already did. lifespan=backend.lifespan calls initialize() for you on ASGI startup, so in the common case you never call it directly.
What you get for free¶
- Fork-safety for threads. The backend registers an
os.register_at_forkhook that rebuilds its internal thread pool in every forked child - POSIXfork()only duplicates the calling thread, so the parent's worker threads simply don't exist post-fork, and this handles that transparently. - Safe shutdown. Only the process that actually called
start()will ever unlink the shared memory segment onclose(). An ordinary worker recycling (e.g. gunicorn'smax_requests) never tears down state the other workers still need. - Self-healing on restart. If a segment with the target name already exists when
start()runs, it's treated as a stale leftover from a previous run (never an actively-used peer - nothing can safely attach to one anyway) and recreated.
Redis client backends¶
Traffik ships two independent Redis backend implementations, one per client library. Both are named RedisBackend, live under traffik.backends.redis, and implement the identical ThrottleBackend interface - pick one and the rest of your code (throttles, quotas, middleware) doesn't change. You do not need both installed.
# redis-py (the "aioredis" module) - default, most common
from traffik.backends.redis.aioredis import RedisBackend
# coredis - native cluster & sentinel support
from traffik.backends.redis.coredis import RedisBackend
For backwards compatibility, from traffik.backends.redis import RedisBackend still works and resolves to the aioredis-based backend if it's installed.
| Feature | redis.aioredis (redis-py) | redis.coredis |
|---|---|---|
| Underlying client | redis.asyncio (redis package) | coredis |
| Redis Cluster | No (single node / redis-side proxy only) | Yes, native (coredis.RedisCluster) |
| Redis Sentinel | No | Yes, native (coredis.Sentinel) |
| Distributed lock types | "redis" (SET NX EX + Lua) and "redlock" (via pottery) | Single client-native lock (coredis.patterns.lock.Lock) |
| Extra dependencies | redis>=5.0.0, pottery>=3.0.1 | coredis>=6.0 |
| Python support | Same as Traffik | Python 3.10+ only |
| Install extra | traffik[aioredis] (alias: traffik[redis]) | traffik[coredis] |
When to pick which:
- Default to
aioredisfor a single Redis instance or a standard Redis deployment behind a proxy. It's the more battle-tested client, andpottery's Redlock support covers the multi-instance-without-native-clustering case. - Reach for
coredisif you're running an actual Redis Cluster or Sentinel topology and want the backend talking to it natively, instead of routing through a single endpoint. It requires Python 3.10+.
redis.aioredis¶
from traffik.backends.redis.aioredis import RedisBackend
backend = RedisBackend(
"redis://localhost:6379",
namespace="myapp",
persistent=False,
on_error="throttle",
lock_type="redis", # "redis" (default) or "redlock"
lock_blocking=True,
lock_ttl=10.0, # Never set to "0" to avoid deadlocks
lock_blocking_timeout=None,
)
When you need full control over the connection (connection pools, TLS, password, etc.), pass an async factory instead of a URL:
import redis.asyncio as aioredis
from traffik.backends.redis.aioredis import RedisBackend
async def get_redis():
return await aioredis.from_url(
"redis://:secretpassword@redis-host:6379/0",
decode_responses=True,
max_connections=20,
)
backend = RedisBackend(
get_redis, # async callable, not a string
namespace="myapp",
)
Lock types:
lock_type | Algorithm | Best for |
|---|---|---|
"redis" | SET NX EX + Lua fencing | Single Redis instance, lowest latency |
"redlock" | Redlock (via pottery.AIORedlock) | Redis clusters, multiple instances |
Redlock is slower by design
Redlock involves multiple round-trips across several Redis nodes. Unless you are running a genuine Redis cluster with multiple masters, stick with lock_type="redis". The added latency of Redlock is rarely worth it for a single node.
Characteristics:
- Atomic
increment_with_ttlis implemented as a single Lua script, no race conditions between increment and expire. multi_getuses RedisMGET(one round-trip for multiple keys).multi_setuses a Redis pipeline withMULTI/EXECfor atomicity.clear()/reset()scan and delete namespace keys via a Lua script to avoid blocking the server.
redis.coredis¶
Supports three connection topologies via the same connection argument.
Single node:
from traffik.backends.redis.coredis import RedisBackend
backend = RedisBackend("redis://localhost:6379/0", namespace="myapp")
Redis Cluster - pass a pre-built client, or a list of startup nodes and let the backend build the cluster client for you:
from coredis import RedisCluster
from coredis.connection import TCPLocation
from traffik.backends.redis.coredis import RedisBackend
client = RedisCluster(
startup_nodes=[TCPLocation("127.0.0.1", 7000)],
decode_responses=True,
)
backend = RedisBackend(client, namespace="myapp")
# or, equivalently, hand the backend the node list directly:
backend = RedisBackend(
[TCPLocation("127.0.0.1", 7000), TCPLocation("127.0.0.1", 7001)],
namespace="myapp",
)
Sentinel:
from coredis import Sentinel
from traffik.backends.redis.coredis import RedisBackend
sentinel = Sentinel([("sentinel-host", 26379)], stream_timeout=0.1)
backend = RedisBackend(
sentinel,
sentinel_service_name="myredis",
namespace="myapp",
)
Or pass an async factory () -> Union[Redis, RedisCluster] for full control over client construction, the same way aioredis supports it.
Characteristics:
- Any pre-built
coredis.Redis/coredis.RedisClusterinstance handed in must be constructed withdecode_responses=True, or initialization raises. - No
lock_typechoice - locking always goes throughcoredis.patterns.lock.Lock, which is fencing-token protected but not Redlock-style multi-instance quorum locking. lock_sleepcontrols the poll interval between blocking-lock acquisition attempts (default0.0025s), independent oflock_blocking_timeout.lock_contention_thresholdgates high-contention keys through a process-localasyncio.Lockbefore hitting Redis, reducing thundering-herd load on the server when many local tasks are waiting on the same key.- A pre-built client or Sentinel-provided primary is never closed by
close()- only clients the backend constructed itself are torn down, so ownership stays with whoever passed the client in.
Memcached client backends¶
Same story as Redis: two backend implementations, one per client library, both named MemcachedBackend, both under the same ThrottleBackend interface.
# aiomcache - default, single node only
from traffik.backends.memcached.aiomcache import MemcachedBackend
# emcache - multi-node, higher throughput, no Windows support
from traffik.backends.memcached.emcache import MemcachedBackend
from traffik.backends.memcached import MemcachedBackend still works for backwards compatibility and resolves to the aiomcache-based backend if installed.
| Feature | memcached.aiomcache | memcached.emcache |
|---|---|---|
| Underlying client | aiomcache | emcache |
| Multi-node support | No, single node only | Yes, native (Rendezvous hashing across nodes) |
| TLS / auth | No | Yes (ssl, ssl_verify, ssl_extra_ca, username, password) |
| Autobatching | No | Yes, optional (autobatching=True) - concurrent gets transparently batched into get_many |
| Platform support | All (Windows included) | Linux and macOS only (no Windows wheels) |
| Extra dependency | aiomcache>=0.8.2 | emcache>=1.3.3 |
| Install extra | traffik[aiomcache] (alias: traffik[memcached]) | traffik[emcache] |
When to pick which:
- Default to
aiomcachefor a single Memcached instance, or if you need Windows support. - Reach for
emcacheif you're spreading load across multiple Memcached nodes, need TLS/auth, or want autobatching for read-heavy workloads - and can run on Linux/macOS.
memcached.aiomcache¶
from traffik.backends.memcached.aiomcache import MemcachedBackend
# From explicit host/port
backend = MemcachedBackend(
host="localhost",
port=11211,
pool_size=2,
pool_minsize=1,
namespace=":memcached:",
persistent=False,
on_error="throttle",
lock_blocking=True,
lock_ttl=None,
lock_blocking_timeout=None,
track_keys=False, # see below
)
# Or from a URL
backend = MemcachedBackend(
url="memcached://localhost:11211",
namespace=":memcached:",
)
Characteristics:
- Does not support multi-node setups -
host/port/urlalways point at a single Memcached server. - Locks are implemented using Memcached's atomic
addoperation (add only succeeds if the key does not exist), with a fencing token for ownership verification. - Locks are instance-bound, not task-reentrant the way Redis locks are.
- Memcached keys are limited to 250 bytes, keep your
namespaceshort.
memcached.emcache¶
from traffik.backends.memcached.emcache import MemcachedBackend
# Single node
backend = MemcachedBackend(
host="localhost",
port=11211,
namespace=":memcached:",
)
# Multiple nodes - traffic distributed via Rendezvous hashing
backend = MemcachedBackend(
nodes=[("memcached-1", 11211), ("memcached-2", 11211), "memcached-3:11211"],
max_connections=4,
min_connections=1,
namespace=":memcached:",
)
# TLS + auth
backend = MemcachedBackend(
host="memcached.internal",
port=11211,
ssl=True,
ssl_verify=True,
username="myapp",
password="secret",
namespace=":memcached:",
)
Characteristics:
nodestakes precedence overurl/host/portwhen provided; each element may be a(host, port)tuple, a"memcached://host:port"URL, or a"host:port"string.max_connections/min_connectionsandpurge_unused_connections_afterconfigure emcache's adaptive per-node connection pool;connection_timeoutbounds how long opening a new connection may take.autobatching=Truetransparently coalesces concurrentgetcalls into a singleget_manyround-trip - useful under high read concurrency, off by default.- Only supported on Linux and macOS (
emcacheships no Windows wheels); useaiomcacheif you need Windows support.
The track_keys limitation¶
Memcached has no equivalent of Redis SCAN, so Traffik cannot natively list all keys in a namespace. The clear() method, called during non-persistent context teardown, is therefore a no-op by default on both Memcached backends.
Enable track_keys=True to have Traffik maintain a side-car key(s) that records every key it sets:
backend = MemcachedBackend(
host="localhost",
port=11211,
namespace=":memcached:",
track_keys=True, # enables `clear()` at the cost of extra writes
number_of_tracking_shards=16, # spread tracking writes to reduce contention
)
track_keys=True adds a little overhead, per write, not per namespace
Enabling this adds one atomic append (or, on the very first write to a shard, an add) alongside every real write - no read-before-write involved, so it doesn't race under concurrent writes the way a naive get-modify-set tracking scheme would. Keys are spread across multiple tracking shards (number_of_tracking_shards, default 16) rather than one, so a busy namespace doesn't concentrate contention on a single tracking key either. Still not free, so only enable it if you genuinely need clear() to work on Memcached. An alternative if the Memcached instance is dedicated to Traffik, is to override clear() in a subclass to call flush_all() on the Memcached client instead.
Installing client dependencies¶
Redis and Memcached support are optional - install only the client(s) you need:
| Extra | Installs | Backend |
|---|---|---|
traffik[redis] | redis, pottery | traffik.backends.redis.aioredis |
traffik[aioredis] | redis, pottery | traffik.backends.redis.aioredis |
traffik[coredis] | coredis (Python 3.10+) | traffik.backends.redis.coredis |
traffik[memcached] | aiomcache | traffik.backends.memcached.aiomcache |
traffik[aiomcache] | aiomcache | traffik.backends.memcached.aiomcache |
traffik[emcache] | emcache (Linux/macOS only) | traffik.backends.memcached.emcache |
traffik[all] | All of the above | All backends |
traffik[redis] and traffik[memcached] are convenience aliases for the default client of each family (aioredis and aiomcache respectively) - use them if you don't have a specific reason to prefer the alternative client.
Backend lifecycle¶
Every backend needs to be started before use and closed when you are done. Traffik provides three patterns; pick the one that fits your framework.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from traffik.backends.redis.aioredis import RedisBackend
backend = RedisBackend("redis://localhost:6379", namespace="myapp")
@asynccontextmanager
async def lifespan(app: FastAPI):
async with backend.lifespan(app):
yield
app = FastAPI(lifespan=lifespan)
Without ASGI lifespan (scripts, tests, CLI tools)¶
When you are not running an ASGI application, for example in a standalone script, a CLI tool, or a test that doesn't need a full app, you can use the backend as an async context manager directly without passing an app:
from traffik.backends.inmemory import InMemoryBackend
backend = InMemoryBackend(namespace="myapp")
async def main():
async with backend():
# backend is ready; use throttles here
pass
This initialises the backend on entry and closes it (calling reset() if persistent=False) on exit. No ASGI app, no lifespan fixture required. This pattern is particularly useful for one-off scripts, data migration tools, or test helpers that need to exercise throttle logic without spinning up a full server.
Persistence¶
By default, persistent=False, Traffik calls reset() on the backend when the context exits. This wipes all throttle counters, which can be what you usually want between test runs and application restarts.
Set persistent=True to keep counter state alive across restarts:
backend = RedisBackend(
"redis://localhost:6379",
namespace="myapp",
persistent=True, # counters survive restarts
)
The on_error parameter¶
Every backend shares the same error-handling knob:
| Value | Behaviour on backend error |
|---|---|
"throttle" | Treat the request as if it exceeded the limit (safe default) |
"allow" | Let the request through (optimistic) |
"raise" | Propagate the exception to your exception handler |
callable | Call your function (connection, exc_info) -> wait_ms |