Skip to content

pubsub: breaking changes to channels and channel operations - #741

Open
owenpearson wants to merge 7 commits into
integration/v4from
v4-channels
Open

owenpearson wants to merge 7 commits into
integration/v4from
v4-channels

Conversation

@owenpearson

Copy link
Copy Markdown
Member

Summary

Breaking changes to channels and channel operations for 4.0.0, one commit each, with an UPDATING.md entry for each breaking one. The headline is that setting channel options now replaces them, cipher included: set_options(ChannelOptions(params=...)) on an encrypted channel turns encryption off, so callers must pass the cipher again. That was the maintainer's decision, matching ably-js, java, cocoa, go, dotnet and ruby, with no warning when a cipher is dropped and no dict-style shim on channel.options.

test/uts closes 8 gated Test IDs (2 REST, 6 realtime) and 2 adapted-test deviations, and deviations.md is updated to match. Its header counts were re-measured after every commit.

Commits

  • pubsub: make channel options a ChannelOptions that set_options replaces (TB2, RTS3, RTL16, RSL7, RTS3c, RSN3c, RTL16a, RTS3c1). This folds in item 2, REST set_options, because it falls out of the same change.
    • channel.options is the ChannelOptions last set, not a dict. The cipher is derived from it and cached only because CbcChannelCipher carries the CBC chaining state; the cache is rebuilt whenever the options change, so the separate __cipher that drifted is gone.
    • Before this change the cipher could never be removed: REST channels.get with options that left it out, realtime set_options, and realtime cipher=None all kept encrypting. REST Presence captured the cipher once, when it was built. Every reader, LiveObjects' RTO2b mode check included, now reads the current options.
    • The REST channel gains set_options, and channels.get(name, ChannelOptions(...)) works on REST, where it raised TypeError. The kwargs and dict forms keep working.
    • A reattach used to compare the whole options, cipher included. Now only params or modes that differ from the current ones reattach an attaching or attached channel. RTL16a says "provided"; I read that as provided and different, as ably-js does, so repeating the same params on an attached channel (which test_channel_get_channel_with_same_params expects) still doesn't raise.
    • Migration: read channel.options.params rather than channel.options['params'], and pass the cipher again whenever you set the options of an encrypted channel.
  • pubsub: stop creating a channel for any unknown attribute of channels. Removes Channels.__getattr__, which the realtime Channels inherited.
    • Migration: client.channels.my_channel raises AttributeError; use client.channels.get('my_channel').
    • Four legacy sites in httpauth_test.py are updated.
  • pubsub: encode a space in a channel name as %20 in HTTP paths (RSL2, RSL8).
    • Paths use parse.quote instead of quote_plus, with : still literal.
    • status() now escapes the name.
    • REST presence and annotations build on the channel's base path, and serials use quote too.
    • Migration: HTTP data that 3.x stored under a name with a space lives in the + channel. It is still reachable as channels.get('my+channel').
  • pubsub: raise from attach() when the channel does not attach (RTL4d, RTL3b).
    • attach() raises unless the channel attaches. It raises the state's reason, or a 90001 when there is none, which also stops a None reason from surfacing as TypeError. _ensure_active now just calls it.
    • The two fire-and-forget attaches go through a new _attach_implicitly that logs failures instead of leaving task exceptions unretrieved. They are the reattach of a SUSPENDED channel on reconnect, and the RTP8d implicit attach on presence enter or update.
    • subscribe (RTL7g), presence and annotation subscribes, and LiveObjects all await their attach, so they raise in the same cases.
    • Migration: expect attach() and subscribe() to raise when the client is closed or the channel detached while an attach is pending.
  • pubsub: detach at once when the connection is not connected (RTL5a, RTL5b, RTL5j, RTL5l). detach() follows RTL5 in order, and from any connection state but CONNECTED it goes straight to DETACHED without sending a DETACH.
    • Before, it hung from CONNECTING, DISCONNECTED and the rest, and raised 90001 for a CLOSING connection.
    • Taking each connection state:
      • INITIALIZED, CONNECTING, DISCONNECTED, SUSPENDED: an ATTACHING or ATTACHED channel goes straight to DETACHED.
      • CLOSING, CLOSED: channels are already DETACHED by RTL3b, so detach() is a no-op (RTL5a).
      • FAILED: channels are already FAILED by RTL3a, so detach() raises (RTL5b).
    • Not breaking.
  • pubsub: defer a ping from DISCONNECTED and time it from the heartbeat (RTN13b, RTN13c, RTN13d).
    • ping() from DISCONNECTED now waits for the connection, as it already did from CONNECTING. The realtime_request_timeout now runs from the HEARTBEAT going out, not from the call.
    • It still fails at once from INITIALIZED, SUSPENDED, CLOSING, CLOSED and FAILED, and a deferred ping fails when the connection moves to one of those.
    • Migration: a ping from DISCONNECTED no longer raises 40000 at once. Bound it with asyncio.wait_for if needed.
  • pubsub: return from set_options once an attached channel reattaches (RTL16a). This is item 8, which turned out to be a two-line fix in the code commit 1 had touched.
    • The reattach goes through _request_state(ATTACHING), so the answering ATTACHED reaches the internal emitter, and set_options no longer hangs.
    • Not breaking.

