Coming from APScheduler, slowapi or aioredlock
APScheduler, slowapi and aioredlock each solve one problem well. Two of them change behavior once the service runs as more than one replica. This page shows what you have today, what changes on several replicas, the grelmicro code that replaces it, and when to keep the library you have.
APScheduler
This section covers APScheduler 3, the current stable release.
What you have
A scheduler started in the FastAPI lifespan, with one interval job and one cron job:
from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI
scheduler = AsyncIOScheduler(timezone="Europe/Zurich")
async def cleanup():
...
async def nightly_report():
...
scheduler.add_job(cleanup, "interval", minutes=1)
scheduler.add_job(nightly_report, "cron", hour=2)
@asynccontextmanager
async def lifespan(app):
scheduler.start()
yield
scheduler.shutdown()
app = FastAPI(lifespan=lifespan)
On more than one replica
Every replica starts its own scheduler, so three replicas run each job three times. A shared job store does not fix it: the APScheduler 3 docs say a job store must never be shared between schedulers. APScheduler 4, still in alpha, adds data stores that several schedulers share.
With grelmicro
gate="claim" runs each interval and each cron fire on one replica, whichever claims it first:
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.providers.redis import RedisProvider
from grelmicro.task import Tasks
tasks = Tasks(timezone="Europe/Zurich")
redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(uses=[redis, tasks])
@tasks.every(interval=60, gate="claim")
async def cleanup() -> None:
print("One replica cleans up each minute")
@tasks.cron("0 2 * * *", gate="claim")
async def nightly_report() -> None:
print("One replica builds the report at 02:00")
app = FastAPI()
micro.install(app)
The claims live in Redis. PostgreSQL and SQLite work the same way, see Coordination.
Mapping
| APScheduler | grelmicro |
|---|---|
AsyncIOScheduler() |
Tasks() |
scheduler.start(), scheduler.shutdown() |
micro.install(app), or async with micro: |
add_job(func, "interval", minutes=1) |
@tasks.every(interval=60) |
add_job(func, "cron", hour=2) |
@tasks.cron("0 2 * * *") |
CronTrigger.from_crontab("0 2 * * *") |
@tasks.cron("0 2 * * *") |
AsyncIOScheduler(timezone="Europe/Zurich") |
Tasks(timezone="Europe/Zurich") |
max_instances=1 |
Always. The next interval starts once the run ends |
misfire_grace_time=600 |
@tasks.cron(..., gate="claim", misfire_grace=600) |
coalesce=True |
Always for a claimed cron task: only the most recent missed fire runs |
| One run per replica | gate="claim", or gate=leader with a LeaderElection |
"date" trigger |
None |
| A persistent job store | None. Tasks are declared in code |
An APScheduler interval counts from the start of one run to the start of the next. A grelmicro interval counts from the end of one run to the start of the next. See Task Scheduler.
When to keep APScheduler
Keep APScheduler for one process, for one-off date jobs, or for jobs added at runtime and kept in a persistent job store.
slowapi
What you have
A limit of five calls a minute on one route:
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.get("/home")
@limiter.limit("5/minute")
async def homepage(request: Request):
return PlainTextResponse("test")
On more than one replica
Without storage_uri, slowapi counts in memory, so each replica keeps its own count and three replicas allow up to fifteen calls a minute. Behind a proxy, get_remote_address reads the proxy's address, so every caller shares one budget.
With grelmicro
RateLimited meters the route, counts in Redis, and reads the caller from the proxies you trust:
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.http import ErrorResponses
from grelmicro.integrations.fastapi import RateLimited
from grelmicro.providers.redis import RedisProvider
from grelmicro.resilience import RateLimiter
from grelmicro.security import TrustedProxies
redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(uses=[redis, ErrorResponses()])
app = FastAPI()
micro.install(app)
home = RateLimiter.sliding_window("home", limit=5, window=60)
proxies = TrustedProxies(["10.0.0.0/8"])
@app.get("/home", dependencies=[RateLimited(home, trusted=proxies)])
async def homepage() -> dict[str, str]:
return {"message": "test"}
The sixth call in a minute gets a 429 with Retry-After, RateLimit and RateLimit-Policy headers. See Rate limit.
Mapping
| slowapi | grelmicro |
|---|---|
@limiter.limit("5/minute") |
dependencies=[RateLimited(RateLimiter.sliding_window("home", limit=5, window=60))] |
Limiter(default_limits=["100/minute"]) |
RateLimitedRequests(limiter, trusted=...) for every route |
Limiter(storage_uri="redis://...") |
RedisProvider(...) in Grelmicro(uses=[...]) |
key_func=get_remote_address |
trusted=TrustedProxies([...]) |
A custom key_func |
key= |
strategy="fixed-window" |
RateLimiter.sliding_window(...) or RateLimiter.token_bucket(...) |
app.add_exception_handler(RateLimitExceeded, ...) |
ErrorResponses() answers 429 as a problem detail |
headers_enabled=True |
Always on. legacy_headers=True adds X-RateLimit-* |
The request: Request parameter |
Not needed |
When to keep slowapi
Keep slowapi for one process, or when the counts must live in Memcached or MongoDB, which grelmicro does not support.
aioredlock
What you have
A lock around one order, held for at most ten seconds:
from aioredlock import Aioredlock, LockError
lock_manager = Aioredlock(["redis://localhost:6379/0"])
async def charge(order_id: str) -> None:
try:
async with await lock_manager.lock(f"order:{order_id}", lock_timeout=10):
...
except LockError:
...
On more than one replica
aioredlock already works across replicas. It locks on Redis only, and its last release, 0.7.3, dates from February 2022.
With grelmicro
Lock holds the same lease on Redis, PostgreSQL, SQLite or Kubernetes:
import asyncio
from grelmicro import Grelmicro
from grelmicro.coordination import Lock
from grelmicro.providers.redis import RedisProvider
redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(uses=[redis])
async def charge(order_id: str) -> None:
lock = Lock(f"order:{order_id}", lease_duration=10)
# Wait up to 5 seconds, then raise LockTimeoutError.
async with lock.hold(timeout=5) as held:
print(f"Charging order {order_id}, fencing token {held.fencing_token}")
async def main() -> None:
async with micro:
await charge("42")
if __name__ == "__main__":
asyncio.run(main())
Every hold carries a fencing token, so the resource you write to can refuse a holder whose lease ran out. See Lock.
Mapping
| aioredlock | grelmicro |
|---|---|
Aioredlock([...]) |
RedisProvider(...) in Grelmicro(uses=[...]) |
lock_manager.lock("r", lock_timeout=10) |
Lock("r", lease_duration=10) |
async with await lock_manager.lock("r"): |
async with Lock("r"): |
retry_count, retry_delay_min, retry_delay_max |
hold(timeout=5) bounds the wait, retry_interval and retry_jitter pace it |
LockError |
LockTimeoutError from hold(timeout=...) or acquire(timeout=...) |
await lock_manager.extend(lock) |
await lock.extend() |
await lock_manager.unlock(lock) |
await lock.release() |
await lock_manager.is_locked("r") |
await lock.locked() |
await lock_manager.destroy() |
Closed with the app, by async with micro: or micro.install(app) |
| None | held.fencing_token |
When to keep aioredlock
Keep aioredlock when you want the Redlock algorithm over several independent Redis servers. A grelmicro lock uses one.
Others
- pybreaker: a breaker keeps its state in one process by default. A grelmicro
CircuitBreakerkeeps one state for every replica in Redis or PostgreSQL. See Circuit Breaker. - More libraries side by side, tenacity and aiocache among them, are on the Comparison page.