diff --git a/.github/workflows/integration-nightly.yml b/.github/workflows/integration-nightly.yml index 6e23e87..02a14b4 100644 --- a/.github/workflows/integration-nightly.yml +++ b/.github/workflows/integration-nightly.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c868f7e..27d23ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a0b07..515a1a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/kalshi/_base_client.py b/kalshi/_base_client.py index e2965e2..0f60190 100644 --- a/kalshi/_base_client.py +++ b/kalshi/_base_client.py @@ -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: @@ -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): @@ -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: @@ -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): diff --git a/kalshi/async_client.py b/kalshi/async_client.py index 6c8a004..97765f8 100644 --- a/kalshi/async_client.py +++ b/kalshi/async_client.py @@ -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: diff --git a/kalshi/config.py b/kalshi/config.py index c90551f..abbfd08 100644 --- a/kalshi/config.py +++ b/kalshi/config.py @@ -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" @@ -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 @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index 5d7f24e..484524b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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()