Storage Backends¶
All three backends implement one interface and are selected with a connection string. If you haven't picked yet, start at Which storage backend?.
Memory¶
The default. A dict guarded by an RLock, with expired rate limits swept out at most once an hour.
Two RateLimiter() instances in the same process do not share memory storage โ each constructs its own. If you want two limiters (different header maps, say) to share state, give them a SQLite or Redis URL.
SQLite¶
Two tables are created on first use: rate_limits (quota, reset time, window per endpoint) and token_buckets (capacity, level, refill rate per bucket key). Timestamps are stored as ISO strings.
Path forms¶
"sqlite:///ratelimit.db" # relative to the working directory
"sqlite:///data/ratelimit.db" # relative subdirectory
"sqlite:////var/lib/app/limits.db" # absolute path โ note four slashes
"sqlite:///:memory:" # in-process database, nothing on disk
The directory must already exist; SQLite creates the file, not the folder.
Reading it from elsewhere¶
The file is an ordinary SQLite database, so anything can read it โ a monitoring script, sqlite3, or the bundled CLI:
That is the main reason to prefer SQLite over memory even for a single process: your job's quota becomes observable from outside the job.
Sharing between processes¶
Several processes on one machine can open the same file and will genuinely share the quota:
# worker.py โ run this as many times as you like
from smartratelimit import RateLimiter
limiter = RateLimiter(storage="sqlite:///shared.db")
limiter.request("GET", "https://api.example.com/data")
Writes are serialised by SQLite's own locking, but reads and writes are not one transaction โ two workers can read the same bucket level before either writes back. Sharing is approximate under high concurrency. For a handful of workers doing paced work it holds up well; for dozens, use Redis.
Redis¶
State lives in Redis hashes under the ratelimit: key prefix:
Keys expire on their own โ rate limits after their window plus an hour, buckets after 24 hours โ so nothing accumulates.
Connection strings¶
"redis://localhost:6379/0" # default
"redis://:password@localhost:6379/0" # password only
"redis://user:password@redis.internal:6379/1" # ACL user, database 1
"rediss://user:password@redis.example.com:6380/0" # TLS
Any URL redis-py's from_url() understands works, as long as it starts with redis:// โ note that a rediss:// URL will not match the redis:// prefix check in the constructor and raises ValueError. For TLS, build the backend yourself:
from smartratelimit import RateLimiter
from smartratelimit.storage import RedisStorage
limiter = RateLimiter()
limiter._storage = RedisStorage("rediss://user:pass@redis.example.com:6380/0")
Custom key prefix¶
Useful when several applications share one Redis database:
from smartratelimit.storage import RedisStorage
limiter = RateLimiter()
limiter._storage = RedisStorage(
redis_url="redis://localhost:6379/0",
key_prefix="billing-worker:ratelimit:",
)
storage= takes a string, not an object
The constructor parses a connection string; it does not accept a backend
instance. Assigning to limiter._storage after construction is the current
way to install a pre-built backend, and _storage is private โ it may
change between releases. For the three standard backends, prefer the string.
Verifying the connection¶
Construction falls back to memory on failure rather than raising, so check explicitly if shared state matters:
from smartratelimit.storage import RedisStorage
try:
RedisStorage("redis://localhost:6379/0").redis_client.ping()
print("โ Redis reachable โ limits are shared")
except Exception as e:
raise SystemExit(f"โ Redis unavailable: {e}")
Redis operations at runtime also degrade quietly: a read that fails returns None (treated as "no limit known") and a write that fails is dropped. The job keeps going; the pacing gets less accurate.
Writing your own backend¶
Subclass StorageBackend and implement five methods:
from typing import Optional
from smartratelimit.storage import StorageBackend
from smartratelimit.models import RateLimit, TokenBucket
class DynamoStorage(StorageBackend):
def get_rate_limit(self, endpoint: str) -> Optional[RateLimit]: ...
def set_rate_limit(self, endpoint: str, rate_limit: RateLimit) -> None: ...
def get_token_bucket(self, key: str) -> Optional[TokenBucket]: ...
def set_token_bucket(self, key: str, bucket: TokenBucket) -> None: ...
def clear(self, endpoint: Optional[str] = None) -> None: ...
Contract notes:
endpointkeys arescheme://host; bucket keys arescheme://host:default- Getters return
Nonewhen nothing is stored โ never raise for a miss clear(endpoint)must also remove that endpoint's bucket keys;clear(None)removes everything- Assume concurrent calls from multiple threads
Then attach it:
Clearing state¶
limiter.clear("api.github.com") # one endpoint, quota and bucket
limiter.clear() # everything in this backend
Or from the shell, against the same backend:
smartratelimit --storage "sqlite:///ratelimit.db" clear "https://api.github.com"
smartratelimit --storage "sqlite:///ratelimit.db" clear
Clearing makes the limiter forget a quota entirely โ the next request goes out unpaced and re-learns from the response headers. That is usually what you want after changing an API key or plan tier.