Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions python-asyncio-fastapi/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file not shown.
5 changes: 5 additions & 0 deletions python-asyncio-fastapi/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
123 changes: 123 additions & 0 deletions python-asyncio-fastapi/main.py
Original file line number Diff line number Diff line change
@@ -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}
4 changes: 4 additions & 0 deletions python-asyncio-fastapi/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fastapi>=0.110
uvicorn>=0.29
growthbook>=2.4.0
redis>=5.0
Loading