Docs > docs/analysis/gunicorn-evaluation.md
Date: 2026-08-17 Decision: NO-GO โ stay on Flask dev server (single process). Status: Tested with real gunicorn run, evidence below.
Gunicorn multi-worker breaks the in-memory rate limiter. Fixing it requires Redis or file-based state, which Stage 3 explicitly forbids. The blog is a single-user admin tool whose bottleneck is LLM streaming latency (5s+), not request concurrency. Gunicorn adds ops complexity for zero measured benefit.
gunicorn.conf.py:
bind = "0.0.0.0:8090"
workers = 2
worker_class = "gthread"
threads = 4
timeout = 300
graceful_timeout = 30
keepalive = 5
Started via: gunicorn -c gunicorn.conf.py 'app:app' (1 master + 2 workers confirmed via pgrep).
curl -N "http://127.0.0.1:8090/api/analyze/stream?scope=home&preset=overview"
Result: SSE events (step, reasoning_start, token) stream correctly through gthread worker. No regression. Flask dev server also handles this fine (Stage 1+2 in production).
_rate_limit_store in routes/api.py is an in-process dict. With workers = 2, each worker has its own counter. Rapid 7-request burst:
req 1: HTTP 200
req 2: HTTP 200
req 3: HTTP 200
req 4: HTTP 200 <-- should have been 429 already (limit=3 per 60s)
req 5: HTTP 429
req 6: HTTP 429
req 7: HTTP 429
With 2 workers, the effective rate limit is 2 ร BLOG_APP_RATE_LIMIT_MAX = 6, not 3. Rate limiter is silently degraded.
--pid flag on master only.kill -HUP <master> semantics, not a simple PID kill.None of these are worth solving for a single-user admin tool.
/api/analyze/stream takes 5-30s waiting on LiteLLM. Flask's threaded mode handles the SSE stream acceptably; the user is waiting on the LLM, not on Werkzeug.daily_watchdog.py runs as a separate cron process, also assumes single-owner DB access.Re-evaluate gunicorn if any of these become true:
deploy.sh stays on /usr/bin/python3 app.py.gunicorn.conf.py left in repo for future re-evaluation.requirements.txt does NOT add gunicorn (Stage 3 ่พน็).Loadingโฆ