Summary
Bundle of small HTTP transport / auth DX items. Each is low individual severity but collectively meaningful for "professional-grade" feel.
1. http2=True opaque ImportError; declare h2 extra
KalshiConfig(http2=True) lazily imports h2 only when the first request fires, surfacing ImportError("Using http2=True requires the package h2 ..."). pyproject.toml exposes only pandas, polars, all — no http2 extra.
Fix: add http2 = ["h2>=4,<5"] to [project.optional-dependencies] (include in all). In KalshiConfig.__post_init__, if self.http2 is True, check importlib.util.find_spec('h2') and raise ValueError("http2=True requires the 'h2' package — install with pip install kalshi-sdk[http2]").
2. No per-request header override
SyncTransport.request / AsyncTransport.request only accept params and json. Resource methods cannot attach X-Request-ID, W3C traceparent, tenant headers, etc.
Fix: add extra_headers: dict[str, str] | None = None to both transports' request() signature. Merge as {**config.extra_headers, **(extra_headers or {}), **auth_headers} so signature headers always win.
3. Retry-After HTTP-date form ignored
RFC 7231 §7.1.3 permits Retry-After as delta-seconds OR an HTTP-date. The SDK only parses delta-seconds and silently falls back to computed backoff on ValueError. Safe but masks the signal.
Fix: on ValueError, attempt email.utils.parsedate_to_datetime(retry_after) and convert to delta-seconds via (dt - datetime.now(tz=timezone.utc)).total_seconds(). Cap at retry_max_delay. Debug-log when falling back.
4. extra_headers docs claim wrong
docs/configuration.md:83-85 says extra_headers can override anything the SDK sets. In reality, per-request KALSHI-ACCESS-* headers always win (correct security behavior; wrong doc claim).
Fix: Rewrite the docs paragraph: extra_headers is set at httpx.Client level and is OVERRIDDEN by the SDK's per-request auth headers; it can only override client-level defaults like User-Agent. Optionally add an assertion at transport init that rejects user-supplied KALSHI-ACCESS-* keys in extra_headers.
5. RecordingTransport persists response headers verbatim
The module-level warning calls out that recorded fixtures contain the full response body but not that ALL response headers are also written. Custom X-Kalshi-* headers, rate-limit headers, request IDs, server timestamps get persisted with no scrubbing.
Fix: Update the docstring warning to mention response headers. Add an optional response_header_filter: Callable[[str, str], bool] | Iterable[str] | None parameter to RecordingTransport/AsyncRecordingTransport that defaults to dropping a small allowlist-violating set (Set-Cookie, Authorization, anything matching (?i)^x-kalshi-.*-(id|key|account|user).*$).
6. Trailing-slash signing mismatch foot-gun
sign_request strips a trailing slash from the signing payload, but the path passed to httpx is unchanged. Today no resource path uses a trailing slash, so the two payloads match. A future self._get("/markets/") would produce a request whose wire path is /trade-api/v2/markets/ while the signed message is …/markets → opaque 401.
Fix: canonicalize the trailing slash at the caller (transport request()) so wire URL and signed payload share canonicalization. OR assert in sign_request that the caller's path does not end with / (except literal root) and raise a clear ValueError.
Severity & category
Bundle of low items, mostly dx + security + infra.
Summary
Bundle of small HTTP transport / auth DX items. Each is low individual severity but collectively meaningful for "professional-grade" feel.
1. http2=True opaque ImportError; declare
h2extraKalshiConfig(http2=True)lazily importsh2only when the first request fires, surfacingImportError("Using http2=True requires the package h2 ...").pyproject.tomlexposes onlypandas,polars,all— nohttp2extra.Fix: add
http2 = ["h2>=4,<5"]to[project.optional-dependencies](include inall). InKalshiConfig.__post_init__, ifself.http2is True, checkimportlib.util.find_spec('h2')and raiseValueError("http2=True requires the 'h2' package — install with pip install kalshi-sdk[http2]").2. No per-request header override
SyncTransport.request/AsyncTransport.requestonly acceptparamsandjson. Resource methods cannot attachX-Request-ID, W3Ctraceparent, tenant headers, etc.Fix: add
extra_headers: dict[str, str] | None = Noneto both transports'request()signature. Merge as{**config.extra_headers, **(extra_headers or {}), **auth_headers}so signature headers always win.3.
Retry-AfterHTTP-date form ignoredRFC 7231 §7.1.3 permits
Retry-Afteras delta-seconds OR an HTTP-date. The SDK only parses delta-seconds and silently falls back to computed backoff onValueError. Safe but masks the signal.Fix: on
ValueError, attemptemail.utils.parsedate_to_datetime(retry_after)and convert to delta-seconds via(dt - datetime.now(tz=timezone.utc)).total_seconds(). Cap atretry_max_delay. Debug-log when falling back.4.
extra_headersdocs claim wrongdocs/configuration.md:83-85saysextra_headerscan override anything the SDK sets. In reality, per-requestKALSHI-ACCESS-*headers always win (correct security behavior; wrong doc claim).Fix: Rewrite the docs paragraph:
extra_headersis set at httpx.Client level and is OVERRIDDEN by the SDK's per-request auth headers; it can only override client-level defaults likeUser-Agent. Optionally add an assertion at transport init that rejects user-suppliedKALSHI-ACCESS-*keys inextra_headers.5. RecordingTransport persists response headers verbatim
The module-level warning calls out that recorded fixtures contain the full response body but not that ALL response headers are also written. Custom
X-Kalshi-*headers, rate-limit headers, request IDs, server timestamps get persisted with no scrubbing.Fix: Update the docstring warning to mention response headers. Add an optional
response_header_filter: Callable[[str, str], bool] | Iterable[str] | Noneparameter toRecordingTransport/AsyncRecordingTransportthat defaults to dropping a small allowlist-violating set (Set-Cookie,Authorization, anything matching(?i)^x-kalshi-.*-(id|key|account|user).*$).6. Trailing-slash signing mismatch foot-gun
sign_requeststrips a trailing slash from the signing payload, but the path passed to httpx is unchanged. Today no resource path uses a trailing slash, so the two payloads match. A futureself._get("/markets/")would produce a request whose wire path is/trade-api/v2/markets/while the signed message is…/markets→ opaque 401.Fix: canonicalize the trailing slash at the caller (transport
request()) so wire URL and signed payload share canonicalization. OR assert insign_requestthat the caller's path does not end with/(except literal root) and raise a clearValueError.Severity & category
Bundle of low items, mostly dx + security + infra.