Sentry kept collecting RuntimeError: Event loop is closed events where our code was almost absent from the stack. At first it looked like library cleanup noise. But following the coroutine name and the asyncio.run() boundary showed a lifecycle issue: a raw AsyncOpenAI client was not being closed inside the event loop that created it. The fix was not a broad async rewrite. It was a small change that guaranteed async close before returning from the coroutine path that created the client.
Summary
- Problem:
RuntimeError: Event loop is closedkept accumulating in Sentry as recurring noise. - Symptom: 5 events in the last 24 hours, 36 total occurrences, and the issue status was regressed.
- Judgment: I did not discard the system-only stack. I traced the cleanup resource and the event loop lifecycle boundary.
- Cause: An OpenAI async client created inside an event loop opened by
asyncio.run()was not closed before the coroutine ended. - Fix: In the meta prompt and final HTML generation paths that create raw
AsyncOpenAIclients, I guaranteed async close withfinally. - Lesson: A system-only stack is still evidence. "Our code is not in the stack" can mean the lifecycle boundary is the thing to inspect.
Context
Place2Page receives a map URL, fetches place data, and generates landing page HTML with AI. The generation pipeline roughly looks like this:
place URL
-> Google/Naver place fetch
-> meta prompt generation
-> final freeform HTML generation
-> save landing/debug log
-> send progress through SSEI had been working through Sentry issues by priority. Clear P1 issues like DB pool timeout came first. After that, one recurring issue kept showing up:
RuntimeError: Event loop is closedAt first glance, it did not look like a high-impact user failure. Sentry reported 0 users impacted. But the issue was regressed, had 5 events in the last 24 hours, and 36 total occurrences.
This kind of error is easy to ignore. It did not directly fail a request handler, and the stack was almost entirely inside libraries. But recurring production noise lowers operational sensitivity. It makes real incidents harder to see.
Problem
The latest Sentry event message looked like this:
Task exception was never retrieved
future: <Task finished name='Task-117508'
coro=<AsyncClient.aclose() done, defined at /app/.venv/lib/python3.11/site-packages/httpx/_client.py:1978>
exception=RuntimeError('Event loop is closed')>The exception itself was simple.
RuntimeError: Event loop is closedBut the stack consisted entirely of system frames.
httpx/_client.py:1985 in aclose
httpx/_transports/default.py:406 in aclose
httpcore/_async/connection_pool.py:353 in aclose
httpcore/_async/http11.py:258 in aclose
anyio/streams/tls.py:241 in aclose
anyio/_backends/_asyncio.py:1352 in aclose
asyncio/selector_events.py:864 in close
asyncio/base_events.py:520 in _check_closedOur code files were not visible. That made it hard to see "where" the close was happening.
There were two useful clues.
First, the coroutine name was AsyncClient.aclose(). The OpenAI SDK uses an HTTP client internally. The Sentry stack showed cleanup for httpx.AsyncClient.
Second, the message said Task exception was never retrieved. This looked less like an exception raised directly from a request handler, and more like a cleanup task running later and finding that the event loop had already closed.
Initial Hypothesis
I framed the hypothesis like this:
An OpenAI async client is being created inside an event loop, not explicitly closed in that loop, and later HTTPX cleanup runs after the event loop has closed.
To test that, I needed to find who creates and closes the event loop.
In the Place2Page generation background path, a sync function runs an async generation coroutine.
rendered_html, generation_meta = asyncio.run(
generate_freeform_landing_html_with_meta(...)
)The Python documentation says asyncio.run() runs the coroutine, finalizes async generators and the executor, then closes the event loop it created. It is intended as a main entry point.
In this structure, any async resource created inside generate_freeform_landing_html_with_meta() should be closed before the coroutine returns.
Searching the code showed a path that creates a raw OpenAI async client.
def _openai_raw_client():
return AsyncOpenAI(...)That client was used in two places:
_generate_freeform_meta_prompt_with_openai()_request_freeform_html_with_openai()
Both functions created the client and called client.responses.create(...), but neither closed it explicitly.
Grounding the Rule in Official Docs
I did not want to stop at "closing it sounds right," so I checked the official docs.
HTTPX's async docs recommend managing AsyncClient with async with, or explicitly calling await client.aclose() when using a manual lifecycle. They also warn that manual streaming mode puts close responsibility on the developer.
Python's asyncio.run() docs explain that the function creates a new event loop and closes it at the end. That means an async HTTP client created inside asyncio.run() should be closed while that loop is still alive.
This issue was where those two rules met:
HTTPX async clients need async close.
asyncio.run() closes the event loop at the end.
Therefore, a client created inside asyncio.run() must be closed before the coroutine returns.Checking the Local SDK Surface
There was one more thing to confirm. Sentry showed httpx.AsyncClient.aclose(), but our code used OpenAI SDK's AsyncOpenAI. I checked which close API the installed SDK exposed locally.
cd apps/api
uv run python - <<'PY'
from openai import AsyncOpenAI
import inspect
client = AsyncOpenAI(api_key="test-key")
print("AsyncOpenAI has close:", hasattr(client, "close"))
print("AsyncOpenAI.close is coroutine:", inspect.iscoroutinefunction(getattr(client, "close", None)))
print("AsyncOpenAI has aclose:", hasattr(client, "aclose"))
print("AsyncOpenAI.aclose is coroutine:", inspect.iscoroutinefunction(getattr(client, "aclose", None)))
print("AsyncOpenAI supports async context manager:", hasattr(client, "__aenter__") and hasattr(client, "__aexit__"))
PYThe result was:
AsyncOpenAI has close: True
AsyncOpenAI.close is coroutine: True
AsyncOpenAI has aclose: False
AsyncOpenAI.aclose is coroutine: False
AsyncOpenAI supports async context manager: TrueSo in the SDK version installed in the project, AsyncOpenAI had async close() and did not have aclose(). The helper needed to use aclose if it existed, otherwise use close and await the result if it was awaitable.
RED Tests
I added tests before changing the implementation.
The goals were simple:
- The raw client must be closed after a successful OpenAI final HTML request.
- The raw client must be closed after a successful OpenAI meta prompt request.
The tests did not use the network. They used a fake client with responses.create() and async close().
class _ClosableOpenAIRawClient:
def __init__(self, response: object):
self.responses = _OpenAIResponses(response)
self.close_calls = 0
async def close(self) -> None:
self.close_calls += 1The final HTML stream was represented by an async iterator.
class _AsyncEventStream:
def __init__(self, events: list[object]):
self._events = iter(events)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._events)
except StopIteration as exc:
raise StopAsyncIteration from excThe first test checked that close_calls == 1 after the final HTML request completed.
def test_request_freeform_html_with_openai_closes_raw_client(monkeypatch):
client = _ClosableOpenAIRawClient(_AsyncEventStream([...]))
monkeypatch.setattr(generation_service.settings, "openai_api_key", "test-key")
monkeypatch.setattr(generation_service, "_openai_raw_client", lambda: client)
result = asyncio.run(
generation_service._request_freeform_html_with_openai(...)
)
assert result == html
assert client.close_calls == 1The second test checked the same condition after meta prompt generation.
def test_generate_freeform_meta_prompt_with_openai_closes_raw_client(monkeypatch):
client = _ClosableOpenAIRawClient(
SimpleNamespace(output_text="Use the venue data to choose a focused style.")
)
monkeypatch.setattr(generation_service.settings, "openai_api_key", "test-key")
monkeypatch.setattr(generation_service, "_openai_raw_client", lambda: client)
result = asyncio.run(
generation_service._generate_freeform_meta_prompt_with_openai(...)
)
assert result == "Use the venue data to choose a focused style."
assert client.close_calls == 1Before the implementation change, both failed.
assert 0 == 1That failure was the key evidence. Sentry showed cleanup happening too late, and the tests showed that our generation path was not closing the client.
Fix
The fix was intentionally small.
First, I added a helper for closing the OpenAI async client.
async def _close_openai_async_client(client: Any, *, context: str) -> None:
close = getattr(client, "aclose", None)
if not callable(close):
close = getattr(client, "close", None)
if not callable(close):
return
try:
result = close()
if inspect.isawaitable(result):
await result
except Exception:
logger.warning(
"Failed to close OpenAI async client (context=%s)",
context,
exc_info=True,
)Then I wrapped both functions that create raw clients with try/finally.
client = _openai_raw_client()
try:
stream = await client.responses.create(**request_payload)
...
return output_text
finally:
await _close_openai_async_client(client, context="freeform_html")The meta prompt path used the same pattern.
client = _openai_raw_client()
try:
response = await client.responses.create(**request_payload)
...
return normalized
finally:
await _close_openai_async_client(client, context="meta_prompt")With this shape, OpenAI/httpx cleanup does not spill past the event loop that asyncio.run() is about to close. The client close finishes inside the same loop that made the request.
Why I Did Not Make a Larger Structural Change
There were larger options.
- Make the whole generation background worker async-first.
- Manage the OpenAI client as an app lifespan singleton.
- Remove
asyncio.run()and redesign the event loop boundary.
Those may be cleaner long term. But the direct cause of this Sentry issue was that the per-generation raw async client was not being closed. A larger rewrite would also touch DB session lifecycle, SSE stream behavior, and provider fallback paths.
So I limited this fix to "the code that creates the client closes the client."
That choice had clear advantages.
- It fixed the path directly connected to the production error.
- It preserved fallback and quality gate behavior.
- The tests pinned the close boundary directly.
- Even if the async worker structure changes later, the lifecycle rule remains useful.
Verification
The two tests that failed first passed after the fix.
cd apps/api
uv run pytest -q \
tests/test_generation_service.py::test_request_freeform_html_with_openai_closes_raw_client \
tests/test_generation_service.py::test_generate_freeform_meta_prompt_with_openai_closes_raw_client2 passed in 0.30sI also ran the full generation service test file.
cd apps/api
uv run pytest -q tests/test_generation_service.py72 passed in 0.47sLint passed too.
cd apps/api
uv run ruff check services/generation_service.py tests/test_generation_service.pyAll checks passed!Finally, when checking this Sentry hotfix batch together, 185 targeted API tests passed.
185 passed, 1 warning in 4.44sI kept the evidence files in the repository.
outputs/sentry-event-loop-closed-20260522/
README.md
red-openai-client-close-tests.txt
green-openai-client-close-tests.txt
openai-async-client-surface.txt
test-generation-service.txt
ruff-generation-service.txt
git-diff-check.txt
fix.diffLessons
The most important lesson was that "our code is not in the stack" does not mean "this is not our problem."
The Sentry stack showed only httpx, httpcore, anyio, and asyncio. But that stack showed where cleanup finally failed, not who created the client.
Reading the stack from bottom to top was not enough. I had to turn it into lifecycle questions.
- Where was this async resource created?
- Which event loop is it tied to?
- Who is responsible for closing it?
- Does close happen before or after loop shutdown?
- Can cleanup run outside the request context?
This time, those questions led to asyncio.run() and the raw AsyncOpenAI client.
Another lesson was to inspect the SDK wrapper surface directly. Sentry showed httpx.AsyncClient.aclose(), but the object our code had to close was AsyncOpenAI. Local introspection showed that this SDK exposed async close() and not aclose(). The last small gap between docs and the installed version is often best checked locally.
How I Will Judge This Next Time
For a similar Event loop is closed issue, I will inspect resource lifecycle before focusing on the final library frame. Cleanup stacks often show what resource closed too late, not the original cause.
My order will be:
- Check the coroutine name in the Sentry message.
- Even for a system-only stack, identify the resource being cleaned up.
- Look for
asyncio.run(), background tasks, and thread boundaries. - Add a test that requires the resource to close before the loop ends.
- Treat singleton or app lifespan migration as a separate design after the direct fix.
Event loop is closed usually fails at the end. But the last code to fail is not necessarily the cause. The cause is often between the place that created the resource and the place that promised to close it.
