You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
kalshi/perps/client.py:109-132 and kalshi/perps/async_client.py:100-123:
ifconfigisnotNone:
self._config: PerpsConfig=configelse:
ifdemoandbase_urlisnotNoneand ...: # 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:
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-demobase_url override is not guarded — ws_base_url stays at its PerpsConfig default of PERPS_PRODUCTION_WS_URL. Confirmed:
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 for base_url/timeout/max_retries (default None already distinguishes "unset") and treat demo=True as explicit:
ifconfigisnotNone:
conflicting= [
nameforname, valin (
("demo", demo), ("base_url", base_url),
("timeout", timeout), ("max_retries", max_retries),
) ifval
]
ifconflicting:
raiseValueError(
f"config= cannot be combined with {conflicting}; ""set those on the PerpsConfig instead."
)
self._config=config
(b) Add a ws_base_url: str | None = None kwarg to __init__, thread it into config_kwargs["ws_base_url"], and read KALSHI_PERPS_WS_BASE_URL in from_env (alongside the existing KALSHI_PERPS_API_BASE_URL). Add it to the PerpsClientInitKwargs TypedDict. Then guard a non-demo base_url override that lacks a matching ws_base_url:
ifbase_urlandnotdemoandws_base_urlisNone:
raiseValueError(
"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_URL in docs/perps.md:45 next to KALSHI_PERPS_API_BASE_URL and in the from_env docstring.)
Acceptance criteria
PerpsClient(config=PerpsConfig.production(), demo=True) raises ValueError (and the same for base_url=, timeout=, max_retries= combined with config=); same for AsyncPerpsClient.
PerpsClient(base_url="http://localhost:9000/trade-api/v2") raises ValueError (split-env) unless a matching ws_base_url is also supplied; same for async.
__init__ accepts a ws_base_url kwarg; supplying both base_url and ws_base_url builds a PerpsConfig whose ws_base_url follows the override.
from_env reads KALSHI_PERPS_WS_BASE_URL; documented in docs/perps.md and the from_env docstring.
Regression tests in tests/perps/test_review_fixes.py (mirroring TestPartialAuthFailFast): sync+async config= + demo/base_url conflict raises; sync+async non-demo base_url-without-ws_base_url raises; sync+async base_url+ws_base_url together succeed and set both URLs.
Notes
Adding the config= conflict guard is breaking for any caller currently passing both config= and an ignored shorthand — but today that combination silently does the wrong thing, so failing loudly is the safer behavior. Tag breaking if a SemVer-minor isn't acceptable; the rest is additive.
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 pass base_url; the new ws_base_url env read should follow the same setdefault pattern so an explicit kwarg still wins.
Context
Surfaced by the multi-LLM review of PR #403 (perps/margin API, branch
feat/perps-api). TwoPerpsClient/AsyncPerpsClient__init__argument-precedence footguns remain after the review-fix commits (the partial-auth fail-fast inf2ef973and thedemo=True + explicit base_urlconflict 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 swallowsdemo=/base_url=/timeout=/max_retries=kalshi/perps/client.py:109-132andkalshi/perps/async_client.py:100-123:When
config=is supplied, the entireelsebranch is skipped, sodemo,base_url,timeout, andmax_retriesare accepted by the signature and then silently discarded. Confirmed on current code:demo=Trueis ignored and the client connects to production — a real-money footgun for anyone who readsdemo=Trueand assumes the sandbox.(b) Non-demo
base_urloverride leaves the WS feed on production (split env)There is a
base_urlshorthand (andKALSHI_PERPS_API_BASE_URLinfrom_env,client.py:175/async_client.py:159) but nows_base_urlshorthand and noKALSHI_PERPS_WS_BASE_URLenv var. Thedemo=True + base_urlmismatch is guarded (client.py:112-121/async_client.py:103-112), andif demo:sets the demo WS URL (client.py:125-127/async_client.py:116-118), but a plain non-demobase_urloverride is not guarded —ws_base_urlstays at itsPerpsConfigdefault ofPERPS_PRODUCTION_WS_URL. Confirmed: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 specificprod-REST + demo-WS/demo-REST + prod-WShost pairs, so alocalhost/proxy REST host paired with the prod WS host passes through.Proposed fix
Minimal, in both
client.pyandasync_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:(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:(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.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
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.f8cc960ws,da953b0klear,f2ef973rest/models). Part of the perps EPIC perps: [EPIC] Perps (margin) API — full SDK implementation tracker #387.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.