Routes, services, error handling, async patterns. Load when working on app/routes/*, app/services/*, or API endpoints.
async/awaithttpx.AsyncClient — Not requestsUse pure ASGI middleware only (class with __init__(self, app) and async def __call__(self, scope, receive, send)). Do not use starlette.middleware.base.BaseHTTPMiddleware: its call_next() wraps the response body in an internal task group, which breaks AsyncSession lifecycle in FastAPI dependency-injected routes (e.g. AI Insights "Failed to load insights"). When adding or changing middleware, follow the pattern in PerformanceMiddleware or ErrorHandlingMiddleware in .
app/main.pyServices raise domain exceptions; routes convert to HTTP responses:
| Exception | HTTP | When |
|---|---|---|
NotFoundError | 404 | Resource doesn't exist or user can't access |
ValidationError | 422 | Invalid input or business rule violation |
AuthorizationError | 403 | User lacks permission |
ExternalServiceError | 502 | Google Calendar API, Twilio, OpenAI failure |
ConflictError | 409 | Duplicate resource or state conflict |
Rules:
except SomeError: pass)db.commit()db.flush() — When they need data visible within same transactiondb.commit() in servicesThe "no db.commit() in services" rule applies to request-scoped services where the FastAPI route handler owns the transaction. Webhook handlers (Twilio inbound, DocuSeal callbacks, Stripe webhooks) ARE the route handler — they own their own session via async with AsyncSessionLocal() and MUST await db.commit() (and db.rollback() on error) before returning. The service called from inside still uses flush() only.
@router.post("/webhooks/twilio/inbound")
async def twilio_inbound(request: Request, ...):
if not _validate_twilio_signature(request, params):
return Response(status_code=403)
async with AsyncSessionLocal() as db:
try:
reply = await get_concierge_service(db).process_inbound_sms(...)
await db.commit()
except Exception:
await db.rollback()
raise
return Response(content=twiml(reply), media_type="application/xml")
See MEMORY pattern_webhook_handler_owns_session.md.
Route URLs, handler names, and template filenames do not always match:
| URL | Route File | Handler | Template |
|---|---|---|---|
/home | landing.py | home_page | dashboard.html |
/workflows/builder | workflows.py | workflow_builder_page | workflows/builder.html |
/workflows/builder/{id} | workflows.py | workflow_builder_edit_page | workflows/builder.html |
Builder context variables: Both builder routes must pass template_state (defaults to "draft") for the page header status badge. When adding new template context data to builder routes, update BOTH the new-workflow and edit-workflow handlers.
Always check the route handler before editing templates:
grep -r '"/home"' app/routes/
Services receive structured input and return structured output — never raw primitives for complex operations:
# GOOD - RORO with Pydantic models
class CreateWorkflowRequest(BaseModel):
name: str
event_type: LandlordEventType
steps: list[StepCreate] = []
class WorkflowResponse(BaseModel):
model_config = {"from_attributes": True}
id: UUID
name: str
status: WorkflowState
async def create_workflow(
db: AsyncSession,
user_id: UUID,
request: CreateWorkflowRequest,
) -> WorkflowResponse:
template = WorkflowTemplate(user_id=user_id, **request.model_dump())
db.add(template)
await db.flush()
return WorkflowResponse.model_validate(template)
# BAD - scattered primitives
async def create_workflow(db, user_id, name, event_type, steps=None):
... # Easy to mix up argument order, no validation
When to use RORO:
| Tier | Endpoints |
|---|---|
| Strict | Auth endpoints |
| Moderate | AI endpoints |
| Standard | CRUD operations |
| Relaxed | Read-only endpoints |
CI/CD scripts use async httpx for webhook notifications:
async def send_webhook_notification(
webhook_url: str,
payload: Dict[str, Any],
timeout: int = 10,
) -> bool:
async with httpx.AsyncClient(timeout=timeout) as client:
try:
response = await client.post(webhook_url, json=payload)
return response.status_code < 400
except httpx.HTTPError:
return False # Best-effort, don't fail deployment
Key points:
httpx.AsyncClient (not requests)