diff --git a/kalshi/_base_client.py b/kalshi/_base_client.py index bc5497a8..72980cbd 100644 --- a/kalshi/_base_client.py +++ b/kalshi/_base_client.py @@ -227,6 +227,7 @@ def __init__( if config.limits is not None: client_kwargs["limits"] = config.limits self._client = httpx.Client(**client_kwargs) + self._closed: bool = False @property def is_authenticated(self) -> bool: @@ -412,6 +413,16 @@ def request( raise KalshiError("Max retries exhausted") def close(self) -> None: + """Close the underlying httpx client. Idempotent for sequential callers (#301). + + Concurrent close() across threads is not race-free — ``_closed`` is + checked-then-set without a lock — but the worst case degrades to + one redundant httpx ``Client.close()`` (itself idempotent), so the + practical contract holds. + """ + if self._closed: + return + self._closed = True self._client.close() @@ -439,6 +450,7 @@ def __init__( if config.limits is not None: client_kwargs["limits"] = config.limits self._client = httpx.AsyncClient(**client_kwargs) + self._closed: bool = False @property def is_authenticated(self) -> bool: @@ -610,4 +622,12 @@ async def request( raise KalshiError("Max retries exhausted") async def close(self) -> None: + """Close the underlying httpx async client. Idempotent (#301). + + Concurrent close() is safe under asyncio's cooperative scheduling — + no race between the ``_closed`` check and the flip. + """ + if self._closed: + return + self._closed = True await self._client.aclose() diff --git a/tests/test_async_client.py b/tests/test_async_client.py index ccddea8c..eb54d594 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -821,3 +821,21 @@ async def fake_sleep(d: float) -> None: await transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 assert isinstance(exc_info.value.__cause__, httpx.ReadError) + + +class TestAsyncTransportLifecycle: + @pytest.mark.asyncio + async def test_async_transport_close_is_idempotent( + self, transport: AsyncTransport + ) -> None: + """#301: async ``close()`` must be safe to call multiple times. + + Mirrors ``test_auth.py::test_close_is_idempotent`` for ``KalshiAuth`` + and the sync ``test_sync_transport_close_is_idempotent``: stacked + cleanup paths must not raise on an already-closed httpx AsyncClient. + """ + await transport.close() + assert transport._closed is True + await transport.close() + await transport.close() # triple-close OK + assert transport._closed is True diff --git a/tests/test_client.py b/tests/test_client.py index d8a7846a..fed5ba2c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1452,3 +1452,18 @@ def test_post_does_not_retry_on_read_error( transport.request("POST", "/portfolio/orders", json={"ticker": "T"}) assert route.call_count == 1 assert isinstance(exc_info.value.__cause__, httpx.ReadError) + + +class TestSyncTransportLifecycle: + def test_sync_transport_close_is_idempotent(self, transport: SyncTransport) -> None: + """#301: ``close()`` must be safe to call multiple times. + + Mirrors ``test_auth.py::test_close_is_idempotent`` for ``KalshiAuth``: + cleanup paths (context exits, ``atexit``, explicit teardown in tests) + can stack without raising on an already-closed httpx client. + """ + transport.close() + assert transport._closed is True + transport.close() + transport.close() # triple-close OK + assert transport._closed is True