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
2 changes: 1 addition & 1 deletion examples/01-local-dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ database, so it is the fastest way to try the app or develop against it.
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "strands-compose-chat==0.1.5"
pip install "strands-compose-chat==0.1.6"
```

With the pip path, keep this virtual environment activated for the remaining
Expand Down
4 changes: 2 additions & 2 deletions examples/01-local-dev/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ description = "Local deployment of strands-compose-chat"
requires-python = ">=3.11"
dependencies = [
# Pin to the version you want to run.
"strands-compose-chat==0.1.5",
"strands-compose-chat==0.1.6",
# To use Postgres instead of the default SQLite, swap the line above for:
# "strands-compose-chat[postgres]==0.1.5",
# "strands-compose-chat[postgres]==0.1.6",
]

[tool.uv]
Expand Down
18 changes: 14 additions & 4 deletions examples/02-docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ WORKDIR /app

# Pin the version to deploy. Override at build time with:
# docker build --build-arg STRANDS_COMPOSE_CHAT_VERSION=X.Y.Z .
ARG STRANDS_COMPOSE_CHAT_VERSION=0.1.5
ARG STRANDS_COMPOSE_CHAT_VERSION=0.1.6

RUN uv venv "$VIRTUAL_ENV" && \
uv pip install "strands-compose-chat[postgres]==${STRANDS_COMPOSE_CHAT_VERSION}"
Expand All @@ -33,11 +33,21 @@ RUN groupadd --gid 1001 app && \
WORKDIR /app

COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
ENV PATH="/app/.venv/bin:$PATH" \
HOME="/tmp" \
TMPDIR="/tmp"

# Gunicorn process-manager config (worker recycling, timeouts, forwarded IPs).
COPY gunicorn.conf.py /app/gunicorn.conf.py

USER app

EXPOSE 8000

# Apply migrations, then start the server bound to all interfaces in the container.
CMD ["sh", "-c", "strands-compose-chat migrate && strands-compose-chat serve --host 0.0.0.0 --port 8000"]
# Apply migrations, then start under gunicorn. Gunicorn is the process manager:
# `exec` makes it PID 1 so it receives SIGTERM directly for a clean drain,
# and it recycles the worker to bound memory growth without killing the container.
CMD ["sh", "-c", "\
strands-compose-chat migrate && \
exec gunicorn -c /app/gunicorn.conf.py strands_compose_chat.app:app \
"]
31 changes: 30 additions & 1 deletion examples/02-docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,39 @@ docker compose down -v # stop and delete the database volume

## Notes

- **Pin the version.** The image installs `strands-compose-chat==0.1.5`. Change
- **Why gunicorn, not `serve`.**

CLI command `serve` is a single unmanaged uvicorn process meant for local
development — if it exits (crash, or hitting a request limit) the container
dies and the health check fails.

In production, we recommend **Gunicorn** as process manager: it recycles
its worker after `max_requests` to bound memory growth from slow leaks,
and replaces a crashed worker without terminating the container. This matters
on small tasks (e.g. 0.25 vCPU / 512 MB) where an unbounded process gets OOM-killed.
Tune it with the `GUNICORN_*` / `WEB_CONCURRENCY` environment variables —
see `gunicorn.conf.py`
- **Pin the version.** The image installs `strands-compose-chat==0.1.6`. Change
it by editing the `STRANDS_COMPOSE_CHAT_VERSION` build arg in `Dockerfile`,
or pass `--build-arg STRANDS_COMPOSE_CHAT_VERSION=X.Y.Z` to `docker build`.
- **Database data** persists in the `pgdata` volume across restarts.
- **Read-only root filesystem?** If you lock the container's root filesystem
(e.g. AWS ECS `readonlyRootFilesystem: true`), mount **one writable volume at
`/tmp`** — that is the only writable path the app needs. Gunicorn's control
socket (`HOME`) and file-upload spillover (`TMPDIR`) are both pinned to `/tmp`
in the `Dockerfile`. FastAPI buffers uploads in memory up to 1 MB and rolls
larger attachments over to `TMPDIR`, so without a writable `/tmp` uploads over
1 MB fail. On Fargate use a task `volumes` entry mounted at `/tmp` (backed by
task ephemeral storage); `linuxParameters.tmpfs` is EC2-only. No broader
filesystem access is required.

```json
"containerDefinitions": [{
"readonlyRootFilesystem": true,
"mountPoints": [{ "sourceVolume": "tmp", "containerPath": "/tmp" }]
}],
"volumes": [{ "name": "tmp" }]
```
- **Deploying to a real server?** Set `APP_ENV=prod`, set `TRUSTED_HOSTS`,
`CORS_ALLOWED_ORIGINS`, and (if using SSO) `OIDC_REDIRECT_URI` to your public
domain, and serve over HTTPS behind a reverse proxy. In `prod` the session
Expand Down
39 changes: 39 additions & 0 deletions examples/02-docker/gunicorn.conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Gunicorn configuration for running strands-compose-chat in production.

Gunicorn acts as the process manager: it keeps the container alive and recycles
its worker on a request count, so a slow memory leak never grows unbounded and a
crashed worker is replaced without terminating the container (which would trip
the ECS/ALB health check and kill the task).

Every value can be overridden with an environment variable, so the same image
tunes to different CPU/memory budgets without a rebuild. Defaults target a small
container (e.g. 0.25 vCPU / 512 MB).
"""

import os

# Bind to all interfaces inside the container.
bind = os.getenv("GUNICORN_BIND", "0.0.0.0:8000")

# One async uvicorn worker per process. A single ASGI worker already handles
# concurrent requests; scale with WEB_CONCURRENCY only when you have the CPU.
workers = int(os.getenv("WEB_CONCURRENCY", "1"))
worker_class = "uvicorn_worker.UvicornWorker"

# Recycle a worker after this many requests
# (+ jitter so workers don't all recycle at once).
max_requests = int(os.getenv("GUNICORN_MAX_REQUESTS", "1000"))
max_requests_jitter = int(os.getenv("GUNICORN_MAX_REQUESTS_JITTER", "100"))

# High worker timeout so the silence watchdog never fires on a slow model
# time-to-first-token or a long-lived SSE stream.
timeout = int(os.getenv("GUNICORN_TIMEOUT", "120"))
graceful_timeout = int(os.getenv("GUNICORN_GRACEFUL_TIMEOUT", "30"))
keepalive = int(os.getenv("GUNICORN_KEEPALIVE", "30"))

# Behind a reverse proxy / load balancer: trust forwarded headers.
forwarded_allow_ips = os.getenv("FORWARDED_ALLOW_IPS", "*")

# Log to stdout/stderr for container log collection.
accesslog = os.getenv("GUNICORN_ACCESSLOG", "-")
errorlog = os.getenv("GUNICORN_ERRORLOG", "-")
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,18 @@ dependencies = [
"argon2-cffi>=25.1",
"authlib>=1.3,<2.0",
"fastapi>=0.138,<1.0",
"gunicorn>=26.0,<27.0",
"httpx>=0.27",
"itsdangerous>=2.2,<3.0",
"markupsafe>=2.0",
"pydantic[email]>=2.13,<3.0",
"pydantic-settings>=2.14,<3.0",
"sqlalchemy[asyncio]>=2.0,<3.0",
"sqladmin>=0.27,<0.29",
"sqladmin>=0.28,<0.29",
"strands-compose-agentcore>=0.9.0,<1.0.0",
"structlog>=26.1",
"uvicorn>=0.49,<1.0",
"uvicorn-worker>=0.4,<1.0",
"wtforms>=3.1,<3.3",
]

Expand Down
15 changes: 10 additions & 5 deletions src/strands_compose_chat/admin/views/base.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Shared base view and common helpers for all admin ModelViews."""

from datetime import datetime
from typing import Any, Tuple
from typing import Any

from markupsafe import Markup
from sqladmin import ModelView
from starlette.requests import Request


def _format_datetime(value: datetime) -> Any:
Expand Down Expand Up @@ -67,17 +68,21 @@ def __init__(self) -> None:
# Rebuild the reverse lookup that sqladmin maintains alongside the forward map.
self._column_labels_value_by_key = {v: k for k, v in self._column_labels.items()}

async def get_list_value(self, obj: Any, prop: str) -> Tuple[Any, Any]:
async def get_list_value(
self, obj: Any, prop: str, request: Request | None = None
) -> tuple[Any, Any]:
"""Return badge list as formatted_value for declared relation props."""
value, formatted_value = await super().get_list_value(obj, prop)
value, formatted_value = await super().get_list_value(obj, prop, request)
if prop in self._badge_relation_props and isinstance(value, list):
color = self._badge_relation_props[prop]
formatted_value = _relation_badge_list(value, color)
return value, formatted_value

async def get_detail_value(self, obj: Any, prop: str) -> Tuple[Any, Any]:
async def get_detail_value(
self, obj: Any, prop: str, request: Request | None = None
) -> tuple[Any, Any]:
"""Return badge list as formatted_value for declared relation props."""
value, formatted_value = await super().get_detail_value(obj, prop)
value, formatted_value = await super().get_detail_value(obj, prop, request)
if prop in self._badge_relation_props and isinstance(value, list):
color = self._badge_relation_props[prop]
formatted_value = _relation_badge_list(value, color)
Expand Down
8 changes: 5 additions & 3 deletions src/strands_compose_chat/admin/views/chat_session.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Admin view for the ChatSession model."""

import json
from typing import Any, Tuple
from typing import Any

from fastapi import Request
from markupsafe import Markup
Expand Down Expand Up @@ -136,7 +136,9 @@ def details_query(self, request: Request) -> Any:
"""Eagerly load token_usage to avoid DetachedInstanceError."""
return super().details_query(request).options(selectinload(ChatSession.token_usage))

async def get_detail_value(self, obj: Any, prop: str) -> Tuple[Any, Any]:
async def get_detail_value(
self, obj: Any, prop: str, request: Request | None = None
) -> tuple[Any, Any]:
"""Render token_usage as a summarised total string."""
if prop == "token_usage":
try:
Expand All @@ -145,4 +147,4 @@ async def get_detail_value(self, obj: Any, prop: str) -> Tuple[Any, Any]:
rows = []
value = _summarise_token_usage(rows) if rows else "—"
return value, value
return await super().get_detail_value(obj, prop)
return await super().get_detail_value(obj, prop, request)
20 changes: 14 additions & 6 deletions src/strands_compose_chat/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
Exposes the ``strands-compose-chat`` console script with two subcommands:

- ``migrate`` — apply all pending database migrations (``alembic upgrade head``).
- ``serve`` — run the ASGI application with uvicorn.
- ``serve`` — run the ASGI application under plain uvicorn (single process).

``serve`` is a convenience runner aimed at local development. It has no worker
recycling and no memory bounding, which is fine for dev but not for a
long-lived production container — for that, run under gunicorn with a
process manager (see the docker example's ``gunicorn.conf.py``).

The commands are intentionally separate and composable: a container or init
job runs ``migrate`` once, then ``serve`` starts the web process.
job runs ``migrate`` once, then ``serve`` (or gunicorn) starts the web process.
"""

import argparse
Expand All @@ -21,6 +26,7 @@

_DEFAULT_HOST = "127.0.0.1"
_DEFAULT_PORT = 8000
_KEEPALIVE = 30


def _alembic_config() -> Config:
Expand All @@ -34,7 +40,11 @@ def migrate() -> None:


def serve(host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> None:
"""Run the web server.
"""Run the web server under plain uvicorn (single process).

Intended for local development. For production, run under gunicorn with a
recycling uvicorn worker so memory growth is bounded and a crashed worker
is replaced without killing the container.

Args:
host: Interface to bind (default: loopback; use ``0.0.0.0`` in containers).
Expand All @@ -46,11 +56,9 @@ def serve(host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> None:
"strands_compose_chat.app:app",
host=host,
port=port,
timeout_keep_alive=30,
timeout_keep_alive=_KEEPALIVE,
proxy_headers=True,
forwarded_allow_ips="*",
limit_max_requests=500,
limit_max_requests_jitter=50,
)


Expand Down
Loading