Repository navigation
fix(realtime): handle channel errors and rejoins like realtime-js - #1433
Merged
Merged
Conversation
When the server refuses a postgres_changes subscription (e.g. the table isn't in the supabase_realtime publication), it sends a system message with status "error". Every message with status "error" made the channel rejoin: leave, wait for UNSUBSCRIBED, join. The rejoin ran inline on the websocket message loop, which is the only thing that reads the reply to the leave, so the loop hung until a heartbeat timeout forced a reconnect, and the refusal never reached systemFlow(). Handle these the way realtime-js (Phoenix's client) does: - A system "error" is logged and forwarded to systemFlow(); the channel stays joined. Only phx_error and a rejected join rejoin; a reply error to another push is logged. - scheduleRejoin() schedules the rejoin in the realtime scope after rejoinDelay instead of running it in the caller's coroutine, replacing a pending one; subscribe(), unsubscribe(), teardown and disconnect() cancel it. It is no longer suspend (@SupabaseInternal). - A rejoin sends the join again instead of leaving first; the server replaces the channel's current join. Enabling presence on a subscribed channel rejoins the same way. - Messages for an earlier join_ref are dropped, such as the server's close for the join a rejoin replaced. maxAttempts and rejoinDelay keep their meaning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sproctor
force-pushed
the
realtime-channel-errors
branch
from
October 23, 2026 02:40
eabed79 to
d7c78d1
Compare
resolveEvent took the first event in EVENTS whose condition matched, so overlapping conditions were settled by the set's iteration order: a successful join reply listing postgres changes matched both RPostgresServerChangesEvent and RSystemReplyEvent, and a system message about an expired access token both RSystemEvent and RTokenExpiredEvent. Now that RSystemEvent also handles system errors, the second overlap covers the "error" status too. Make the conditions mutually exclusive, each broader event leaving out what a more specific one handles, and resolve with singleOrNull, so the order of EVENTS doesn't matter. EVENTS is back in its original order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jan-tennert
reviewed
Oct 26, 2026
- Cancel a pending rejoin in updateStatus() when the channel becomes UNSUBSCRIBING or UNSUBSCRIBED, instead of in teardown(), unsubscribe() and disconnect() separately. disconnect() no longer casts to RealtimeChannelImpl, and cancelRejoin() is private. - Resolve events with firstOrNull again; testEachMessageHasOneEvent keeps the conditions from overlapping. - Shorten or remove comments. - RealtimeChannelRejoinTest uses Turbine for the sent messages, status and systemFlow(), and collects its postgresChangeFlow. ScriptedWebsocket no longer uses the delicate isClosedForReceive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jan-tennert
approved these changes
Oct 27, 2026
…/RealtimeChannel.kt
sproctor
added a commit
to sproctor/supabase-kt
that referenced
this pull request
Oct 27, 2026
Brings in supabase#1433, which this branch was stacked on, and supabase#1423, which also resets the heartbeat state, on connect. Conflict and resolution: - supabase#1423's reset on connect stored 0, which on this branch is a real ref, so every new socket started with heartbeat 0 pending and its first tick timed out. It now stores NO_HEARTBEAT, and this branch's reset in disconnect() is dropped as redundant: every socket goes through connect(). - Both sides added RealtimeHeartbeatTest; the tests are combined, this branch's reading sent messages with Turbine as ScriptedWebsocket now does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
What changed
When the server refuses a
postgres_changessubscription (for example, the table isn't in thesupabase_realtimepublication), it replies OK to the join and then sends asystemmessage withstatus: "error". supabase-kt treated every message withstatus: "error"as a channel error and rejoined: leave, wait forUNSUBSCRIBED, join. That rejoin ran inline on the websocket message loop, which is the only thing that reads the reply to the leave, so the loop hung until a heartbeat timeout forced a reconnect. Then it joined, was refused, and hung again. The refusal itself never reachedsystemFlow(), becauseRSystemEventonly handledstatus: "ok".This handles channel errors and rejoins the way realtime-js does (through Phoenix's JS client):
systemerror is reported, not rejoined. It's logged and forwarded tosystemFlow(), and the channel stays joined. Onlyphx_errorand a rejected join (aphx_replywithstatus: "error"while joining) rejoin, as in Phoenix. A reply error to some other push is logged.scheduleRejoin()launches the rejoin in the realtime scope afterrejoinDelayinstead of running it in the caller's coroutine (Phoenix'srejoinTimer). It replaces a pending rejoin, andsubscribe(),unsubscribe(), teardown anddisconnect()cancel it.scheduleRejoin()(@SupabaseInternal) is no longersuspend.rejoin(). Enabling presence on a subscribed channel (presenceChangeFlow()) rejoins the same way, so it no longer goes throughUNSUBSCRIBED.join_refare dropped, as in Phoenix'sisMember: for example, the server's close for the join a rejoin replaced, which would otherwise remove the channel.Unchanged:
maxAttemptsandrejoinDelaykeep their meaning. Phoenix retries forever with backoff; this keeps supabase-kt's fixed delay and limit, and a successful join still resets the count.Behavior change on token expiry. When a channel's access token expires on the server, Realtime sends a
systemerror ("Token has expired …") and closes the channel. Before, the error made the channel rejoin, which deadlocked as above, and the heartbeat timeout's reconnect then rejoined with a fresh token. Now the error goes tosystemFlow()and the server's close removes the channel, as in realtime-js. supabase-kt already sends refreshed tokens to joined channels, so this only matters when a refresh doesn't reach the server in time.Resolving each message to exactly one event (second commit)
RealtimeEvent.resolveEventtook the first event inEVENTSwhose condition matched, so overlapping conditions were settled by the set's iteration order. Two already overlapped: a successful join reply listing postgres changes matched bothRPostgresServerChangesEventandRSystemReplyEvent, and asystemmessage about an expired access token matched bothRSystemEventandRTokenExpiredEvent. The second commit makes the conditions mutually exclusive (each broader event leaves out what a more specific one handles), resolves withsingleOrNull, and leavesEVENTSin its original order.It's in this PR because this PR widens one of those overlaps:
RSystemEventnow also handlessystemmessages withstatus: "error", so whether an expired-token message reachedRTokenExpiredEventwould have depended on the order ofEVENTS, which I'd otherwise have had to reorder. If you'd rather review it on its own, I'm happy to split it into a separate PR; this one would then only keepRSystemEventfrom claimingRTokenExpiredEvent's messages.Why
We hit this in production: an app release subscribed to a table that wasn't in the publication yet. Every device's channel deadlocked until a heartbeat timeout, reconnected about every 37 seconds, and never received changes for any table, while
statuskept flipping back toSUBSCRIBED, so the app saw nothing wrong.Testing
RealtimeChannelRejoinTest, against a scripted socket (ScriptedWebsocket, test-only): a refused subscription reachessystemFlow()while the channel stays subscribed and nothing more is sent; a rejected join is retried with a join, not a leave; rejected joins give up aftermaxAttempts; a channel error rejoins without leaving; the server's close for the replaced join is ignored; a successful join resets the attempt count. All six fail on master. Without the earlier-join_refcheck, the close test fails; withsystemerrors still rejoining, the refusal test fails.RealtimeEventTest: asystemerror resolves toRSystemEvent, a rejected reply toRErrorEvent, a join reply listing postgres changes toRPostgresServerChangesEvent(and a rejected one toRErrorEvent), and an expired-tokensystemerror toRTokenExpiredEvent.testEachMessageHasOneEventchecks that every kind of message matches exactly one event, whichresolveEventrelies on; it fails with the previous conditions.testResubscribeOnPresenceChangenow expects the presence rejoin to join again without leaving../gradlew :realtime-kt:jvmTest :realtime-kt:detektpass.Not changed here:
RealtimeImpl.onMessagetakes any message whoserefequals the idle heartbeat ref (0) for a heartbeat reply, which swallows the server's messages for a socket's first join. That's #1434, which builds on this.🤖 Generated with Claude Code