Skip to content

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

Closed

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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