|
1 | 1 | # Migration |
2 | 2 |
|
| 3 | +## v2.2 → v2.3 |
| 4 | + |
| 5 | +v2.3 is additive on the REST surface and **soft-breaking on the WebSocket |
| 6 | +surface** in one place: `KalshiWebSocket.run_forever()` no longer returns |
| 7 | +silently when nothing has been subscribed. Everything else is opt-in. |
| 8 | + |
| 9 | +### `run_forever()` requires an active subscription |
| 10 | + |
| 11 | +Per closed #175 / #185, `KalshiWebSocket.run_forever()` now raises |
| 12 | +`KalshiSubscriptionError` at the call site when no `subscribe_*` call has |
| 13 | +landed on the session. The previous silent no-op masked a common mistake — |
| 14 | +registering an `@ws.on(channel)` callback without subscribing, then |
| 15 | +wondering why no frames arrived (the server only sends frames for channels |
| 16 | +the client explicitly subscribed to). |
| 17 | + |
| 18 | +```python |
| 19 | +# v2.2 — silently returned, recv loop never ran |
| 20 | +async with ws.connect() as session: |
| 21 | + await session.run_forever() |
| 22 | + |
| 23 | +# v2.3 — wrap a subscribe_* call before run_forever() |
| 24 | +async with ws.connect() as session: |
| 25 | + await session.subscribe_ticker(tickers=["EXAMPLE-25-T"]) |
| 26 | + await session.run_forever() |
| 27 | +``` |
| 28 | + |
| 29 | +No production usage of the silent shape is known; the foot-gun was the |
| 30 | +bug. |
| 31 | + |
| 32 | +### Cooperative shutdown via `run_forever(stop_event=...)` |
| 33 | + |
| 34 | +Per closed #177 / #186, `run_forever()` now accepts an optional |
| 35 | +`stop_event: asyncio.Event | None = None`. When set, the recv loop |
| 36 | +closes the connection and exits cleanly without raising `CancelledError` |
| 37 | +— typically wired to `SIGINT` so Ctrl+C drains in-flight dispatches: |
| 38 | + |
| 39 | +```python |
| 40 | +import asyncio |
| 41 | +import signal |
| 42 | + |
| 43 | +stop = asyncio.Event() |
| 44 | +asyncio.get_running_loop().add_signal_handler(signal.SIGINT, stop.set) |
| 45 | + |
| 46 | +async with ws.connect() as session: |
| 47 | + await session.subscribe_ticker(tickers=["EXAMPLE-25-T"]) |
| 48 | + await session.run_forever(stop_event=stop) |
| 49 | +``` |
| 50 | + |
| 51 | +Omitting `stop_event` preserves v2.2 behavior — external cancellation |
| 52 | +still propagates. |
| 53 | + |
| 54 | +### WS resubscribe-window frame stashing |
| 55 | + |
| 56 | +Per #176, the reconnect path used to silently drop data frames that |
| 57 | +arrived between the moment `SubscriptionManager` cleared its |
| 58 | +`sid → client_id` map and the moment the new sids landed in the |
| 59 | +subscribe-response handler. Under burst reconnects on high-volume |
| 60 | +channels (`ticker`, `trade`, `fill`), this lost tens of messages per |
| 61 | +reconnect. |
| 62 | + |
| 63 | +`SubscriptionManager` now stashes those frames in a per-sid bounded |
| 64 | +`collections.deque(maxlen=stash_maxlen)` (default 1000 per sid). After |
| 65 | +`resubscribe_all` completes, the stash is drained through the normal |
| 66 | +dispatch path so seq trackers advance, orderbook state applies, and |
| 67 | +iterator consumers receive the frames in arrival order. No API change — |
| 68 | +behavior change only. |
| 69 | + |
| 70 | +### Async RSA-PSS sign offload — `KalshiAuth.close()` for standalone users |
| 71 | + |
| 72 | +Per #178, `KalshiAuth.sign_request_async()` now routes the RSA-PSS sign |
| 73 | +through a dedicated `ThreadPoolExecutor(max_workers=2)` lazy-initialised |
| 74 | +on first use, so signs don't queue behind `getaddrinfo` / file I/O / other |
| 75 | +`to_thread()` work during WS reconnect storms. |
| 76 | + |
| 77 | +If you use `KalshiAuth` **through** `KalshiClient` / `AsyncKalshiClient`, |
| 78 | +`client.close()` already tears the executor down for you and no migration |
| 79 | +is required. If you instantiate `KalshiAuth` **standalone** (e.g. signing |
| 80 | +requests through a custom transport), call `auth.close()` to release the |
| 81 | +executor: |
| 82 | + |
| 83 | +```python |
| 84 | +from kalshi import KalshiAuth |
| 85 | + |
| 86 | +auth = KalshiAuth.from_key_path("your-key-id", "~/.kalshi/private_key.pem") |
| 87 | +try: |
| 88 | + headers = await auth.sign_request_async("GET", "/trade-api/v2/markets") |
| 89 | + ... |
| 90 | +finally: |
| 91 | + auth.close() |
| 92 | +``` |
| 93 | + |
| 94 | +--- |
| 95 | + |
| 96 | +## v2.1 → v2.2 |
| 97 | + |
| 98 | +v2.2 is **soft-breaking at the response-parse boundary only** (per |
| 99 | +#157 / #172). The wire format is unchanged. The behavior change: server |
| 100 | +omission of a previously-optional spec-required field now raises |
| 101 | +`pydantic.ValidationError` at parse time, instead of silently producing |
| 102 | +`field=None` and pushing the surprise to a downstream `NoneType` |
| 103 | +attribute access. |
| 104 | + |
| 105 | +### What changed |
| 106 | + |
| 107 | +226 fields across REST models, WS payloads, and helper classes flipped |
| 108 | +from `Optional[X] = None` to required, matching what the OpenAPI / |
| 109 | +AsyncAPI specs already declared. |
| 110 | + |
| 111 | +Affected response model categories include `Market`, `Order`, `Fill`, |
| 112 | +`Event`, `EventMetadata`, `Settlement`, `Trade`, `IncentiveProgram`, |
| 113 | +`RFQ`, `Quote`, `OrderGroup` plus its three group-response shapes, and |
| 114 | +11 WebSocket payloads — including the new `*_ts_ms` millisecond |
| 115 | +timestamps and the `outcome_side` / `book_side` direction encoding the |
| 116 | +server already emits. See `CHANGELOG.md` for the full per-model |
| 117 | +breakdown. |
| 118 | + |
| 119 | +### Recovery |
| 120 | + |
| 121 | +If you parse live responses and previously branched on `field is None` |
| 122 | +for an optional you read from the SDK, you may now hit |
| 123 | +`pydantic.ValidationError` at the parse site when the server omits the |
| 124 | +field on a malformed event. Wrap the call site: |
| 125 | + |
| 126 | +```python |
| 127 | +from pydantic import ValidationError |
| 128 | + |
| 129 | +try: |
| 130 | + market = client.markets.get(ticker) |
| 131 | +except ValidationError as exc: |
| 132 | + logger.warning("skipping malformed market %s: %s", ticker, exc) |
| 133 | + continue |
| 134 | +``` |
| 135 | + |
| 136 | +The vast majority of fields were already populated in practice — only |
| 137 | +models with legitimate live-server omission gaps need the try/except. |
| 138 | +Two known cases (`Event.product_metadata`, `EventMetadata.market_details`) |
| 139 | +ship in v2.3 with `server_omits_despite_required` exclusions baked in |
| 140 | +and need no caller action. |
| 141 | + |
| 142 | +### `extra="allow"` everywhere |
| 143 | + |
| 144 | +All WS envelope and helper classes (and the five REST helpers from the |
| 145 | +v2.0 sweep — `Page`, `Orderbook`, `OrderbookLevel`, `BidAskDistribution`, |
| 146 | +`PriceDistribution`) now uniformly use `model_config = ConfigDict(extra="allow")`. |
| 147 | +Additive server fields no longer raise; they land on |
| 148 | +`__pydantic_extra__` and round-trip through `model_dump()`. |
| 149 | + |
| 150 | +--- |
| 151 | + |
3 | 152 | ## v2.0 → v2.1 |
4 | 153 |
|
5 | 154 | v2.1 syncs the SDK to OpenAPI spec v3.18.0. It's **additive at the resource |
@@ -402,9 +551,11 @@ async for market in async_client.markets.list_all(status="open"): |
402 | 551 | ... |
403 | 552 | ``` |
404 | 553 |
|
405 | | -`list_all()` walks cursors until the server returns no more pages, with a |
406 | | -1000-page safety cap and a cursor-repeat detector that raises `KalshiError` |
407 | | -if the server hands back a duplicate cursor. |
| 554 | +`list_all()` is unbounded by default — it walks cursors until the server |
| 555 | +returns no more pages. Pass `max_pages=N` for an explicit cap; passing |
| 556 | +`max_pages=0` raises `ValueError`. The cursor-repeat detector still raises |
| 557 | +`KalshiError` if the server hands back a duplicate cursor. See |
| 558 | +[Pagination](pagination.md) for the canonical contract. |
408 | 559 |
|
409 | 560 | ## Still missing? |
410 | 561 |
|
|
0 commit comments