Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions kalshi/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
18 changes: 18 additions & 0 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading