endaaman.com

2025-11-28

Tips

SSEを繋いでるとuvicornがreloadされない

SSEコネクションが切れるのを待ってしまい、ブラウザリロードしないとサーバーもリロードされない問題

問題

FastAPIなどでSSEを使ってuvicornのreloadで開発しているとき、ブラウザを開いてSSEを繋いだ状態でサーバーのコードを変更すると、

WARNING:  WatchFiles detected changes in 'main.py'. Reloading...
INFO:     Shutting down
INFO:     Waiting for connections to close. (CTRL+C to force quit)

というログのまま動かなくなる。

「サーバーは再起動しようとしてる」でも「SSE繋がってるからこれが切れるまで待つ」でブラウザからの接続が切れるまで待ってしまう。

ブラウザを再起動したらSSEのコネクションが切れてサーバーは再起動されるが、開発体験が良くない。

対処方

timeout_graceful_shutdown=1 みたいに待機時間を決めるとよい。

uvicorn.run(
	"main:app",
	port=3000,
	reload=True,
	workers=1,
	timeout_graceful_shutdown=1,  # SSE connections will be force-closed after 1s
)

これでサーバーのコード変更でタイムアウト時間だけ待ってからサーバー側から接続を切ってくれる。

ただしこのままだと以下のようないかついエラーログが出る。

[2025-11-28 10:54:34] INFO     Shutting down Vision Backend...                                                                     lifecycle.py:93
ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "<PROJECT ROOT>/.venv/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 409, in run_asgi
    result = await app(  # type: ignore[func-returns-value]
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<PROJECT ROOT>/.venv/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<PROJECT ROOT>/.venv/lib/python3.12/site-packages/fastapi/applications.py", line 1134, in __call__
    await super().__call__(scope, receive, send)
  File "<PROJECT ROOT>/.venv/lib/python3.12/site-packages/starlette/applications.py", line 107, in __call__
    await self.middleware_stack(scope, receive, send)

... 中略(約90行) ...

  File "<PROJECT ROOT>/.venv/lib/python3.12/site-packages/sse_starlette/sse.py", line 255, in __call__
    async with anyio.create_task_group() as task_group:
               ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<PROJECT ROOT>/.venv/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 785, in __aexit__
    raise exc_val
  File "<PROJECT ROOT>/.venv/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 753, in __aexit__
    await self._on_completed_fut
asyncio.exceptions.CancelledError: Task cancelled, timeout graceful shutdown exceeded

開発中何度も見ることになるので、ログフィルタで書き換えると目に優しい。

class SSEShutdownFilter(logging.Filter):
    """Convert SSE shutdown CancelledError to a friendly warning."""

    def filter(self, record: logging.LogRecord) -> bool:
        if record.levelno == logging.ERROR and record.exc_info:
            exc_type = record.exc_info[0]
            if exc_type and exc_type.__name__ == "CancelledError":
                msg = str(record.exc_info[1]) if record.exc_info[1] else ""
                if "timeout graceful shutdown" in msg:
                    # Replace with a clean warning
                    logging.getLogger("uvicorn.error").warning(
                        "SSE connection closed due to server restart"
                    )
                    return False  # Suppress the original error
        return True

logging.getLogger("uvicorn.error").addFilter(SSEShutdownFilter())

そうすれば以下のような穏やかなログになる。

[2025-11-28 10:58:43] INFO     4 changes detected                                                                                      main.py:308
WARNING:  WatchFiles detected changes in 'main.py'. Reloading...
INFO:     Shutting down
INFO:     Waiting for connections to close. (CTRL+C to force quit)
ERROR:    Cancel 2 running task(s), timeout graceful shutdown exceeded
INFO:     Waiting for application shutdown.
[2025-11-28 10:58:48] INFO     Shutting down Vision Backend...                                                                     lifecycle.py:93

ずっときもいなーと思って困っていたので解決方法を共有しておく。

一言

Celery + FastAPIはいろんなジョブを非同期で実行環境を分散させたり魔法っぽくて便利だけど、Redisのキュー管理とかゾンビタスクとか、あとSSEの連携まで考えると非常に辛く厳しい。

型がないので簡単に壊れるし実行時に変な参照エラーで死んだりするのも普通に苦しい。かと言ってこの分野はGoで気軽に書けるものでもないので逃げ場がない。つまり、やるしかない。


©2024 endaaman.com