Repository navigation
Description
Context
Surfaced by the multi-LLM review of PR #403 (perps/margin API, branch feat/perps-api). Two PerpsClient / AsyncPerpsClient __init__ argument-precedence footguns remain after the review-fix commits (the partial-auth fail-fast in f2ef973 and the demo=True + explicit base_url conflict guard are already landed; these two are the deferred items). Both can silently point a real-money client at the wrong environment.
Problem
(a) config= silently swallows demo= / base_url= / timeout= / max_retries=
kalshi/perps/client.py:109-132 and kalshi/perps/async_client.py:100-123:
if config is not None:
self._config: PerpsConfig = config
else:
if demo and base_url is not None and ...: # only reached when config is None
...When config= is supplied, the entire else branch is skipped, so demo, base_url, timeout, and max_retries are accepted by the signature and then silently discarded. Confirmed on current code:
c = PerpsClient(config=PerpsConfig.production(), demo=True)
c._config.base_url # https://external-api.kalshi.com/trade-api/v2 (PRODUCTION)
c._config.ws_base_url # wss://external-api-margin-ws.kalshi.com/... (PRODUCTION)demo=True is ignored and the client connects to production — a real-money footgun for anyone who reads demo=True and assumes the sandbox.
(b) Non-demo base_url override leaves the WS feed on production (split env)
There is a base_url shorthand (and KALSHI_PERPS_API_BASE_URL in from_env, client.py:175 / async_client.py:159) but no ws_base_url shorthand and no KALSHI_PERPS_WS_BASE_URL env var. The demo=True + base_url mismatch is guarded (client.py:112-121 / async_client.py:103-112), and if demo: sets the demo WS URL (client.py:125-127 / async_client.py:116-118), but a plain non-demo base_url override is not guarded — ws_base_url stays at its PerpsConfig default of PERPS_PRODUCTION_WS_URL. Confirmed:
c = PerpsClient(base_url="http://localhost:9000/trade-api/v2")
c._config.base_url # http://localhost:9000/trade-api/v2 (local proxy)
c._config.ws_base_url # wss://external-api-margin-ws.kalshi.com/... (PRODUCTION)REST goes to the local proxy while the margin WS feed silently stays on production. The PerpsConfig.__post_init__ split-REST/WS guard (kalshi/perps/config.py:106-120) does not catch this — it only rejects the specific prod-REST + demo-WS / demo-REST + prod-WS host pairs, so a localhost/proxy REST host paired with the prod WS host passes through.
Proposed fix
Minimal, in both client.py and async_client.py __init__ (keep sync/async identical):
-
(a) When
config is not None, hard-fail if any environment-bearing shorthand is also explicitly set. Use a sentinel forbase_url/timeout/max_retries(defaultNonealready distinguishes "unset") and treatdemo=Trueas explicit:if config is not None: conflicting = [ name for name, val in ( ("demo", demo), ("base_url", base_url), ("timeout", timeout), ("max_retries", max_retries), ) if val ] if conflicting: raise ValueError( f"config= cannot be combined with {conflicting}; " "set those on the PerpsConfig instead." ) self._config = config
-
(b) Add a
ws_base_url: str | None = Nonekwarg to__init__, thread it intoconfig_kwargs["ws_base_url"], and readKALSHI_PERPS_WS_BASE_URLinfrom_env(alongside the existingKALSHI_PERPS_API_BASE_URL). Add it to thePerpsClientInitKwargsTypedDict. Then guard a non-demobase_urloverride that lacks a matchingws_base_url:if base_url and not demo and ws_base_url is None: raise ValueError( "base_url override without ws_base_url would leave the perps WS feed " "pinned to production (split REST/WS environment). Pass ws_base_url too " "(or KALSHI_PERPS_WS_BASE_URL), or use PerpsConfig.production()/demo()." )
(Document
KALSHI_PERPS_WS_BASE_URLindocs/perps.md:45next toKALSHI_PERPS_API_BASE_URLand in thefrom_envdocstring.)
Acceptance criteria
-
PerpsClient(config=PerpsConfig.production(), demo=True)raisesValueError(and the same forbase_url=,timeout=,max_retries=combined withconfig=); same forAsyncPerpsClient. -
PerpsClient(base_url="http://localhost:9000/trade-api/v2")raisesValueError(split-env) unless a matchingws_base_urlis also supplied; same for async. -
__init__accepts aws_base_urlkwarg; supplying bothbase_urlandws_base_urlbuilds aPerpsConfigwhosews_base_urlfollows the override. -
from_envreadsKALSHI_PERPS_WS_BASE_URL; documented indocs/perps.mdand thefrom_envdocstring. - Regression tests in
tests/perps/test_review_fixes.py(mirroringTestPartialAuthFailFast): sync+asyncconfig=+demo/base_urlconflict raises; sync+async non-demobase_url-without-ws_base_urlraises; sync+asyncbase_url+ws_base_urltogether succeed and set both URLs.
Notes
- Adding the
config=conflict guard is breaking for any caller currently passing bothconfig=and an ignored shorthand — but today that combination silently does the wrong thing, so failing loudly is the safer behavior. Tagbreakingif a SemVer-minor isn't acceptable; the rest is additive. - Deferred from the PR perps: full Perps (margin) API — REST + WebSocket + SCM/Klear (#387) #403 fix pass because the review batched only the highest-severity findings (
f8cc960ws,da953b0klear,f2ef973rest/models). Part of the perps EPIC perps: [EPIC] Perps (margin) API — full SDK implementation tracker #387. - The
from_envkwargs.setdefault("base_url", ...)lines (client.py:175/async_client.py:159) mean the env var only fills in when the caller didn't passbase_url; the newws_base_urlenv read should follow the samesetdefaultpattern so an explicit kwarg still wins.
Activity
perps: config/init hardening — host-allowlist split, ws_base_url + pa…