fix: set Basic Auth on HTTPRequest, not as fetch kwargs; close client cleanly

tornado rejects fetch(request, **kwargs) when the first arg is already an
HTTPRequest and the kwargs overlap request construction (auth_username /
auth_password). Move auth onto the HTTPRequest. Add OpenCodeClient.close()
to release its AsyncHTTPClient; module-level atexit closes the tornado
singleton so 'jupyter lab' can exit cleanly after SIGINT instead of
hanging on 'received signal 2, stopping'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-23 17:40:57 +08:00
co-authored by Claude Fable 5
parent ff932d9095
commit c86de1d31b
+42 -7
View File
@@ -1,5 +1,6 @@
"""Async HTTP client for the local OpenCode server.""" """Async HTTP client for the local OpenCode server."""
import atexit
import json import json
from typing import Any, Optional from typing import Any, Optional
@@ -13,6 +14,21 @@ class OpenCodeError(Exception):
"""Raised when an OpenCode API request fails.""" """Raised when an OpenCode API request fails."""
# Ensure the tornado AsyncHTTPClient singleton is closed at interpreter
# shutdown so the IOLoop can finish and the process can exit cleanly
# (otherwise Ctrl+C on `jupyter lab` hangs after "received signal 2,
# stopping" because the singleton keeps persistent connections alive).
def _close_opencode_http() -> None:
try:
AsyncHTTPClient().close()
except Exception:
# Never block interpreter shutdown on close errors.
pass
atexit.register(_close_opencode_http)
class OpenCodeClient: class OpenCodeClient:
def __init__( def __init__(
self, self,
@@ -20,7 +36,24 @@ class OpenCodeClient:
http_client: Optional[AsyncHTTPClient] = None, http_client: Optional[AsyncHTTPClient] = None,
) -> None: ) -> None:
self._config = config self._config = config
self._http_client = http_client or AsyncHTTPClient() # Use a dedicated client (not the singleton) so close() can
# deterministically release its connections without affecting
# other components. The module-level atexit still closes the
# singleton as a safety net.
self._http_client = http_client or AsyncHTTPClient(force_instance=True)
self._owns_http_client = http_client is None
def close(self) -> None:
"""Release the underlying HTTP client's connections. Idempotent."""
if getattr(self, "_http_client", None) is None:
return
try:
if self._owns_http_client:
self._http_client.close()
except Exception:
pass
self._http_client = None # type: ignore[assignment]
self._owns_http_client = False
async def health(self) -> dict[str, Any]: async def health(self) -> dict[str, Any]:
return await self._request("GET", "/global/health") return await self._request("GET", "/global/health")
@@ -82,15 +115,17 @@ class OpenCodeClient:
} }
if body is not None: if body is not None:
request_kwargs["body"] = json.dumps(body).encode("utf-8") request_kwargs["body"] = json.dumps(body).encode("utf-8")
# HTTP Basic Auth must be set on the HTTPRequest itself; passing
# auth_* as **kwargs to fetch() when the first arg is already an
# HTTPRequest is rejected by tornado ("kwargs can't be used if
# request is an HTTPRequest object").
if self._config.auth is not None:
request_kwargs["auth_username"] = self._config.auth[0]
request_kwargs["auth_password"] = self._config.auth[1]
request = HTTPRequest(url, **request_kwargs) request = HTTPRequest(url, **request_kwargs)
fetch_kwargs: dict[str, str] = {} response = await self._http_client.fetch(request)
if self._config.auth is not None:
fetch_kwargs["auth_username"] = self._config.auth[0]
fetch_kwargs["auth_password"] = self._config.auth[1]
response = await self._http_client.fetch(request, **fetch_kwargs)
if 200 <= response.code < 300: if 200 <= response.code < 300:
if not response.body: if not response.body: