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
8 changes: 8 additions & 0 deletions .github/workflows/integration-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ jobs:
KALSHI_KEY_ID: ${{ secrets.KALSHI_KEY_ID }}
run: uv run pytest tests/integration/ -v

- name: Shred Kalshi private key
if: always() && steps.guard.outputs.has_secrets == 'true'
run: |
KEY_PATH="${RUNNER_TEMP}/kalshi_private_key.pem"
if [ -f "${KEY_PATH}" ]; then
shred -u "${KEY_PATH}" || rm -f "${KEY_PATH}"
fi

- name: Report failure to GitHub Issue
if: failure() && steps.guard.outputs.has_secrets == 'true' && steps.pytest.outcome == 'failure'
env:
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ jobs:
name: pypi
url: https://pypi.org/project/kalshi-sdk/
permissions:
id-token: write # required for PyPI trusted publishing (OIDC)
id-token: write # required for PyPI trusted publishing + attestations (OIDC)
attestations: write # required to upload sigstore attestations
steps:
- uses: actions/download-artifact@v4
with:
Expand All @@ -55,6 +56,8 @@ jobs:

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
attestations: true

github-release:
name: Create GitHub Release
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ All notable changes to kalshi-sdk will be documented in this file.
cursor-repeat guard remains the safety net against infinite loops (#98).
- **`RateLimit` model** exposed via `kalshi.RateLimit` — represents the per-
direction token-bucket structure on `AccountApiLimits.read` / `.write`.
- **`KalshiConfig.http2` and `KalshiConfig.limits`** — opt-in HTTP/2 and
`httpx.Limits` (connection pool sizing, keep-alive) on the transport.
Defaults preserve existing behavior (`http2=False`, `limits=None`).

### Changed

Expand Down
40 changes: 26 additions & 14 deletions kalshi/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,18 @@ def __init__(
) -> None:
self._auth = auth
self._config = config
self._client = httpx.Client(
base_url=config.base_url,
timeout=config.timeout,
headers=config.extra_headers,
transport=transport,
)
# Cached once: base_url is immutable on a frozen KalshiConfig.
self._base_path = urlparse(config.base_url).path
client_kwargs: dict[str, Any] = {
"base_url": config.base_url,
"timeout": config.timeout,
"headers": config.extra_headers,
"transport": transport,
"http2": config.http2,
}
if config.limits is not None:
client_kwargs["limits"] = config.limits
self._client = httpx.Client(**client_kwargs)

@property
def is_authenticated(self) -> bool:
Expand All @@ -121,7 +127,7 @@ def request(
) -> httpx.Response:
"""Make an authenticated HTTP request with retry logic."""
# Sign with path-only (not full URL). Kalshi expects: /trade-api/v2/endpoint
sign_path = urlparse(self._config.base_url).path + path
sign_path = self._base_path + path
last_error: KalshiError | None = None

for attempt in range(self._config.max_retries + 1):
Expand Down Expand Up @@ -218,12 +224,18 @@ def __init__(
) -> None:
self._auth = auth
self._config = config
self._client = httpx.AsyncClient(
base_url=config.base_url,
timeout=config.timeout,
headers=config.extra_headers,
transport=transport,
)
# Cached once: base_url is immutable on a frozen KalshiConfig.
self._base_path = urlparse(config.base_url).path
client_kwargs: dict[str, Any] = {
"base_url": config.base_url,
"timeout": config.timeout,
"headers": config.extra_headers,
"transport": transport,
"http2": config.http2,
}
if config.limits is not None:
client_kwargs["limits"] = config.limits
self._client = httpx.AsyncClient(**client_kwargs)

@property
def is_authenticated(self) -> bool:
Expand All @@ -242,7 +254,7 @@ async def request(
import asyncio

# Sign with path-only (not full URL). Kalshi expects: /trade-api/v2/endpoint
sign_path = urlparse(self._config.base_url).path + path
sign_path = self._base_path + path
last_error: KalshiError | None = None

for attempt in range(self._config.max_retries + 1):
Expand Down
15 changes: 15 additions & 0 deletions kalshi/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ def is_authenticated(self) -> bool:
def ws(self) -> KalshiWebSocket:
"""WebSocket client for real-time streaming.

.. note::
Each access returns a **new** ``KalshiWebSocket`` instance.
Per-instance state (callbacks registered via ``.on()``,
pending subscriptions) does not carry across accesses —
assign the property to a local variable if you need to share
state across multiple operations::

ws = client.ws # capture once
@ws.on("ticker_v2")
async def handle(msg): ...
async with ws.connect() as session: ...

The sync ``KalshiClient`` does not expose ``.ws``; WebSocket
access is async-only.

Usage::

async with client.ws.connect() as session:
Expand Down
8 changes: 8 additions & 0 deletions kalshi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from dataclasses import dataclass, field
from urllib.parse import urlparse

import httpx

PRODUCTION_BASE_URL = "https://api.elections.kalshi.com/trade-api/v2"
DEMO_BASE_URL = "https://demo-api.kalshi.co/trade-api/v2"

Expand Down Expand Up @@ -34,6 +36,10 @@ class KalshiConfig:
max_retries: Max retry attempts for transient errors. Defaults to 3.
retry_base_delay: Base delay in seconds for exponential backoff. Defaults to 0.5.
retry_max_delay: Maximum delay in seconds for backoff. Defaults to 30.
http2: Enable HTTP/2 for REST requests. Off by default for compat.
Requires the ``h2`` package (install ``httpx[http2]`` or ``h2``).
limits: Custom ``httpx.Limits`` for connection pool tuning. ``None``
uses httpx defaults.
"""

base_url: str = PRODUCTION_BASE_URL # trailing slash is stripped automatically
Expand All @@ -44,6 +50,8 @@ class KalshiConfig:
extra_headers: dict[str, str] = field(default_factory=dict)
ws_base_url: str = PRODUCTION_WS_URL # trailing slash is stripped automatically
ws_max_retries: int = DEFAULT_WS_MAX_RETRIES
http2: bool = False
limits: httpx.Limits | None = None

def __post_init__(self) -> None:
# Strip trailing slash to prevent double-slash in auth signing paths
Expand Down
33 changes: 33 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,36 @@ def test_extra_headers_defaults_to_empty_dict(self) -> None:
assert config_b.extra_headers == {}
config_a.extra_headers["X-Trace-Id"] = "a"
assert config_b.extra_headers == {}


class TestHttpClientTuning:
"""http2 and limits are exposed on KalshiConfig and forwarded to httpx."""

def test_http2_defaults_off(self) -> None:
assert KalshiConfig().http2 is False

def test_limits_defaults_none(self) -> None:
assert KalshiConfig().limits is None

def test_http2_flag_forwarded_to_sync_client(self, test_auth: KalshiAuth) -> None:
# http2=False with no h2 installed must still build successfully —
# only http2=True requires the h2 extra.
config = KalshiConfig(http2=False)
transport = SyncTransport(test_auth, config)
try:
# httpx exposes http2 setting via the private _h2_pool; safer to
# assert via no-error construction and via the config round-trip.
assert transport._config.http2 is False
finally:
transport.close()

def test_custom_limits_forwarded_to_sync_client(
self, test_auth: KalshiAuth
) -> None:
limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
config = KalshiConfig(limits=limits)
transport = SyncTransport(test_auth, config)
try:
assert transport._config.limits is limits
finally:
transport.close()
Loading