fix: prevent "Event loop is closed" on repeated Gitea API calls

The httpx AsyncClient was cached across asyncio.run() boundaries.
Each asyncio.run() creates and closes a new event loop, leaving the
cached client's connections on a dead loop.  Second+ calls would fail
with "Event loop is closed".

Fix: create a fresh client per request and close it in a finally block.
No more cross-loop client reuse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trip T
2026-03-12 20:40:39 -04:00
parent 7163b15300
commit 350e6f54ff
2 changed files with 25 additions and 20 deletions

View File

@@ -72,7 +72,6 @@ class GiteaHand:
self._token = token or _resolve_token()
self._repo = repo or settings.gitea_repo
self._timeout = timeout or settings.gitea_timeout
self._client = None
if not self._token:
logger.warning(
@@ -92,20 +91,24 @@ class GiteaHand:
return bool(settings.gitea_enabled and self._token and self._repo)
def _get_client(self):
"""Lazy-initialise the async HTTP client."""
"""Create a fresh async HTTP client for the current event loop.
Always creates a new client rather than caching, because tool
functions call us via ``asyncio.run()`` which creates a new loop
each time — a cached client from a previous loop would raise
"Event loop is closed".
"""
import httpx
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers={
"Authorization": f"token {self._token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=self._timeout,
)
return self._client
return httpx.AsyncClient(
base_url=self._base_url,
headers={
"Authorization": f"token {self._token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=self._timeout,
)
async def _request(self, method: str, path: str, **kwargs) -> GiteaResult:
"""Make an API request with full error handling."""
@@ -119,8 +122,8 @@ class GiteaHand:
error="Gitea not configured (missing token or repo)",
)
client = self._get_client()
try:
client = self._get_client()
resp = await client.request(method, path, **kwargs)
latency = (time.time() - start) * 1000
@@ -155,6 +158,8 @@ class GiteaHand:
error=str(exc),
latency_ms=latency,
)
finally:
await client.aclose()
# ── Issue operations ─────────────────────────────────────────────────

View File

@@ -199,7 +199,7 @@ async def test_create_issue_success():
mock_client = AsyncMock()
mock_client.request = AsyncMock(return_value=mock_response)
mock_client.is_closed = False
mock_client.aclose = AsyncMock()
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
@@ -208,7 +208,7 @@ async def test_create_issue_success():
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
hand._client = mock_client
hand._get_client = MagicMock(return_value=mock_client)
result = await hand.create_issue("Test issue", "Body text")
assert result.success is True
@@ -231,7 +231,7 @@ async def test_create_issue_handles_http_error():
mock_client = AsyncMock()
mock_client.request = AsyncMock(return_value=mock_response)
mock_client.is_closed = False
mock_client.aclose = AsyncMock()
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
@@ -240,7 +240,7 @@ async def test_create_issue_handles_http_error():
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
hand._client = mock_client
hand._get_client = MagicMock(return_value=mock_client)
result = await hand.create_issue("Test issue")
assert result.success is False
@@ -254,7 +254,7 @@ async def test_create_issue_handles_connection_error():
mock_client = AsyncMock()
mock_client.request = AsyncMock(side_effect=ConnectionError("refused"))
mock_client.is_closed = False
mock_client.aclose = AsyncMock()
with patch("infrastructure.hands.gitea.settings") as mock_settings:
mock_settings.gitea_enabled = True
@@ -263,7 +263,7 @@ async def test_create_issue_handles_connection_error():
mock_settings.gitea_repo = "owner/repo"
mock_settings.gitea_timeout = 30
hand = GiteaHand(token="test-token")
hand._client = mock_client
hand._get_client = MagicMock(return_value=mock_client)
result = await hand.create_issue("Test issue")
assert result.success is False