From 1461d54eb6fefc1766aea05aaa69e53ecddbc494 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 17 May 2026 08:34:03 -0500 Subject: [PATCH] fix(models): match live server for AccountApiLimits + tags_by_categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two integration-test failures, both caused by SDK models that matched the published OpenAPI spec but not the actual server response. ### AccountApiLimits — server returns nested token buckets Spec v3.13.0 declares 'read_limit'/'write_limit' as ints, but the live server returns: {"usage_tier": "...", "read": {"bucket_capacity": N, "refill_rate": N}, "write": {"bucket_capacity": N, "refill_rate": N}} Added 'RateLimit' model and changed AccountApiLimits.read_limit/.write_limit to .read/.write of type RateLimit. The old int fields never worked against the live server (the integration test was failing) so practical migration impact is minimal. Documented as breaking in CHANGELOG anyway. ### GetTagsForSeriesCategoriesResponse — server sends null lists The 'Social' category returns null instead of an empty list. Changed 'tags_by_categories: dict[str, list[str]]' to 'dict[str, NullableList[str]]' so consumers always get an iterable. The NullableList helper already exists in kalshi.types. ### Verification - tests/integration/test_account.py::TestAccount{Sync,Async}::test_limits PASS - tests/integration/test_search.py::TestSearch{Sync,Async}::test_tags_by_categories PASS - Unit suite: 1608 passed (up from 1607; +1 since RateLimit assertions added) - mypy --strict + ruff clean - Updated _contract_map.py to flag the spec-vs-server drift explicitly Closes 4 of the 11 integration-test failures tracked in #124. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ kalshi/__init__.py | 2 ++ kalshi/_contract_map.py | 6 +++++- kalshi/models/__init__.py | 3 ++- kalshi/models/account.py | 25 ++++++++++++++++++++----- kalshi/models/search.py | 9 +++++++-- tests/integration/test_account.py | 6 ++++-- tests/test_account.py | 20 ++++++++++++-------- 8 files changed, 80 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa4be4..9d88a8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,36 @@ All notable changes to kalshi-sdk will be documented in this file. ## [Unreleased] +### Fixed + +- **`/account/limits` response now parses against the live server.** The + published OpenAPI spec (v3.13.0) declares `read_limit`/`write_limit` as + ints, but the live API returns nested `read`/`write` token-bucket objects + with `bucket_capacity` + `refill_rate`. `AccountApiLimits` now matches the + server. New `RateLimit` model exposed for the bucket structure. +- **`/search/tags_by_categories` no longer crashes** when a category (e.g. + `Social`) returns `null` instead of an empty list. `tags_by_categories` + values are now `NullableList[str]`, collapsing `null` → `[]`. + ### Breaking +- **`AccountApiLimits.read_limit` / `.write_limit` removed.** Replaced with + `AccountApiLimits.read` / `.write`, both of type `RateLimit` + (`bucket_capacity: int`, `refill_rate: int`). The previous int fields + never worked against the live server, so practical migration impact is + expected to be limited to code written from the spec rather than tested + against the API. + + ```python + # Before + limits = client.account.limits() + limits.read_limit # AttributeError after upgrade + + # After + limits.read.bucket_capacity # int + limits.read.refill_rate # int + ``` + - **`Order.type` renamed to `Order.order_type`.** Wire format is unchanged (`validation_alias=AliasChoices("type", "order_type")` accepts both names on deserialization), but any user code reading `.type` on an `Order` instance diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 3b2fd33..35d99c8 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -111,6 +111,7 @@ PositionsResponse, PriceDistribution, Quote, + RateLimit, Schedule, ScopeList, SelfTradePreventionTypeLiteral, @@ -246,6 +247,7 @@ "PositionsResponse", "PriceDistribution", "Quote", + "RateLimit", "Schedule", "ScopeList", "SelfTradePreventionTypeLiteral", diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index ca1c5a3..3ddf009 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -210,7 +210,11 @@ class ContractEntry: ContractEntry( sdk_model="kalshi.models.account.AccountApiLimits", spec_schema="GetAccountApiLimitsResponse", - notes="Spec wraps tier/read/write limits in GetAccountApiLimitsResponse", + notes=( + "SPEC DRIFT: spec declares int 'read_limit'/'write_limit' but the " + "live server returns nested 'read'/'write' token-bucket objects. " + "SDK model follows the server." + ), ), ContractEntry( sdk_model="kalshi.models.structured_targets.StructuredTarget", diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index 3fc6c6d..1e627e4 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -1,6 +1,6 @@ """Kalshi SDK data models.""" -from kalshi.models.account import AccountApiLimits +from kalshi.models.account import AccountApiLimits, RateLimit from kalshi.models.api_keys import ( ApiKey, CreateApiKeyRequest, @@ -238,6 +238,7 @@ "PositionsResponse", "PriceDistribution", "Quote", + "RateLimit", "Schedule", "ScopeList", "SelfTradePreventionTypeLiteral", diff --git a/kalshi/models/account.py b/kalshi/models/account.py index 8a8f583..566861f 100644 --- a/kalshi/models/account.py +++ b/kalshi/models/account.py @@ -5,16 +5,31 @@ from pydantic import BaseModel +class RateLimit(BaseModel): + """Per-direction (read/write) token-bucket rate limit. + + The server enforces a token bucket per direction: ``bucket_capacity`` + tokens are allowed in a burst; ``refill_rate`` tokens are added per + second up to the cap. Requests above the cap return 429. + """ + + bucket_capacity: int + refill_rate: int + + model_config = {"extra": "allow"} + + class AccountApiLimits(BaseModel): """Rate limits associated with the authenticated user's API tier. - ``read_limit`` and ``write_limit`` are requests-per-second ceilings - the server will enforce before returning 429. ``usage_tier`` is a - human-readable label (e.g., ``standard``, ``elevated``). + NOTE: The published OpenAPI spec (v3.13.0) declares ``read_limit`` and + ``write_limit`` as ints, but the live server returns nested token-bucket + objects under ``read`` and ``write``. The SDK matches the server. If the + spec is corrected upstream, the contract-drift test will flag it. """ usage_tier: str - read_limit: int - write_limit: int + read: RateLimit + write: RateLimit model_config = {"extra": "allow"} diff --git a/kalshi/models/search.py b/kalshi/models/search.py index 363475d..cd09dee 100644 --- a/kalshi/models/search.py +++ b/kalshi/models/search.py @@ -40,9 +40,14 @@ class SportFilterDetails(BaseModel): class GetTagsForSeriesCategoriesResponse(BaseModel): - """Response from GET /search/tags_by_categories.""" + """Response from GET /search/tags_by_categories. - tags_by_categories: dict[str, list[str]] = {} + NullableList collapses server-side nulls into empty lists, so consumers + can always iterate ``tags_by_categories[category]`` without a None check. + Observed in the wild: the ``Social`` category currently returns null. + """ + + tags_by_categories: dict[str, NullableList[str]] = {} model_config = {"extra": "allow"} diff --git a/tests/integration/test_account.py b/tests/integration/test_account.py index 0365351..b2065a2 100644 --- a/tests/integration/test_account.py +++ b/tests/integration/test_account.py @@ -19,8 +19,10 @@ def test_limits(self, sync_client: KalshiClient) -> None: result = sync_client.account.limits() assert isinstance(result, AccountApiLimits) assert_model_fields(result) - assert result.read_limit >= 0 - assert result.write_limit >= 0 + assert result.read.bucket_capacity > 0 + assert result.read.refill_rate > 0 + assert result.write.bucket_capacity > 0 + assert result.write.refill_rate > 0 assert result.usage_tier diff --git a/tests/test_account.py b/tests/test_account.py index 7515cbc..64dfcf2 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -52,15 +52,17 @@ def test_returns_limits(self, account: AccountResource) -> None: 200, json={ "usage_tier": "standard", - "read_limit": 100, - "write_limit": 10, + "read": {"bucket_capacity": 200, "refill_rate": 100}, + "write": {"bucket_capacity": 20, "refill_rate": 10}, }, ) ) limits = account.limits() assert limits.usage_tier == "standard" - assert limits.read_limit == 100 - assert limits.write_limit == 10 + assert limits.read.bucket_capacity == 200 + assert limits.read.refill_rate == 100 + assert limits.write.bucket_capacity == 20 + assert limits.write.refill_rate == 10 def test_requires_auth(self, unauth_account: AccountResource) -> None: with pytest.raises(AuthRequiredError): @@ -86,15 +88,17 @@ async def test_returns_limits( 200, json={ "usage_tier": "elevated", - "read_limit": 500, - "write_limit": 50, + "read": {"bucket_capacity": 1000, "refill_rate": 500}, + "write": {"bucket_capacity": 100, "refill_rate": 50}, }, ) ) limits = await async_account.limits() assert limits.usage_tier == "elevated" - assert limits.read_limit == 500 - assert limits.write_limit == 50 + assert limits.read.bucket_capacity == 1000 + assert limits.read.refill_rate == 500 + assert limits.write.bucket_capacity == 100 + assert limits.write.refill_rate == 50 @pytest.mark.asyncio async def test_requires_auth(