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 0000000..9b05a2f Binary files /dev/null and b/python-asyncio-fastapi/__pycache__/main.cpython-313.pyc differ 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