RSL2 evidence

  • The sandbox. A probe created a throwaway sandbox app the way test/ably/testapp.py does, from ably-common's test-app-setup.json, published to a channel named with spaces through both encodings, read history back, and deleted the app.
    • POST /channels/rsl2%20probe%20<id>/messages was stored in "rsl2 probe <id>".
    • POST /channels/rsl2+probe+<id>/messages was stored in "rsl2+probe+<id>".
    • History via %20 returned only the %20 message. History via + and via %2B both returned only the + message, and status() agreed.
    • So the service reads + in a path as a literal plus, and 3.x's HTTP calls on such a channel went to a different channel from the one realtime clients attach to.
  • Other SDKs. ably-js builds the path with encodeURIComponent(channel.name), and ably-java with HttpUtils.encodeURIComponent. Both send %20.

Skipped or scoped down

  • Item 2 is folded into commit 1, as allowed.
  • Item 8 is included as its own commit.
  • RTL16a's "provided" is read as "provided and different", as described above. A literal reading, as ably-java implements it, would make a repeat channels.get(name, same_options) on an attached channel raise, which existing users and a legacy test rely on not happening.
  • The raise None in detach() is left. attach() and set_options() now fall back to a 90001 when a state change has no reason, but detach() still re-raises whatever the state change carries; deviations.md entry 2.1 records it.
  • The RTL24 reason plumbing is not done. A server DETACHED's error is still discarded, so test_rtl24_error_reason_attach_failure, test_rtl4c_error_cleared_on_attach and test_rtl13b_attaching_detached_to_suspended stay gated, now failing on error_reason rather than TypeError.
  • The missing-accessors table in deviations.md: I removed the RTS2, RTS4c… row because it was recorded only for the __getattr__ hazard, and the file records idiomatic spelling nowhere else. In the translation notes, a Channels.__iter__ annotation note replaces the __getattr__ one.

Tests

  • test/uts, before: 1571 passed, 185 skipped. With RUN_DEVIATIONS=1: 170 failed, 1571 passed, 15 skipped.
  • test/uts, after: 1579 passed, 177 skipped. With RUN_DEVIATIONS=1: 162 failed, 1579 passed, 15 skipped.
  • test/unit: 287 passed after, including 6 new tests in test/unit/channel_options_test.py for the replace semantics.
  • Each commit was checked out into a clean snapshot and passes ruff, test/unit and test/uts on its own.
  • The legacy test/ably suite was not run, because it hits a flaky sandbox. These legacy tests were edited and not run:
    • test/ably/http/httpauth_test.py: four channels.<name> sites become channels.get(...).
    • test/ably/http/httppresence_test.py: the two mock URLs use the literal : now sent, rather than %3A. respx matches either.

