forked from Rockachopa/Timmy-time-dashboard
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:
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user