Repository navigation
pubsub: breaking changes to channels and channel operations - #741
Open
owenpearson wants to merge 7 commits into
Open
owenpearson wants to merge 7 commits into
owenpearson wants to merge 7 commits into
Conversation
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>
|
Warning Review limit reachedYou'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. View limit details
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. Comment |
This branch was successfully deployed
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
Breaking changes to channels and channel operations for 4.0.0, one commit each, with an
UPDATING.mdentry 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 onchannel.options.test/utscloses 8 gated Test IDs (2 REST, 6 realtime) and 2 adapted-test deviations, anddeviations.mdis 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, RESTset_options, because it falls out of the same change.channel.optionsis theChannelOptionslast set, not a dict. The cipher is derived from it and cached only becauseCbcChannelCiphercarries the CBC chaining state; the cache is rebuilt whenever the options change, so the separate__cipherthat drifted is gone.channels.getwith options that left it out, realtimeset_options, and realtimecipher=Noneall kept encrypting. REST Presence captured the cipher once, when it was built. Every reader, LiveObjects' RTO2b mode check included, now reads the current options.set_options, andchannels.get(name, ChannelOptions(...))works on REST, where it raisedTypeError. The kwargs and dict forms keep working.paramsormodesthat 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 (whichtest_channel_get_channel_with_same_paramsexpects) still doesn't raise.channel.options.paramsrather thanchannel.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. RemovesChannels.__getattr__, which the realtimeChannelsinherited.client.channels.my_channelraisesAttributeError; useclient.channels.get('my_channel').httpauth_test.pyare updated.pubsub: encode a space in a channel name as %20 in HTTP paths(RSL2, RSL8).parse.quoteinstead ofquote_plus, with:still literal.status()now escapes the name.quotetoo.+channel. It is still reachable aschannels.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 aNonereason from surfacing asTypeError._ensure_activenow just calls it._attach_implicitlythat 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.attach()andsubscribe()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.detach()is a no-op (RTL5a).detach()raises (RTL5b).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. Therealtime_request_timeoutnow runs from the HEARTBEAT going out, not from the call.asyncio.wait_forif 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._request_state(ATTACHING), so the answering ATTACHED reaches the internal emitter, andset_optionsno longer hangs.RSL2 evidence
test/ably/testapp.pydoes, from ably-common'stest-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>/messageswas stored in"rsl2 probe <id>".POST /channels/rsl2+probe+<id>/messageswas stored in"rsl2+probe+<id>".%20returned only the%20message. History via+and via%2Bboth returned only the+message, andstatus()agreed.+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.encodeURIComponent(channel.name), and ably-java withHttpUtils.encodeURIComponent. Both send%20.Skipped or scoped down
channels.get(name, same_options)on an attached channel raise, which existing users and a legacy test rely on not happening.raise Noneindetach()is left.attach()andset_options()now fall back to a 90001 when a state change has no reason, butdetach()still re-raises whatever the state change carries;deviations.mdentry 2.1 records it.test_rtl24_error_reason_attach_failure,test_rtl4c_error_cleared_on_attachandtest_rtl13b_attaching_detached_to_suspendedstay gated, now failing onerror_reasonrather thanTypeError.deviations.md: I removed theRTS2, RTS4c…row because it was recorded only for the__getattr__hazard, and the file records idiomatic spelling nowhere else. In the translation notes, aChannels.__iter__annotation note replaces the__getattr__one.Tests
test/uts, before: 1571 passed, 185 skipped. WithRUN_DEVIATIONS=1: 170 failed, 1571 passed, 15 skipped.test/uts, after: 1579 passed, 177 skipped. WithRUN_DEVIATIONS=1: 162 failed, 1579 passed, 15 skipped.test/unit: 287 passed after, including 6 new tests intest/unit/channel_options_test.pyfor the replace semantics.test/unitandtest/utson its own.test/ablysuite was not run, because it hits a flaky sandbox. These legacy tests were edited and not run:test/ably/http/httpauth_test.py: fourchannels.<name>sites becomechannels.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:
v4-exceptions: conflicts inUPDATING.md,test/uts/deviations.mdandably/pubsub/realtime/channel.py.detach(). pubsub: breaking changes to exceptions #737 swaps theAblyExceptionarguments on the RTL5b raise, and this PR moves that line. Resolve by keeping this PR's order of checks with pubsub: breaking changes to exceptions #737's(…, 400, 90001).v4-auth-tokens: conflicts inUPDATING.mdandtest/uts/deviations.md.v4-http-client-options: conflicts inUPDATING.mdandtest/uts/deviations.md.v4-values-typesisn't pushed yet, so I couldn't trial-merge it. Expect conflicts inUPDATING.md,deviations.mdand probably the RESTPresence.__init__inably/pubsub/types/presence.py.Presence.__init__, this PR replaces the base path and the captured cipher.ably/pubsub/realtime/presence.py, this PR changes one line: RTP8d'sasyncio.create_task(channel.attach())becomeschannel._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.🤖 Generated with Claude Code