Conflicts with the other v4 PRs

Trial merges of each open PR's head into this branch:

  • pubsub: breaking changes to exceptions #737 v4-exceptions: conflicts in UPDATING.md, test/uts/deviations.md and ably/pubsub/realtime/channel.py.
  • pubsub: breaking changes to auth and tokens #738 v4-auth-tokens: conflicts in UPDATING.md and test/uts/deviations.md.
  • pubsub: breaking changes to HTTP and client options #739 v4-http-client-options: conflicts in UPDATING.md and test/uts/deviations.md.
  • v4-values-types isn't pushed yet, so I couldn't trial-merge it. Expect conflicts in UPDATING.md, deviations.md and probably the REST Presence.__init__ in ably/pubsub/types/presence.py.
    • In Presence.__init__, this PR replaces the base path and the captured cipher.
    • In ably/pubsub/realtime/presence.py, this PR changes one line: RTP8d's asyncio.create_task(channel.attach()) becomes channel._attach_implicitly().
  • deviations.md: besides the header counts and the counts paragraph near the end, this PR removes candidate issues and renumbers the rest of their tiers.
    • Tier 1: old 1.3 and 1.4 are removed, so 1.5 becomes 1.3.
    • Tier 2: 2.3 is removed, so 2.4 to 2.9 become 2.3 to 2.8. The fix: replace deprecated utcfromtimestamp with fromtimestamp #656 row now points at 2.3.
    • Tier 3: 3.14 is removed, so 3.15 to 3.17 become 3.14 to 3.16.
    • Another PR that renumbers the same tiers will need its references rechecked.

🤖 Generated with Claude Code

owenpearson and others added 7 commits October 26, 2026 20:55
channel.options was the dict ChannelOptions.to_dict() produced, and the
cipher a channel encrypted with lived beside it in a separate __cipher
that the REST options setter only ever overwrote when the dict had a
'cipher' key. to_dict() leaves a None cipher out, so the cipher could
never be removed: set_options(ChannelOptions(params=...)) on an encrypted
channel, or a repeat channels.get with options that left the cipher out,
kept encrypting while channel.options showed no cipher. REST Presence
also captured the channel's cipher once, when it was built.

channel.options is now the ChannelOptions last set (TB2, RTS3), and the
cipher is derived from it. Setting options replaces them whole, as ably-js,
java, cocoa, go, dotnet and ruby do, and rebuilds the cached cipher, so
options without a cipher turn encryption off (RTL16, RSL7, RTS3c, RSN3c).
Every reader, REST Presence and the LiveObjects RTO2b mode check
included, now reads the current options.

The REST channel gains set_options (RSL7), and channels.get(name,
ChannelOptions(...)) works on REST, where it raised TypeError. The kwargs
and dict forms channels.get already took keep working.

A reattach compared the whole options, cipher included. Per RTL16a and
RTS3c1 only params or modes that differ from the current ones now make an
attaching or attached channel reattach, so set_options reattaches, and
channels.get raises 40000, only for those. A change of cipher alone no
longer raises, and repeating the same params is still accepted, as
test_channel_get_channel_with_same_params expects. The spec's "provided"
is read as provided and different, as ably-js reads it.

This is a breaking change. Read channel.options.params rather than
channel.options['params'], and pass the cipher again whenever setting the
options of an encrypted channel. The two RSL7 UTS tests are un-gated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Channels.__getattr__ answered every attribute the collection did not
define with self.get(name), and the realtime Channels inherited it. So
hasattr(client.channels, 'x') was always true and created a channel
called x, a typo became a phantom channel rather than an AttributeError,
and client.channels.get_derived(...) failed with "'RealtimeChannel'
object is not callable".

