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
68 changes: 62 additions & 6 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Kalshi uses RSA-PSS request signing. You'll need:

- A **key ID** (string, identifies the key on Kalshi's side).
- A **private key** — RSA, **PEM-encoded, PKCS#8, unencrypted**.
- A **private key** — RSA, PEM-encoded, PKCS#8. May be unencrypted or
encrypted; encrypted keys require a passphrase (see below).

Generate the pair in your [Kalshi account settings](https://kalshi.com/account/profile)
and download the PEM file. The signing scheme used internally is
Expand Down Expand Up @@ -175,13 +176,68 @@ load, check:
- **Must be PKCS#8** (`-----BEGIN PRIVATE KEY-----`). Legacy PKCS#1
(`-----BEGIN RSA PRIVATE KEY-----`) is supported by the underlying
`cryptography` library on a best-effort basis.
- **Must be unencrypted.** Passphrase-protected keys raise `KalshiAuthError`
with a hint. Strip the passphrase with `openssl pkey`:
- **Passphrase-protected keys are supported via `password=`** — see below. If
you load an encrypted PEM without supplying a password, `KalshiAuth` raises
`KalshiAuthError` with a hint pointing at the `password=` parameter and the
`KALSHI_PRIVATE_KEY_PASSPHRASE` env var.

```bash
openssl pkey -in encrypted.pem -out unencrypted.pem
```
### Passphrase-protected keys

Encrypted PEMs are a recommended practice for trading keys: you keep the key
at-rest encrypted on disk or in a secret manager. The SDK accepts a
`password=` keyword on every loader so you don't have to write a plaintext key
to disk:

```python
from kalshi import KalshiAuth, KalshiClient

# Inline literal (don't hard-code in real code — pull from a secret manager).
auth = KalshiAuth.from_key_path(
"your-key-id",
"~/.kalshi/encrypted_key.pem",
password="hunter2",
)

# Deferred fetch: pass a zero-arg callable. The SDK invokes it during load,
# so the secret never sits in the calling frame longer than necessary.
auth = KalshiAuth.from_pem(
"your-key-id",
pem_string,
password=lambda: secret_manager.get("kalshi/passphrase"),
)

# Bytes are accepted too.
auth = KalshiAuth.from_pem("your-key-id", pem, password=b"hunter2")
```

For env-driven flows, set `KALSHI_PRIVATE_KEY_PASSPHRASE` alongside
`KALSHI_PRIVATE_KEY` / `KALSHI_PRIVATE_KEY_PATH`:

```bash
export KALSHI_KEY_ID="..."
export KALSHI_PRIVATE_KEY_PATH="~/.kalshi/encrypted_key.pem"
export KALSHI_PRIVATE_KEY_PASSPHRASE="hunter2"
```

```python
from kalshi import KalshiClient

with KalshiClient.from_env() as client:
...
```

An explicit `password=` argument always wins over
`KALSHI_PRIVATE_KEY_PASSPHRASE` when both are supplied. A wrong passphrase
raises `KalshiAuthError` ("Invalid PEM private key…"); a missing passphrase
for an encrypted PEM raises `KalshiAuthError` pointing at the `password=`
parameter.

If you'd rather store the key unencrypted, you can still strip the passphrase
with `openssl pkey`:

```bash
openssl pkey -in encrypted.pem -out unencrypted.pem
```
### Manual signing

`KalshiAuth.sign_request(method, path, timestamp_ms=None)` is part of the
Expand Down
16 changes: 10 additions & 6 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,23 @@ This catches plaintext config slip-ups before any request is sent.
KalshiConfig(extra_headers={"User-Agent": "acme-trader/2.1 (+ops@acme.co)"})
```

`extra_headers` is merged into every request after the SDK's own headers, so
you can override anything the SDK sets (rarely a good idea outside
User-Agent).
`extra_headers` is set at the `httpx.Client` level and is **overridden** by the
SDK's per-request `KALSHI-ACCESS-*` headers, so it cannot be used to forge or
replace auth headers. It can only override client-level defaults that the SDK
doesn't re-set on every request — `User-Agent` is the realistic use case.

## HTTP/2

```python
KalshiConfig(http2=True)
```

Requires `pip install 'httpx[http2]'` — the SDK doesn't pin h2 as a hard
dependency. Once enabled, the same setting flows into the WebSocket client too
(WebSocket runs on top of an HTTP/1.1 upgrade today; this only affects REST).
Requires the `h2` package — install via `pip install 'kalshi-sdk[http2]'` (or
`pip install 'kalshi-sdk[all]'`). `KalshiConfig` validates this at construction
time and raises `ValueError` immediately if `h2` is missing, rather than
deferring the failure to the first request. Once enabled, the same setting
flows into the WebSocket client too (WebSocket runs on top of an HTTP/1.1
upgrade today; this only affects REST).

## Connection-pool limits

Expand Down
57 changes: 52 additions & 5 deletions kalshi/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@

from __future__ import annotations

import email.utils
import logging
import math
import random
import time
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlparse

Expand Down Expand Up @@ -90,7 +92,26 @@ def _map_error(response: httpx.Response) -> KalshiError:
if retry_after_val < 0 or not math.isfinite(retry_after_val):
retry_after_val = None
except ValueError:
retry_after_val = None # HTTP-date format, fall back to computed backoff
# RFC 7231 §7.1.3: Retry-After may also be an HTTP-date.
try:
dt = email.utils.parsedate_to_datetime(retry_after)
except (TypeError, ValueError):
dt = None
if dt is None:
logger.debug(
"Retry-After %r is neither delta-seconds nor HTTP-date; "
"falling back to computed backoff",
retry_after,
)
else:
# RFC 5322 dates without a tz are interpreted as UTC by
# parsedate_to_datetime; ensure aware before subtracting.
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
delta = (dt - datetime.now(tz=UTC)).total_seconds()
# Clamp negatives (date in the past) to 0 — retry immediately.
# The transport caller caps at config.retry_max_delay.
retry_after_val = max(0.0, delta)
return KalshiRateLimitError(
message=message, status_code=status, retry_after=retry_after_val
)
Expand Down Expand Up @@ -157,17 +178,33 @@ def request(
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
extra_headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Make an authenticated HTTP request with retry logic."""
"""Make an authenticated HTTP request with retry logic.

``extra_headers`` are merged per-request on top of
``config.extra_headers`` (so a per-call ``X-Request-ID`` overrides a
client-default one), but the ``KALSHI-ACCESS-*`` auth headers always
win — callers cannot forge them via ``extra_headers``.
"""
# P1.6: canonicalize trailing slash BEFORE both signing and httpx call
# so the wire path and the signed payload stay byte-identical. Root "/"
# is preserved.
if path != "/" and path.endswith("/"):
path = path.rstrip("/") or "/"
# Sign with path-only (not full URL). Kalshi expects: /trade-api/v2/endpoint
sign_path = self._base_path + path
last_error: KalshiError | None = None
# #193: wall-clock budget across the whole request including retries.
start = time.monotonic()
total_timeout = self._config.total_timeout
config_extra = self._config.extra_headers or {}
per_call_extra = extra_headers or {}

for attempt in range(self._config.max_retries + 1):
auth_headers = self._auth.sign_request(method.upper(), sign_path) if self._auth else {}
# Order matters: config defaults < per-request overrides < signed auth.
merged_headers = {**config_extra, **per_call_extra, **auth_headers}

logger.debug(
"Request: %s %s (attempt %d/%d)",
Expand All @@ -183,7 +220,7 @@ def request(
url=path,
params=params,
json=json,
headers=auth_headers,
headers=merged_headers,
)
except httpx.PoolTimeout as e:
# #204: pool exhaustion never reached the wire — safe to retry
Expand Down Expand Up @@ -312,22 +349,32 @@ async def request(
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
extra_headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Make an authenticated async HTTP request with retry logic."""
"""Make an authenticated async HTTP request with retry logic.

See :meth:`SyncTransport.request` for ``extra_headers`` semantics.
"""
import asyncio

# P1.6: canonicalize trailing slash BEFORE both signing and httpx call.
if path != "/" and path.endswith("/"):
path = path.rstrip("/") or "/"
# Sign with path-only (not full URL). Kalshi expects: /trade-api/v2/endpoint
sign_path = self._base_path + path
last_error: KalshiError | None = None
# #193: wall-clock budget across the whole request including retries.
start = time.monotonic()
total_timeout = self._config.total_timeout
config_extra = self._config.extra_headers or {}
per_call_extra = extra_headers or {}

for attempt in range(self._config.max_retries + 1):
if self._auth:
auth_headers = await self._auth.sign_request_async(method.upper(), sign_path)
else:
auth_headers = {}
merged_headers = {**config_extra, **per_call_extra, **auth_headers}

logger.debug(
"Async request: %s %s (attempt %d/%d)",
Expand All @@ -343,7 +390,7 @@ async def request(
url=path,
params=params,
json=json,
headers=auth_headers,
headers=merged_headers,
)
except httpx.PoolTimeout as e:
# #204: pool exhaustion never reached the wire — safe to retry
Expand Down
Loading
Loading