v2.0.0
Audit-driven hardening release. 30 audit-findings landed across five
parallel waves (Wave 1 – Wave 5) plus follow-ups: a WebSocket recv-loop
overhaul, a spec-sync supply-chain rewrite, double-parse/double-validate
elimination on the WS hot path, and surface-level cleanup including
three deliberate breaking changes called out below.
Breaking
-
AccountApiLimits.read_limit/.write_limitremoved. Replaced with
AccountApiLimits.read/.write, both of typeRateLimit
(bucket_capacity: int,refill_rate: int). The published OpenAPI spec
declares the limits as ints, but the live server returns nested token
buckets — v2 matches the server. The old int fields never worked against
the live API.# v1 limits = client.account.limits() limits.read_limit # AttributeError after upgrade # v2 limits.read.bucket_capacity # int limits.read.refill_rate # int
-
Order.typerenamed toOrder.order_type. Wire format is unchanged
(validation_alias=AliasChoices("type", "order_type")accepts both names
on deserialization), but any user code reading.typeon anOrder
instance must migrate to.order_type. Matches the project's existing
builtin-shadow-avoidance convention (milestone_type,target_type,
incentive_type). Spec v3.13.0 still definestypeas required, so the
field is preserved on the wire — only the Python attribute name changed
(#91).# v1 order = client.portfolio.orders.get(order_id="...") print(order.type) # AttributeError after upgrade # v2 print(order.order_type)
-
Count/size/volume response fields retyped
DollarDecimal→FixedPointCount
(#90). Fields with_fpwire aliases were annotated as the type that
signals "dollar amount." Runtime behavior is unchanged (both validators
resolve toDecimal), but the annotation now communicates the right
semantics.mypy --strictusers may need to update narrow assertions;
isinstance(x, Decimal)remains valid. Affected:Market.{yes_bid_size, yes_ask_size, no_bid_size, no_ask_size, volume, volume_24h, open_interest},
Candlestick.{volume, open_interest},OrderbookLevel.quantity,
Fill.count,Trade.count,MarketPosition.position,
EventPosition.total_cost_shares,Settlement.{yes_count, no_count},
Series.volume.
Added
max_pages: int | Nonekwarg on every public*_all()method — sync
and async (19 + 19 method signatures). Bounded iteration without manual
pagination.None(default) iterates until the server returns no cursor
(#98).RateLimitmodel exposed viakalshi.RateLimit— represents the
per-direction token-bucket structure onAccountApiLimits.read/.write.KalshiConfig.http2andKalshiConfig.limits— opt-in HTTP/2 and
httpx.Limits(connection pool sizing, keep-alive) on the transport.
Defaults preserve existing behavior (#141, F-R-15).- 23 model classes re-exported from
kalshi.__all__— every name in
kalshi.models.__all__is now also importable from the top-level
kalshipackage. New dynamic parity test enforces the invariant (#89). ConnectionManager.mark_streaming()public API — replaces the
recv-loop's prior_set_statereach-through (#88).
Changed
*_all()methods are now unbounded by default. Previous internal
1000-page cap silently truncated callers iterating beyond ~100k items.
The cursor-repeat guard remains the runaway protection it always was.
Callers wanting a cap passmax_pages=Nexplicitly (#98).- All response models uniformly use
extra="allow"(#114). Previously
5 response models (Page,Orderbook,OrderbookLevel,
BidAskDistribution,PriceDistribution) fell back to Pydantic's default
extra="ignore", silently dropping unknown fields. They now preserve
them on__pydantic_extra__. Request bodies remainextra="forbid".
Drift guard test (tests/test_model_extra_policy.py) enforces the
policy across every exported model. - WS callbacks no longer suppress queue delivery (#80). Previously,
registering a callback for a channel silently disabled the iterator
queue for that channel — a user holding both an@on()callback AND an
iterator on the same channel would never see the iterator fire. Now
messages fan out to both. A WARNING is logged atregister_callback
time if an active subscription already exists, so upgraders see the
signal. Callback-only users now accumulate up tomaxsize=1000(existing
DROP_OLDESTbackpressure prevents unbounded growth). OrderbookManagerreturns fresh snapshots instead of mutating in
place (#85). Consumers holding a reference to a previously-emitted
Orderbookno longer see leaked mutations on the next delta.KalshiConfigvalidatesbase_urlandws_base_urlat construction
time. Non-https/wssto remote hosts is rejected; loopback HTTP/WS
permitted for local mock servers. Unknown but secure hosts log a
WARNING (proxies still work). Trailing slashes are normalized (#94).
Fixed
/account/limitsresponse now parses against the live server. The
published OpenAPI spec declaresread_limit/write_limitas ints, but
the live API returns nestedread/writetoken-bucket objects.
AccountApiLimitsnow matches the server./search/tags_by_categoriesno longer crashes when a category (e.g.
Social) returnsnullinstead of an empty list.tags_by_categories
values are nowNullableList[str], collapsingnull→[].- WS recv-loop reconnect/resubscribe correctness — 5 race conditions
fixed (per-sub isolation in resubscribe-all,_subscribe_lockcovering
the full reconnect+resubscribe sequence,asyncio.shieldaround the
recv→dispatch critical section,ConnectionClosedsurfacing as
KalshiConnectionError, sentinel-before-cleanup ordering in
unsubscribe) (#77). - WS recv-loop exception ladder narrowed —
KalshiBackpressureError/
KalshiSubscriptionErrorbreak the loop and broadcast sentinels to all
consumers;json.JSONDecodeError/pydantic.ValidationError/KeyError
log+continue; unexpected exceptions broadcast sentinels then re-raise
(#83). - WS server-initiated unsubscribe reaps
_sid_to_clientmappings
alongside the subscription, and resetsSequenceTracker._last_seq[sid]
via the now-wiredseq_trackerkwarg (#81). - WS channel-level error envelopes surface via
on_errorinstead of
being silently dropped, with a fallback log if no handler is registered
(#82). - WS seq watermark rolls back on backpressure —
_process_frame
captures the pre-trackwatermark and restores it if dispatch raises
KalshiBackpressureError, so the dropped message stays visible as a
future gap rather than being silently treated as already-seen (#78). - WS multi-ticker seq-gap clears every ticker in the affected
subscription instead of onlytickers[0](#79). Retry-Afterparser rejects negative, NaN, and infinite values
(busy-loop / sleep-crash / cap-bypass) and honorsRetry-After: 0
end-to-end through the retry loop (was dropped by a falsy-check) (#96).Page.to_dataframe()/.to_polars()nested-model serialization
pinned by tests; behavior is now under regression guard (#101).- WS double-parse + double-validate eliminated — recv loop parses
JSON once and hands the parsed dict (plus apre_validatedtyped
message for orderbook channels) to the dispatcher (#86).
Security
- Spec-sync workflow hardened against upstream compromise. Reduced
permissions fromcontents: write + pull-requests: writeto
contents: read + issues: write. Removed automatic PR creation and
in-CI code-generation. Drift now opens a newspec-drift-labeled issue
per distinct fingerprint (sha256 of upstream specs), deduped to prevent
spam. SHA-pinned all third-party actions. Body rendered via Python
template (no shell expansion of upstream content) (#92). - URL leakage scrubbed from
KalshiError.__str__— httpx exception
strings include the full request URL with query parameters, which
surfaced credentials in Sentry/log sinks. Surface method + path only
(no host, no query); the underlying exception is on__cause__. Same
fix inKalshiConnectionErrorfor the WebSocket connect path (#84
F-O-09). - Trade-data leakage scrubbed from WS dispatch log — Pydantic's
ValidationError.__str__echoes the full input including trade
payload (price, count, user identifiers). Droppedexc_info=Trueon
the failure log; surface type + exception class only (#84 F-O-05). - Claude action workflows SHA-pinned + Dependabot enabled + nightly
pip-auditworkflow added (#93, #95). - Integration-nightly workflow shreds the PEM on exit so a paused
job can't leak the demo private key (#106 F-O-11). - PyPI release workflow uploads sigstore attestations for trusted
publisher verification (#106 F-O-12). - pytest bumped to
>=9,<10to clear CVE-2025-71176 (predictable
/tmp/pytest-of-{user}directory on UNIX) (#123).
Performance
MessageQueue.qsize()O(n) → O(1) via a counter updated on
put/get/__anext__/DROP_OLDEST eviction (#103).- REST retry backoff switched to AWS Full Jitter —
uniform(0, min(cap, base * 2**attempt)), cap applied before
randomization (#104). RecordingTransportO(N²) → amortized O(1) per request — buffered
in-memory, flushed on close instead of rewriting the recording on
every request (#105).OrderbookManager.apply_deltaO(n) → O(1) via a price-indexed
dict, materializing sorted level lists lazily on snapshot emit (#87).- Transport caches
urlparse(base_url).pathonce instead of
re-parsing per request (#106 F-R-04).
Internal
kalshi/__init__.pyre-export parity — 23 model classes now
re-exported from the top-level package with a dynamic parity test
that prevents silent drift (#89).ExclusionKind = "client_only"for SDK-only kwargs with no spec
counterpart (e.g.max_pages). Distinguishes frompaginator_handled
(spec params the SDK hides).tests/integration/helpers.pygainedwait_for_resource/
await_resourcefor demo's eventual-consistency lag on
POST → GET-by-id(orders / order_groups). Fixes 11 integration
failures that surfaced after configuring the demo secrets in CI.tests/integration/test_subaccounts.pyephemeral fixture polls
list_balancesfor the new subaccount instead of asserting immediate
visibility.- Test count delta: 1407 → 1808 (+401 across all waves).
- Dependency-resolution drift fix: pinned
ast-serialize<0.5(a
transitive of mypy 2.1) whose 0.5.0 release droppedcp39-abi3
wheels, breaking CI on Python 3.12/3.13.
Migration
See docs/migration.md for a focused v1.x → v2.0
migration guide.