It is removed, so an unknown attribute raises AttributeError on both
clients. This is a breaking change: client.channels.my_channel no longer
returns a channel; use client.channels.get('my_channel'), or
client.channels['my_channel'], instead. The legacy tests that relied on
it, and the deviations.md notes about it, are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The HTTP channel built its path with parse.quote_plus, which is form
encoding and writes a space as +. In a URL path the Ably service reads +
as a literal plus. Measured against a throwaway sandbox app, a publish to
/channels/rsl2+probe+<id>/messages was stored in a channel named
"rsl2+probe+<id>", which history read back through either + or %2B,
while /channels/rsl2%20probe%20<id>/messages reached "rsl2 probe <id>",
the channel a realtime client attaches to. So every HTTP operation on a
channel whose name has a space went to another channel. ably-js
(encodeURIComponent) and ably-java (HttpUtils.encodeURIComponent) both
send %20. Channel.status interpolated the name with no escaping at all,
so a name with "/", "?" or "#" addressed the wrong resource.

Channel names are now percent-encoded with parse.quote (RSL2), status()
escapes the name the same way (RSL8), REST presence and annotations
build on the channel's own base path rather than encoding the name
again, and message serials use parse.quote too. ":" is still left
literal, as RFC 3986 allows in a path segment.

This is a breaking change for HTTP data stored under a name with a
space: 3.x stored it in the "+" channel, which is still reachable as
channels.get('my+channel'). The two deviations.md rows for RSL2's space
and RSL8's escaping are removed and the RSL2 UTS test asserts %20. REST
presence used to escape ":" as %3A too, so the legacy presence tests now
mock the path with the literal ":" that is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
attach() raised only when the attach ended in SUSPENDED or FAILED. When
the connection closed during the attach, RTL3b moved the channel to
DETACHED and attach() returned None, as if it had attached; so did an
attach overtaken by a detach, which returned on the DETACHING state
change. RTL4d fails the attach in every case but ATTACHED. A SUSPENDED
or FAILED change with no reason also made it raise None, which Python
reports as a TypeError.

attach() now raises unless the channel attaches: the reason for the
state it moved to, else an AblyException with code 90001, as
_ensure_active (RTL33b1) already did, which now just calls attach(). The
two attaches nobody awaits, the reattach of a SUSPENDED channel when the
connection reconnects and the implicit attach of a presence enter or
update (RTP8d), go through _attach_implicitly, which logs a failure
rather than leaving an exception unretrieved on the task. subscribe and
the presence and annotation subscribes await their attach, so they raise
in the same cases.

This is a breaking change: code that closes the client, or detaches,
while an attach is pending must expect attach() and subscribe() to
raise. The RTL3b test asserts the failure the specification has, closing
its deviation, and the RTL5i test that overtakes an attach with a detach
now asserts the rejection the specification also allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
detach() requested DETACHING whatever the connection's state, and
_check_pending_state only sends the DETACH once the connection is
CONNECTED, so from CONNECTING, DISCONNECTED or any other state but
CONNECTED it awaited a state change nothing would produce, and never
returned. It also raised 90001 first whenever the connection was CLOSING
or FAILED, which are the old RTL5g, and from CONNECTING called connect(),
which is the old RTL5h; specification 5.0.0 replaced both with RTL5l.

detach() now follows RTL5 in order: nothing to do from INITIALIZED or
DETACHED (RTL5a), 90001 from FAILED (RTL5b), straight to DETACHED from
SUSPENDED (RTL5j), and, when the connection is in any state but CONNECTED,
straight to DETACHED with no DETACH sent (RTL5l). Only a CONNECTED
connection sends the DETACH and waits for the DETACHED (RTL5d). A
CLOSING or CLOSED connection has already moved its channels to DETACHED
(RTL3b) and a FAILED one to FAILED (RTL3a), so those cases now land on
RTL5a and RTL5b rather than the 90001 RTL5g raised.

