Skip to content

perps: client init footguns — config= silently ignores demo/base_url, and non-demo base_url override leaves WS on prod #406

Description

@TexasCoding

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):

  1. (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:

    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
  2. (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:

    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_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.
  • 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 (f8cc960 ws, da953b0 klear, f2ef973 rest/models). Part of the perps EPIC perps: [EPIC] Perps (margin) API — full SDK implementation tracker #387.
  • The from_env kwargs.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestperpsPerps / margin (perpetual futures) APIsecuritySecurity-related concern

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions