๐Ÿ”ฅ Hermes OpenAgent Dashboard

Docs > docs/analysis/gunicorn-evaluation.md

๐Ÿ“„ Gunicorn Evaluation โ€” Stage 3

Gunicorn Evaluation โ€” Stage 3

Date: 2026-08-17 Decision: NO-GO โ€” stay on Flask dev server (single process). Status: Tested with real gunicorn run, evidence below.

TL;DR

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.

Test Setup

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).

Test 1 โ€” SSE Streaming (PASS)

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).

Test 2 โ€” Rate Limiter Cross-Worker (FAIL)

_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.

Test 3 โ€” Operational Complexity

  • deploy.sh would need to manage multiple PIDs (master + workers) or use --pid flag on master only.
  • Rolling restart requires kill -HUP <master> semantics, not a simple PID kill.
  • Failure modes (worker crash vs master crash) require different handling.

None of these are worth solving for a single-user admin tool.

Why Flask Dev Server is Sufficient Here

  1. Single user โ€” this is a blog admin dashboard, not a public API. Concurrency demand โ‰ˆ 1.
  2. Bottleneck is LLM latency โ€” /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.
  3. Stage 3 boundary โ€” "็ฆๆญข: ๅผ• Redis / Celery (Stage 3 ่พน็•Œ, ๅ•่ฟ›็จ‹ๅ‡่ฎพ)". Gunicorn's only real benefit (multi-worker concurrency) requires fixing per-process state, which is out of scope.
  4. Existing watchdog state โ€” daily_watchdog.py runs as a separate cron process, also assumes single-owner DB access.

What Would Change My Mind

Re-evaluate gunicorn if any of these become true:

  • Blog becomes multi-user with concurrent writers โ†’ need real concurrency + real rate limiter (Redis).
  • LLM endpoint moves to a faster backend where Werkzeug thread startup becomes the bottleneck.
  • We adopt a process manager (systemd / supervisord) that already handles multi-process lifecycle โ€” then gunicorn's master model drops in naturally.

Action

  • 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 ่พน็•Œ).
  • Documented as the close-out of Stage 1's "ไธŠ gunicorn ๅ‰ๅ…ˆ้ชŒ่ฏๅฟ…่ฆๆ€ง" note.

โ† Back to Docs

โš™๏ธ Running Processes

Loadingโ€ฆ