This matters more now that release() requires a detached channel. It is
not a breaking change: a call that hung now returns, and one that raised
for a CLOSING connection now returns for its already DETACHED channel.
The two RTL5l UTS tests are un-gated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConnectionManager.ping admitted only CONNECTED and CONNECTING and raised
40000 at once from DISCONNECTED, where RTN13d defers a ping requested
while CONNECTING or DISCONNECTED until the connection is CONNECTED, and
RTN13b fails it only from INITIALIZED, SUSPENDED, CLOSING, CLOSED or
FAILED. It also entered asyncio.wait_for as soon as ping() was called,
so the whole connecting period was charged to realtimeRequestTimeout and
a deferred ping could time out before its HEARTBEAT went out, where
RTN13c with RTN13d counts the timeout from the HEARTBEAT.

ping() now admits DISCONNECTED too. A deferred ping waits until its
heartbeat is sent, which happens on CONNECTED as before, or until a
state that fails pending pings (SUSPENDED, CLOSING, CLOSED, FAILED)
fails it, and only then starts the realtimeRequestTimeout wait.

This is a behaviour change rather than a new API: a ping from
DISCONNECTED that raised 40000 at once now waits for the connection, up
to the connectionStateTtl after which it suspends and the ping fails.
Wrap the call in asyncio.wait_for to bound it. The three gated RTN13 UTS
tests now pass and are un-gated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
set_options on an ATTACHED channel whose params or modes changed sent
the ATTACH through _attach_impl() and then awaited the internal state
emitter. The channel never left ATTACHED, so the server's ATTACHED took
the RTL12 branch of _on_message, which emits "update" on the public
emitter only, and set_options never returned. It raised the state
change's reason, None included, for SUSPENDED and FAILED, and returned
for any other state.

The reattach now goes through _request_state(ATTACHING), so the channel
passes through ATTACHING and the ATTACHED that answers ends the wait,
which then raises as attach() does unless the channel attached (RTL16a,
RTL4d). On an ATTACHING channel the new ATTACH goes out and the wait
ends with the pending attach. Not a breaking change; a call that hung
now returns. The RTL16a UTS test is un-gated, and the two attach tests
that ran set_options as a task and cancelled it now await it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 44 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3c3289b4-cd65-440a-a6ea-c2a6fe82f659
Commits

Reviewing files that changed from the base of the PR and between 7032b4c and ce96102.

Files selected for processing: 25
  • UPDATING.md
  • ably/pubsub/http/annotations.py
  • ably/pubsub/http/channel.py
  • ably/pubsub/liveobjects/realtimeobject.py
  • ably/pubsub/realtime/channel.py
  • ably/pubsub/realtime/connectionmanager.py
  • ably/pubsub/realtime/presence.py
  • ably/pubsub/types/presence.py
  • test/ably/http/httpauth_test.py
  • test/ably/http/httppresence_test.py
  • test/unit/channel_options_test.py
  • test/uts/deviations.md
  • test/uts/realtime/unit/channels/channel_attach_test.py
  • test/uts/realtime/unit/channels/channel_attributes_test.py
  • test/uts/realtime/unit/channels/channel_connection_state_test.py
  • test/uts/realtime/unit/channels/channel_detach_test.py
  • test/uts/realtime/unit/channels/channel_options_test.py
  • test/uts/realtime/unit/channels/channel_server_initiated_detach_test.py
  • test/uts/realtime/unit/channels/channels_collection_test.py
  • test/uts/realtime/unit/connection/connection_ping_test.py
  • test/uts/realtime/unit/presence/realtime_presence_enter_test.py
  • test/uts/rest/unit/channel/get_message_test.py
  • test/uts/rest/unit/channel/history_test.py
  • test/uts/rest/unit/channel/rest_channel_attributes_test.py
  • test/uts/rest/unit/channel/update_delete_message_test.py
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch was successfully deployed

1 active deployment
staging/pull/741/features — ce96102b Deployed Oct 26, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant