fix(auth): from_env preserves caller ownership of KalshiAuth + lazy env eval#362
Conversation
Two overlapping integrity bugs in KalshiClient.from_env / AsyncKalshiClient.from_env: #311 (HIGH): from_env recomputed _auth_owned from the input kwarg after __init__ had already encoded the correct ownership. Case C — caller passes auth=my_auth with env vars set — flipped ownership to True and client.close() then closed the caller's still-live shared KalshiAuth. Case B — caller passes key_id+private_key with no env vars — flipped ownership to False and leaked the sign-offload ThreadPoolExecutor. #316 (MEDIUM): from_env eagerly evaluated KalshiAuth.try_from_env() through kwargs.setdefault, so a malformed KALSHI_PRIVATE_KEY lingering in the process env raised KalshiAuthError even when the caller supplied an explicit auth=, key_id, private_key, or private_key_path. Fix: - Tighten __init__ ownership invariant: _auth_owned is True iff the constructor built the KalshiAuth from key_id+private-key kwargs; False for caller-supplied auth and for unauthenticated clients. - Rewrite from_env to consult the env lazily — only when the caller did not supply any explicit credential source. When from_env builds the auth itself from env vars, it explicitly claims ownership on the resulting client. Adds TestFromEnvOwnershipAndLazyEval / TestAsyncFromEnvOwnershipAndLazyEval covering both #311 cases and both #316 short-circuits, sync and async. Closes #311, #316
Code Review — PR #362Overall: Solid, surgical fix for two real bugs. The logic is correct, the comments explain the invariant clearly, and the tests cover the two primary regression scenarios. A few minor items below. What's correct
Post- Issues1. Trailing blank lines (minor, ruff-catchable) Both new test classes end with two added blank lines before the next class definition, giving three total (the existing blank line is still there as context). PEP 8 / ruff E302 wants exactly two.
2. Missing test:
3. Subtle asymmetry in caller_supplied_auth = (
"auth" in kwargs # membership check — catches auth=None
or kwargs.get("key_id") is not None # value check — misses key_id=None
or kwargs.get("private_key") is not None
or kwargs.get("private_key_path") is not None
)
No concerns with
Verdict: Approve with the trailing blank lines fixed before merge (ruff will catch them if CI runs linting). The missing |
Summary
KalshiClient.from_envand its async mirror had two overlapping integritybugs in how they handled
KalshiAuth. First (#311), the method recomputed_auth_ownedfrom the input kwarg after__init__had already encodedthe correct ownership, so a caller passing
auth=my_authwhile env vars wereset would have their auth shut down by
client.close()(closing a still-liveshared
KalshiAuth), and a caller passingkey_id+private_keywith no envvars would silently leak the sign-offload
ThreadPoolExecutorbecause theSDK forgot it owned the auth it had just built. Second (#316),
from_envunconditionally evaluated
KalshiAuth.try_from_env()even when the caller hadalready supplied explicit credentials, so a malformed
KALSHI_PRIVATE_KEYlingering in the process env would raise
KalshiAuthErrorbefore thecaller-supplied auth had a chance to win — turning a CI hygiene paper-cut
into a hard outage.
This PR tightens the ownership invariant in
__init__(_auth_ownedisTrue only when the constructor actually built a
KalshiAuth; False when thecaller supplied one or when there is no auth at all) and rewrites
from_envso it only consults the env when the caller has not provided
auth=,key_id,private_key, orprivate_key_path. Whenfrom_envbuilds theauth itself from env vars, it explicitly claims ownership on the resulting
client so
close()shuts it down.Issues closed
from_envno longer flips_auth_ownedfrom the input kwarg; constructor is the source of truth.from_envevaluatestry_from_env()lazily; explicitauth=/key_id/private_keyshort-circuits env eval.Behavioral changes
KalshiClient(...)/AsyncKalshiClient(...)with no auth credentials nowreports
_auth_owned == False(previouslyTrue, which was a mootinconsistency: there was nothing to own).
close()behavior is unchangedbecause it already gates on
self._auth is not None.from_env(auth=..., ...)andfrom_env(key_id=..., private_key=..., ...)no longer parse
KALSHI_PRIVATE_KEYfrom the environment; a malformed envPEM can no longer block a call that passes explicit credentials.
Tests
tests/test_client.py::TestFromEnvOwnershipAndLazyEval— sync regressions for from_env flips _auth_owned from input kwarg, breaking #210 ownership contract #311 (cases B and C) and from_env() eagerly evaluates try_from_env() even when caller passes auth= #316 (env not eagerly read whenauth=orkey_id+private_keyis supplied).tests/test_async_client.py::TestAsyncFromEnvOwnershipAndLazyEval— async mirror of the same four scenarios.Source
Round-3 independent audit closure plan, wave
W1.