fix(ws): TimeoutError wrap + zombie-subscription cleanup on gap-recovery failure#364
Conversation
ChannelsManager._wait_for_response only caught ConnectionClosed; the bare TimeoutError raised by asyncio.wait_for escaped to subscribe() / unsubscribe() / update_subscription() callers. The top-of-loop 'remaining <= 0' guard never reached because the next iteration never ran. Add a TimeoutError handler that raises the same structured KalshiSubscriptionError with channel / client_id / op populated, so callers branching on SDK exception types (#213) retain the recovery surface for the timeout case. SubscriptionManager.broadcast_error put an error sentinel on the queue but never popped the Subscription. After a failed resubscribe_one in _handle_seq_gap (or the KalshiBackpressureError rollback in _process_frame), the dead sub stayed in _subscriptions with server_sid cleared — user-driven unsubscribe short-circuited on the stuck entry, the next reconnect's resubscribe_all resurrected it on the server with a fresh sid, and the dispatcher routed frames into the closed queue (silent data loss + server-quota leak). Pop the Subscription from _subscriptions / _sid_to_client after delivering the sentinel so the zombie cannot resurrect. Closes #314, #315
Code Review — PR #364Overall: Two clean, targeted bug fixes with good test coverage. The root causes are correctly identified and the fixes are minimal. A few things worth discussing below. Fix 1 —
|
| Area | Status |
|---|---|
| Bug fix correctness | ✅ Both fixes are correct |
| Minimal footprint | ✅ Only the necessary lines changed |
| Test coverage | ✅ with one caveat: _sid_to_client branch in gap-recovery test passes vacuously |
| Comment style | |
from None suppression |
from e for better debuggability |
No blockers. The _sid_to_client test observation is the most worth addressing before merge, as it could give false confidence that the gap-recovery path is fully covered.
|
Round-2 review addressed in 09ea50a:
|
Code ReviewOverviewThis PR closes two HIGH-severity WS integrity issues: a bare Fix #314 —
|
| Test | Verdict |
|---|---|
TestIssue314 — subscribe timeout surfaces KalshiSubscriptionError with correct fields |
✅ Good — NeverRecv.recv() via asyncio.Event().wait() cleanly simulates a hung connection |
TestIssue315 gap-recovery — both maps cleared, resubscribe_all is a no-op |
✅ Good — verifies the _subscriptions == {} postcondition explicitly |
TestIssue315 backpressure — same cleanup through the KalshiBackpressureError path |
✅ Good — confirms broadcast_error is the single cleanup point regardless of error type |
One minor nit — timeout=0.05 in test_issue_314:
await mgr._wait_for_response(
msg_id=42,
timeout=0.05, # ← may be tight on a loaded CI runner
...
)50 ms is fine for a mocked connection on a healthy machine but can produce occasional flakes on a heavily loaded CI host. 0.1 or even 0.2 would cost negligible wall time and provide a larger margin. Not a blocker, just a robustness suggestion.
Summary
Both fixes are correct, minimal, and well-tested. The only actionable suggestion is the _wait_for_response op parameter narrowing (eliminates two # type: ignore lines) and the optional timeout bump in the regression test. Neither is a blocker.
Verdict: Approve with optional nits.
Summary
Two HIGH-severity WS integrity fixes from the Round-3 audit.
ChannelsManager._wait_for_responsenow wrapsasyncio.wait_for's bareTimeoutErrorin a structuredKalshiSubscriptionErrorso callers retain thechannel/client_id/oprecovery surface promised by #213.SubscriptionManager.broadcast_errornow pops the deadSubscriptionfrom_subscriptions/_sid_to_clientso a failed gap-recovery (or backpressure rollback) doesn't leave a zombie sub that the next reconnect'sresubscribe_allresurrects on the server — preventing silent data loss into a closed queue and server-quota leak.Issues closed
_wait_for_responseleaksTimeoutErrorinstead ofKalshiSubscriptionErrorSubscriptionthat resurrects on next reconnectBehavioral changes
KalshiSubscriptionError(withchannel/client_id/oppopulated) instead of the bareTimeoutErrorit used to leak fromasyncio.wait_for.broadcast_erroris called for a subscription (gap-recovery failure, backpressure rollback), the subscription is removed from_subscriptions/_sid_to_client. The next reconnect'sresubscribe_allno longer re-establishes it on the server, andunsubscribe(client_id)no longer needs to short-circuit on a stuck entry.Tests
tests/ws/test_client.py::TestIssue314WaitForResponseTimeout::test_issue_314_subscribe_raises_kalshi_subscription_error_on_timeout— drives_wait_for_responseagainst a connection whoserecv()hangs forever and asserts the structuredKalshiSubscriptionErrorsurfaces withchannel/client_id/op.tests/ws/test_client.py::TestIssue315ZombieSubscriptionCleanup::test_issue_315_failed_resubscribe_removes_zombie_subscription— simulates the_handle_seq_gapfailure path and asserts_subscriptions/_sid_to_clientare clean and a subsequentresubscribe_allsends nothing.tests/ws/test_client.py::TestIssue315ZombieSubscriptionCleanup::test_issue_315_backpressure_path_also_removes_subscription— covers theKalshiBackpressureErrorrollback path through the samebroadcast_error.Source
Round-3 independent audit closure plan, wave W1.