feat: add on_heartbeat_ack callback and close_connection method - #54
Merged
AlexanderZ-Band merged 6 commits intoAug 30, 2026
Conversation
Exposes heartbeat-ack liveness so a caller can drive its own dead-threshold watchdog: today `topic == "phoenix"` responses are routed to a private callback and never reach public dispatch. on_heartbeat_ack fires on every ack; close_connection lets a watchdog force a close that flows through the existing reconnect classification, instead of a full shutdown().
DisconnectCallback and ReconnectCallback are both re-exported alongside PHXChannelsClient; HeartbeatAckCallback was missing from both the import and __all__, so band-sdk-python can't type its on_heartbeat_ack callback against it.
close_connection() sent an unbounded reason straight into the WS close frame with no length check; RFC 6455 caps control frames at 125 bytes total (2 for the code, 123 left for the reason), so a realistic watchdog message could raise an uncaught ProtocolError. Truncate to fit. Also, _classify_disconnect keyed off close_code, but that reflects the code the remote echoes back, not the 4000 we sent — a server that doesn't echo it (e.g. answering 1000) could silently defeat close_connection under the default reconnect_on_normal_close=False. Track that a close was force-initiated and always reconnect for it, bypassing classification instead of trusting the echo. Also swap reconnect_controller.py's raw close-code literals for websockets.frames.CloseCode (already RFC 6455-named there); _FORCED_CLOSE_CODE stays a private constant since 4000 has no RFC name (private-use range).
on_heartbeat_ack is typed sync but nothing stopped a caller from passing an async function by analogy with on_reconnect/on_disconnect; the coroutine was silently constructed and discarded, so its body never ran. Detect that case, close the coroutine, and log it as an error instead of a silent no-op. Also document that this callback runs inline on the message-routing hot path (unlike the two async callbacks) and must be synchronous and fast. Extract _invoke_callback_safely for the on_reconnect/on_disconnect try/except-log duplication. Add PHOENIX_TOPIC in phx_messages.py (matching PHXEvent's existing "phx_*" constants) instead of the raw "phoenix" literal duplicated in protocol_handler.py and supervisor.py.
A mutated-list flag reads the same and needs no nonlocal binding.
3 tasks
AlexanderZ-Band
added a commit
to band-ai/band-sdk-python
that referenced
this pull request
Aug 30, 2026
Removes the [tool.uv.sources] override early rather than waiting for PR band-ai/phoenix-channels-python-client#54 to merge and release. The >=0.3.0 floor now resolves against PyPI as a real consumer would see it, which currently fails (only <=0.2.3 is published) -- expected, and self-resolving the moment #54 releases 0.3.0, with no further edit needed here. uv.lock is intentionally left unregenerated (uv lock cannot resolve without the override); it will be regenerated once #54 releases.
amit-gazal-band
approved these changes
Aug 30, 2026
amit-gazal-band
left a comment
Collaborator
There was a problem hiding this comment.
LGTM. One non-blocking finding worth a follow-up:
close_connection() can leave _forced_close_pending stuck True, causing the wrong reconnect decision on a later disconnect.
close_connection()sets_forced_close_pending = Truethen callsawait connection.close(...)with no exception handling — unlike the equivalent close in_cleanup_connection, which is wrapped intry/except Exception.- The flag is only reset once the current disconnect cycle runs to completion (lines ~290–291).
- If
connection.close()raises before that cycle completes (e.g. cancellation during shutdown, a non-ConnectionClosederror), the exception propagates and the flag staysTrue. - On the next disconnect — even a legitimate server-initiated close (policy violation, or a normal close with
reconnect_on_normal_close=False) — the supervisor takes theforced_closebranch instead of running_classify_disconnect, reconnecting unconditionally and silently overriding the configured reconnect policy.
Suggest wrapping the connection.close() call in close_connection() in try/except like _cleanup_connection, and/or resetting _forced_close_pending on the exception path.
AlexanderZ-Band
deleted the
int-1323-add-shared-heartbeat-interval-dead-threshold-policy-to-band
branch
August 30, 2026 06:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In short: adds two abilities
band-sdk-pythonneeds to build a heartbeat "watchdog" — a way to get notified when the server confirms a heartbeat (on_heartbeat_ack), and a way to force a reconnect without permanently shutting the client down (close_connection). While building it, found and fixed two real bugs: an oversized close reason could crash the client, and a forced reconnect wasn't guaranteed to actually happen (it depended on a code the remote server echoes back, not something we control) — both fixed. Also cleaned up a few duplicated magic numbers/strings into shared, named constants.Part of INT-1323 —
band-sdk-core(band-ai/band-sdk-core#60, merged) added a sharedheartbeat_interval/dead_thresholdpolicy thatband-sdk-pythonwill use to drive a watchdog against this client. Today there's no public way to observe heartbeat-ack liveness or force a reconnect from outside:phxresponses are routed straight to a private callback (protocol_handler.py:158-161), and the only public teardown method,shutdown(), permanently ends the client.on_heartbeat_ack: Callable[[], None] | None— new constructor callback, mirrorson_reconnect/on_disconnect. Fires from the existing_handle_heartbeat_responseright after_pending_heartbeat_refclears; exceptions are caught and logged, same as the other two callbacks. Rejects an async callback passed by mistake (closes the coroutine and logs it as an error) instead of silently discarding it, since — unlikeon_reconnect/on_disconnect— this one runs inline on the message-routing hot path and must stay synchronous and fast.close_connection(reason: str)— new public method. Verifiedshutdown()doesn't fit a watchdog's "close and let it reconnect" need: it cancels the supervisor task and transitions toCLOSEDpermanently. Also verified (correcting an earlier assumption) thatband-sdk-python'sauto_reconnect=Falseonly guards its initial connect retry loop — once connected, the samePHXChannelsClientinstance's own supervisor loop owns every subsequent reconnect, so there's no outer loop to rebuild it.close_connectioncloses just the current socket with a close code (4000, RFC 6455 §7.4.2 private-use range) outside_classify_disconnect's special-cased codes, so the existing reconnect classification decides what happens next — no new disconnect-handling branch.reasonis truncated to fit RFC 6455 §5.5.1's 125-byte close-frame cap (123 bytes after the 2-byte code) before being sent — a realistic watchdog message could otherwise raise an uncaughtProtocolError._classify_disconnectreads the code the remote echoes, not the one we sent — a server that doesn't echo4000(e.g. answering1000) could silently defeatclose_connectionunder the defaultreconnect_on_normal_close=False. A_forced_close_pendingflag now bypasses classification entirely for a close we initiated ourselves.Also, while reviewing:
reconnect_controller.py's close-code branches (1000,1001,1008,1012,1013) now usewebsockets.frames.CloseCodeinstead of raw ints; extracted_invoke_callback_safelyfor theon_reconnect/on_disconnecttry/except-log duplication; added a sharedPHOENIX_TOPICconstant (phx_messages.py) instead of the"phoenix"literal duplicated inprotocol_handler.py/supervisor.py.Test plan
uv run pytest— 114 passeduv run ruff check/uv run ruff format --check— cleanuv run pyrefly check/uv run pyright— 0 errorsNote
Supersedes #53, which was opened as a cross-repo PR from a personal fork (
AlexanderZ-Band) because I didn't have write access to this repo at the time. Now that I do, this reopens the same branch/commits as a same-repo PR instead.