what i built
- auth — signup and login with bcrypt hashing and 30-minute jwt access tokens.
- per-user conversations — users, conversations and messages persisted through async sqlalchemy. every read checks that the conversation belongs to the caller.
- layered structure — routes call repositories, repositories own the queries, and the gemini and redis clients sit behind services.
- cache that can fail safely — normalized prompts cached for an hour. if redis is down, requests skip the cache and still succeed.
- shipped with ci — multi-stage dockerfile, plus a workflow that starts redis 7, runs pytest and builds the image on every push.
how it fits together
don't block the event loop
bcrypt is deliberately slow and cpu-bound. called directly inside an async def, it freezes every other request while it runs. so hashing and verification go to the thread pool, and the loop keeps serving others.
hashedpassword = await asyncio.to_thread(secure_pwd, request.password)
is_valid = await asyncio.to_thread(verify_pwd, request.password, user.hashedpassword)the same applies to the synchronous redis and gemini clients in /chat.
one client, built once
the gemini client is expensive to construct. it's created inside fastapi's lifespan hook, stored on app.state, and injected with a dependency. it's built once at startup, and a bad key fails the boot instead of the first user's request.
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
app.state.gemini = Geminiservice()
yieldredis is an optimisation, not a dependency
both the lookup and the write are wrapped separately. a lookup failure sets cached = None and falls through to gemini. a write failure is logged and ignored. redis being down makes responses slower, not wrong.
try:
normalized_key = normalize_key(request.message)
cached = await asyncio.to_thread(get_cached_response, normalized_key)
except Exception:
logger.warning("Redis unavailable")
cached = None
normalized_key = Nonemetrics
| metric | measured | how |
|---|---|---|
| throughput | 42.5 req/s | locust load against /chat |
| median latency | 110 ms | same run |
| success rate | 99.86% | same run |
| parameter | value |
|---|---|
| access token lifetime | 30 minutes |
| cache ttl | 1 hour, keyed on the normalized prompt |
| password hashing | bcrypt, off the event loop |
| ci | redis 7, pytest and an image build on every push |
the event loop survived. character development.