From 19d0535fc68eb4993459717cfb77cb560c00084d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 11 Aug 2026 12:53:04 -0700 Subject: [PATCH] add python-asyncio-fastapi example with async Redis sticky bucketing --- python-asyncio-fastapi/README.md | 50 +++++++ .../__pycache__/main.cpython-313.pyc | Bin 0 -> 7483 bytes python-asyncio-fastapi/docker-compose.yml | 5 + python-asyncio-fastapi/main.py | 123 ++++++++++++++++++ python-asyncio-fastapi/requirements.txt | 4 + 5 files changed, 182 insertions(+) create mode 100644 python-asyncio-fastapi/README.md create mode 100644 python-asyncio-fastapi/__pycache__/main.cpython-313.pyc create mode 100644 python-asyncio-fastapi/docker-compose.yml create mode 100644 python-asyncio-fastapi/main.py create mode 100644 python-asyncio-fastapi/requirements.txt diff --git a/python-asyncio-fastapi/README.md b/python-asyncio-fastapi/README.md new file mode 100644 index 0000000..17ea223 --- /dev/null +++ b/python-asyncio-fastapi/README.md @@ -0,0 +1,50 @@ +# GrowthBook Python SDK — asyncio / FastAPI example + +A minimal FastAPI service showing the async-native GrowthBook integration +pattern for high-concurrency Python services: + +- **One process-wide `GrowthBookClient`**, created and closed by FastAPI's + lifespan hook. Never create a client per request. +- **Async Redis sticky bucket service** (`AbstractAsyncStickyBucketService`, + growthbook >= 2.4.0) — sticky bucket reads and writes never block the + event loop. `get_all_assignments` is overridden with one batched `MGET`. +- **Per-request `UserContext`** — the client holds no user state, so one + instance serves every request concurrently. + +## Run it + +```bash +pip install -r requirements.txt + +# Optional but recommended: real Redis for sticky bucketing +docker compose up -d redis +export REDIS_URL=redis://localhost:6379/0 + +# Point at your GrowthBook instance +export GB_API_HOST=https://cdn.growthbook.io +export GB_CLIENT_KEY=sdk-your-key + +uvicorn main:app --reload +``` + +Without `REDIS_URL` the example falls back to an in-process store so it runs +out of the box (not for production — assignments are lost on restart and not +shared across workers). + +```bash +curl 'localhost:8000/checkout?user_id=user-123' +curl localhost:8000/healthz +``` + +Create a feature named `checkout-experiment` (an experiment rule with sticky +bucketing enabled) and a flag `new-checkout-flow` in GrowthBook to see real +variations; unknown features fall back to their defaults. + +## Why the async service matters + +With a sync sticky bucket service, every network round-trip to your +assignment store runs on (or is offloaded from) the event loop. The async +interface lets the SDK await your store natively: reads are prefetched per +evaluation, writes are fire-and-forget and drained on `close()`. See the SDK +benchmark (`tests/scripts/benchmark_async_client.py` in growthbook-python) +for the difference under load. diff --git a/python-asyncio-fastapi/__pycache__/main.cpython-313.pyc b/python-asyncio-fastapi/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b05a2f66d1a599b5bf375c8c778fe44c399e69c GIT binary patch literal 7483 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 K^RgO)$NvIGa{5~U literal 0 HcmV?d00001 diff --git a/python-asyncio-fastapi/docker-compose.yml b/python-asyncio-fastapi/docker-compose.yml new file mode 100644 index 0000000..0206560 --- /dev/null +++ b/python-asyncio-fastapi/docker-compose.yml @@ -0,0 +1,5 @@ +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" diff --git a/python-asyncio-fastapi/main.py b/python-asyncio-fastapi/main.py new file mode 100644 index 0000000..67e4565 --- /dev/null +++ b/python-asyncio-fastapi/main.py @@ -0,0 +1,123 @@ +"""GrowthBook Python SDK — asyncio/FastAPI example. + +Demonstrates the async-native integration pattern: + +- one process-wide GrowthBookClient, started and stopped by FastAPI's + lifespan hook (never create a client per request) +- an async, Redis-backed sticky bucket service (non-blocking network I/O + on the event loop) with a batched get_all_assignments +- per-request UserContext — the client itself holds no user state + +Requires growthbook >= 2.4.0 (AbstractAsyncStickyBucketService). +Set REDIS_URL to enable Redis sticky bucketing; without it the example +falls back to an in-process async store so you can run it immediately. +""" +import os +from contextlib import asynccontextmanager +from typing import Dict, Optional + +from fastapi import FastAPI +from growthbook import AbstractAsyncStickyBucketService +from growthbook.common_types import Options, UserContext +from growthbook.growthbook_client import GrowthBookClient + +GB_API_HOST = os.environ.get("GB_API_HOST", "https://cdn.growthbook.io") +GB_CLIENT_KEY = os.environ.get("GB_CLIENT_KEY", "sdk-abc123") +REDIS_URL = os.environ.get("REDIS_URL") # e.g. redis://localhost:6379/0 + + +class RedisStickyBucketService(AbstractAsyncStickyBucketService): + """Sticky bucket assignments in Redis, fully non-blocking. + + get_all_assignments is overridden with a single MGET so one experiment + evaluation costs one Redis round-trip regardless of how many identifier + attributes are configured. + """ + + def __init__(self, redis_client): + self.redis = redis_client + + async def get_assignments(self, attributeName: str, attributeValue: str) -> Optional[Dict]: + import json + raw = await self.redis.get(self.get_key(attributeName, attributeValue)) + return json.loads(raw) if raw else None + + async def get_all_assignments(self, attributes: Dict[str, str]) -> Dict[str, Dict]: + import json + keys = [self.get_key(n, v) for n, v in attributes.items()] + docs = {} + for key, raw in zip(keys, await self.redis.mget(keys)): + if raw: + docs[key] = json.loads(raw) + return docs + + async def save_assignments(self, doc: Dict) -> None: + import json + key = self.get_key(doc["attributeName"], doc["attributeValue"]) + await self.redis.set(key, json.dumps(doc)) + + +class InProcessStickyBucketService(AbstractAsyncStickyBucketService): + """Fallback so the example runs without Redis. Do not use in production: + assignments vanish on restart and are not shared between workers.""" + + def __init__(self): + self.docs: Dict[str, Dict] = {} + + async def get_assignments(self, attributeName: str, attributeValue: str) -> Optional[Dict]: + return self.docs.get(self.get_key(attributeName, attributeValue)) + + async def save_assignments(self, doc: Dict) -> None: + self.docs[self.get_key(doc["attributeName"], doc["attributeValue"])] = doc + + +@asynccontextmanager +async def lifespan(app: FastAPI): + if REDIS_URL: + import redis.asyncio as aioredis + redis_client = aioredis.from_url(REDIS_URL) + sticky = RedisStickyBucketService(redis_client) + else: + redis_client = None + sticky = InProcessStickyBucketService() + + client = GrowthBookClient(Options( + api_host=GB_API_HOST, + client_key=GB_CLIENT_KEY, + sticky_bucket_service=sticky, + )) + await client.initialize() + app.state.growthbook = client + + yield + + # Drains in-flight sticky bucket writes, stops feature refresh. + await client.close() + if redis_client is not None: + await redis_client.aclose() + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/checkout") +async def checkout(user_id: str, country: str = "US"): + """Evaluate an experiment feature for this user. + + The sticky bucket read is prefetched without blocking the event loop; + a new assignment is persisted to Redis fire-and-forget. + """ + gb: GrowthBookClient = app.state.growthbook + user = UserContext(attributes={"id": user_id, "country": country}) + + variant = await gb.get_feature_value("checkout-experiment", "control", user) + new_flow = await gb.is_on("new-checkout-flow", user) + + return {"user_id": user_id, "variant": variant, "new_checkout_flow": new_flow} + + +@app.get("/healthz") +async def healthz(): + """Liveness probe — stays responsive even while sticky bucket I/O is in + flight, because nothing in the SDK blocks the event loop.""" + return {"ok": True} diff --git a/python-asyncio-fastapi/requirements.txt b/python-asyncio-fastapi/requirements.txt new file mode 100644 index 0000000..01c0b6d --- /dev/null +++ b/python-asyncio-fastapi/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.110 +uvicorn>=0.29 +growthbook>=2.4.0 +redis>=5.0