The package is now published to PyPI as ably-pubsub-server rather than ably, and
it installs into ably.pubsub rather than ably. Both ably and ably.pubsub are
namespace packages shared with the other
ably-* distributions, so neither exports anything of its own. The whole public
API is reached through ably.pubsub.server.
Example 3.x:
pip install ablyExample 4.0.0:
pip install ably-pubsub-serverExample 3.x code:
from ably import AblyRealtime, AblyRestExample 4.0.0 code:
from ably.pubsub.server import create_http_client, create_realtime_clientThe synchronous, HTTP-only flavour moves the same way:
Example 3.x code:
from ably.sync import AblyRestSyncExample 4.0.0 code:
from ably.pubsub.server.sync import create_http_clientWhere 3.x left you reaching into submodules such as ably.types.message or
ably.http.paginatedresult, import from ably.pubsub.server instead. It is
the whole public API, and ably.pubsub.server.sync is its synchronous
counterpart. The packages beneath them are internal and free to move. The
types a client returns, hands to a listener or accepts are exported there,
including those 3.x left in submodules only, such as ConnectionState and
PresenceMessage, as the section on exports below lists.
For example, the encryption helpers move from ably.util.crypto:
Example 3.x code:
from ably.util.crypto import generate_random_key, get_default_paramsExample 4.0.0 code:
from ably.pubsub.server import generate_random_key, get_default_paramsAblyRest and AblyRealtime are internal in 4.0.0 and raise TypeError if
constructed directly. Build clients through the factories instead, which take the same options
as keyword arguments. The API key is no longer accepted positionally, so pass it as key=:
Example 3.x code:
rest = AblyRest(key='xxx')
realtime = AblyRealtime(key='xxx')Example 4.0.0 code:
pubsub_http_client = create_http_client(key='xxx')
pubsub_realtime_client = create_realtime_client(key='xxx')The client classes are internal, so annotate against the prototypes the factories are declared to return. Each is exported from the same module as the factory that returns it.
Example 3.x code:
from ably import AblyRealtime, AblyRest
from ably.sync import AblyRestSync
async def publish(client: AblyRest, name: str) -> None: ...
async def subscribe(client: AblyRealtime, name: str) -> None: ...
def publish_blocking(client: AblyRestSync, name: str) -> None: ...Example 4.0.0 code:
from ably.pubsub.server import PubSubHttpClient, PubSubRealtimeClient
from ably.pubsub.server.sync import PubSubHttpClient as PubSubHttpClientSync
async def publish(client: PubSubHttpClient, name: str) -> None: ...
async def subscribe(client: PubSubRealtimeClient, name: str) -> None: ...
def publish_blocking(client: PubSubHttpClientSync, name: str) -> None: ...channels.release(name) on a realtime client now raises an AblyException with code 90011
if the channel is in any state other than INITIALIZED, DETACHED or FAILED, and leaves the
channel as it was. 3.x removed the channel from the collection whatever its state, which could
leave it attached in the Ably service, and later 3.x releases log a deprecation warning when
they do so. Detach the channel, and wait for that to complete, before releasing it:
Example 3.x code:
realtime.channels.release('my-channel')Example 4.0.0 code:
await pubsub_realtime_client.channels.get('my-channel').detach()
pubsub_realtime_client.channels.release('my-channel')Ten errors the library raised itself carried the Ably error code in status_code and the
HTTP status in code. They now carry them the right way round:
| Error | 3.x status_code, code |
4.0.0 status_code, code |
|---|---|---|
Detaching a FAILED realtime channel |
90001, 400 | 400, 90001 |
| A detach superseded by an attach | 90000, 409 | 409, 90000 |
| A queued message failed with no reason given | 80000, 500 | 500, 80000 |
| A NACK with no error | 50001, 500 | 500, 50001 |
| A vcdiff delta with no decoder configured | 40019, 40019 | 400, 40019 |
| A vcdiff delta that cannot be decoded | 40018, 40018 | 400, 40018 |
ChannelOptions given params or options that are not a dict |
40000, 400 | 400, 40000 |
If you branch on code or status_code for any of these, compare against the 4.0.0 values.
In 3.x a request that got no response, because the host could not be reached or did not
answer in time, raised the httpx exception itself from most methods, such as request,
publish, history and auth.authorize. In 4.0.0 every method raises an AblyException:
code 50003 and status 504 for a timeout, and code 80000 and status 500 for any other
network error. The httpx exception is its cause. A timeout or network error reaching
an auth_url is an AblyException with code 40170 and status 401, as for any other
auth_url failure.
Example 3.x code:
try:
await rest.channels.get('my-channel').publish('event', 'data')
except httpx.TimeoutException:
...Example 4.0.0 code:
try:
await pubsub_http_client.channels.get('my-channel').publish('event', 'data')
except AblyException as e:
if e.code == 50003:
...In 3.x any exception an auth_callback raised reached the caller as an AblyException with
code 40170 and status 401, the original only in its cause. In 4.0.0 an AblyException the
callback raises reaches the caller unchanged; any other exception is still wrapped as 40170.
On a realtime client, a callback error with status 403 now fails the connection, with an
80019 whose status is 403 and whose cause is the callback's error, whether it happens while
connecting, on a reauth or in an explicit authorize() call. 3.x went DISCONNECTED and
retried. Every 80019 the connection reports now carries the auth provider's error as its
cause.
If you check for 40170 after request_token or authorize to find a failure in your own
callback, check the code your callback raised instead. Raise a status 403 only for a client
that should stop connecting:
Example 4.0.0 code:
async def auth_callback(token_params):
if not await user_may_connect():
raise AblyException('Access revoked', 403, 40300)
return await fetch_token(token_params)The factories raised a bare ValueError in 3.x when given no means of authenticating, a
key together with key_name or key_secret, a token_details that is not a
TokenDetails, or use_token_auth=False without a key. In 4.0.0 each raises an
AblyException with code 40106 and status 401. push.admin.publish raised TypeError
for a recipient or data that is not a dict and ValueError for an empty one, and now
raises an AblyException with code 40000 and status 400 for all four.
These exceptions are also a ValueError, so except ValueError still catches them, but
except TypeError no longer catches the push.admin.publish ones:
Example 3.x code:
try:
await rest.push.admin.publish(recipient, data)
except TypeError:
...Example 4.0.0 code:
try:
await pubsub_http_client.push.admin.publish(recipient, data)
except AblyException as e:
if e.code == 40000:
...A client given a key together with an auth_callback, auth_url, token or token_details
now uses token auth, and keeps the key only as a means of renewing the token. 3.x used basic
auth with the key whenever one was present, never called the auth_callback or auth_url,
and discarded a token or token_details given alongside the key. So a client configured this
way now makes requests to your auth server, or presents the token you gave it, where 3.x did
neither.
To keep using basic auth with the key, pass use_token_auth=False:
Example 3.x code:
rest = AblyRest(key='xxx', auth_callback=callback)Example 4.0.0 code:
pubsub_http_client = create_http_client(key='xxx', auth_callback=callback, use_token_auth=False)Or drop the auth_callback, auth_url or token, which 3.x never used.
A client with an auth_url now sends the token params to it under the names and in the forms
the other Ably SDKs use, in the query string for GET and in the form body for POST:
- the client ID as
clientId, where 3.x sentclient_id - a
capabilitygiven as a dict orCapabilityas JSON, where 3.x sent its Python repr - a
ttlgiven as atimedeltaas an integer count of milliseconds, where 3.x sent1:00:00
Unset params are left out rather than sent empty. This changes what your auth server receives.
An auth server written for 3.x clients that reads client_id must read clientId instead, as it
already does for clients built with the other SDKs. There is no transition period in which both
are sent, so update the auth server before, or together with, the clients. The token params
passed to create_token_request, request_token and authorize keep their snake_case names.
Example 3.x auth server code:
client_id = request.args.get('client_id')Example 4.0.0 auth server code:
client_id = request.args.get('clientId')A realtime client built with a client_id now reports it from auth.client_id as soon as it is
created, as an HTTP client always has. 3.x held auth.client_id at None until the connection
reached CONNECTED. A realtime client built with a token_details that carries a client ID
likewise reports that ID straight away.
Because the client ID is known when the first token is requested, a realtime client now passes
it to its auth_callback as client_id in the token params, and to its auth_url as
clientId, which 3.x did not do. An auth server or callback that issues tokens from those params
now issues them for that client ID. Check that it does not reject or override a client ID it was
not expecting.
Code that read auth.client_id before connecting to tell whether the client was connected
should check connection.state instead.
A client_id passed in the token params to auth.authorize() now reaches the token request, the
auth_callback or the auth_url as given. The client's own client ID is only filled in when the
params name none. 3.x replaced an explicit client_id with the client's own whenever the client
had one, and wrote it into the dict the caller passed. The caller's dict is no longer modified.
So a client configured with one client ID that authorizes for another now receives a token for
the one it asked for, and then fails with an IncompatibleClientIdException (code 40102), as the
client ID of a client cannot change once configured. 3.x silently issued a token for the
configured client ID instead. Drop the explicit client_id, or pass the configured one:
Example 3.x code:
await rest.auth.authorize({'client_id': 'someone-else', 'ttl': 3600000})Example 4.0.0 code:
await pubsub_http_client.auth.authorize({'ttl': 3600000})Creating a client with both a client_id and a token_details whose own client ID is a different
one, and is not the wildcard *, now raises an IncompatibleClientIdException (code 40102) from
the factory. 3.x built the client: an HTTP client then sent requests under a token for the other
client ID, and a realtime client failed only once it connected. Make the two agree, or leave
client_id out and let the client take the token's.
Example 3.x code:
rest = AblyRest(token_details=token_details_for_bob, client_id='alice')Example 4.0.0 code:
pubsub_http_client = create_http_client(token_details=token_details_for_bob)A bare token string carries no client ID the client can read, so it cannot be checked when the
client is created. A realtime client still checks it against the client ID the server reports
when the connection opens, and fails the connection with 40102 if they conflict.
TokenDetails.capability now holds the capability as the JSON string the Ably service returned,
exactly as it was sent, or None where none is known. 3.x parsed it into a Capability. Wrap
the string in Capability where you indexed or compared it:
Example 3.x code:
token_details = await rest.auth.request_token()
operations = token_details.capability['my-channel']Example 4.0.0 code:
from ably.pubsub.server import Capability
token_details = await pubsub_http_client.auth.request_token()
operations = Capability(token_details.capability)['my-channel']Capability is unchanged as the way to build a capability token param, and its
add_operation_to_resource method now works where 3.x raised AttributeError. A TokenDetails
you construct yourself with a dict or Capability stores it as canonical JSON, and
TokenDetails.to_dict() now gives the capability as that string rather than as a dict. So an
auth server that returns to_dict() to its clients from an auth_url now sends the capability
as a JSON string, the form the Ably service itself returns, rather than as a JSON object.
A TokenDetails built from a token string alone, whether you construct it yourself, pass a
token, or have an auth_callback or auth_url return a bare token string or JWT, now has
expires, issued and capability set to None. 3.x invented an expiry an hour after the
TokenDetails was created, an issued of 0 and an empty capability. Code that read those
values must handle None; the client itself does, and leaves it to the Ably service to report
when such a token has expired.
Example 3.x code:
seconds_left = (rest.auth.token_details.expires - now_ms) / 1000Example 4.0.0 code:
expires = pubsub_http_client.auth.token_details.expires
seconds_left = None if expires is None else (expires - now_ms) / 1000auth.create_token_request() called without token params now applies the client's
default_token_params, as request_token() and authorize() already did. 3.x ignored them. So
an auth server whose client is configured with default_token_params and which signs token
requests with create_token_request() now issues tokens with those defaults, typically a shorter
ttl or a narrower capability than before. Check that the defaults are what you want your
clients' tokens to carry, or remove them.
Token params passed to create_token_request() or request_token() now replace the defaults
entirely, as they already did for authorize() and as every other Ably SDK does. 3.x merged them
over the defaults key by key in request_token(). Take care where a default capability or
ttl restricted the tokens: a request_token() call that names other params no longer carries
them, so its token gets the key's full capability and the service's default ttl. Pass every
param you want, including the defaults you still want:
Example 3.x code:
rest = AblyRest(key='xxx', default_token_params={'capability': capability})
await rest.auth.request_token({'ttl': 600000}) # carried the default capabilityExample 4.0.0 code:
pubsub_http_client = create_http_client(key='xxx', default_token_params={'capability': capability})
await pubsub_http_client.auth.request_token({'ttl': 600000, 'capability': capability})http_open_timeout, http_request_timeout, http_max_retry_count and
http_max_retry_duration on client.options used to read None unless you set them, while
the client applied defaults of its own. They now read back the values in effect: 4, 10, 3 and
15 by default. The timeouts and the retry duration are still in seconds, and are renamed
http_open_timeout_seconds, http_request_timeout_seconds and
http_max_retry_duration_seconds, as described below. Code that tested an
option for None, to tell whether it had been set, will no longer find it so.
time(), stats(), a REST channel's publish(), publish_message(), publish_messages()
and publish_name_data(), get_message() on REST and realtime channels, and
push.admin.publish() accepted a timeout keyword argument that was never applied: every
request used the client's http_open_timeout and http_request_timeout whatever was passed.
The argument is gone, and passing it raises TypeError. stats() also loses its unused
paginated argument. Set the timeouts on the client instead:
Example 3.x code:
await rest.time(timeout=5)Example 4.0.0 code:
pubsub_http_client = create_http_client(key='xxx', http_request_timeout_seconds=5)
await pubsub_http_client.time()In 3.x, http_open_timeout bounded connecting and http_request_timeout bounded each read
of a response, while writing a request body and waiting for a free pooled connection had no
timeout at all. http_request_timeout_seconds now bounds those too, so a request stuck writing to,
or waiting for, a wedged connection raises httpx.TimeoutException and is retried against a
fallback host instead of hanging. If you publish large bodies over a slow link, raise
http_request_timeout_seconds to fit:
pubsub_http_client = create_http_client(key='xxx', http_request_timeout_seconds=30)In 3.x, http_max_retry_count capped the total number of hosts a request was tried
against, the primary endpoint included, so the default of 3 meant the primary and two
fallbacks. It now counts fallback hosts only, as in the Ably specification and the other
Ably SDKs: a request goes to the primary endpoint and then to at most
http_max_retry_count fallback hosts. The default of 3 therefore tries one more host than
before, and http_max_retry_count=0, which used to fail every request, now tries the
primary endpoint alone. To keep the 3.x number of attempts, subtract one:
Example 3.x code:
rest = AblyRest(key='xxx', http_max_retry_count=3)Example 4.0.0 code:
pubsub_http_client = create_http_client(key='xxx', http_max_retry_count=2)log_level was stored on the client options and never read, so it changed nothing. It is
gone, and passing it raises TypeError. The library logs through Python's standard
logging module, to the ably.pubsub logger and the loggers beneath it, so configure that
logger instead to set the level or to choose where records go:
Example 3.x code:
rest = AblyRest(key='xxx', log_level=logging.DEBUG)Example 4.0.0 code:
import logging
logger = logging.getLogger('ably.pubsub')
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
pubsub_http_client = create_http_client(key='xxx')These options were deprecated in 3.x, or duplicated another option, and are gone in 4.0.0.
Passing any of them raises TypeError:
environment: pass the same value asendpoint, soenvironment='sandbox'becomesendpoint='sandbox'.rest_hostandrealtime_host: pass the host asendpoint, which one host now serves for both REST and realtime.auth_token: pass the token astoken, the name the factories document. The token is now read back asclient.options.tokenrather thanclient.options.auth_token.connection_state_ttl: this was never accepted as an option, and theclient.options.connection_state_ttlproperty, which nothing read, is removed. The value the server sends is onconnection.connection_details.connection_state_ttl.
Example 3.x code:
rest = AblyRest(key='xxx', environment='sandbox')
realtime = AblyRealtime(key='xxx', realtime_host='custom.ably.example.com')
rest = AblyRest(auth_token='xxx')Example 4.0.0 code:
pubsub_http_client = create_http_client(key='xxx', endpoint='sandbox')
pubsub_realtime_client = create_realtime_client(key='xxx', endpoint='custom.ably.example.com')
pubsub_http_client = create_http_client(token='xxx')max_message_size is now a client option, defaulting to 65536 bytes (64 KiB). A publish
whose messages exceed it between them raises an AblyException with code 40009 and is not
sent. In 3.x the realtime client checked a fixed 64 KiB limit and REST publishes were not
checked at all, so an oversized REST publish now fails before it reaches Ably rather than
being rejected by the server.
Both clients now measure a message the way the Ably specification does: the length of its name, client ID and string data, the byte length of binary data, and the length of the JSON of object data and of extras. 3.x measured the realtime publish as the whole encoded message, so some realtime publishes that 3.x refused are now sent. Raise the limit only if your Ably account allows larger messages:
pubsub_http_client = create_http_client(key='xxx', max_message_size=131072)The realtime client accepted a recover option, documented as resuming an earlier
connection from its recovery key, but connection recovery is not implemented: the option was
stored and never sent, so a client given one always started a fresh connection. It is gone,
and passing it raises TypeError. Remove it, and rely on the client's own resumption of a
dropped connection, which is implemented. Connection recovery may return in a minor release,
along with the recover option.
Example 3.x code:
realtime = AblyRealtime(key='xxx', recover=recovery_key)Example 4.0.0 code:
pubsub_realtime_client = create_realtime_client(key='xxx')This change is silent: comparing a timestamp with == against a datetime now returns
False rather than raising. PresenceMessage.timestamp is an int of milliseconds since
the unix epoch, as Message.timestamp already is. 3.x returned a naive UTC datetime for a
presence message received from Ably, and an aware one for a LEAVE the library synthesized.
An ordering comparison such as < against a datetime raises TypeError. Convert the
timestamp where you need a datetime:
Example 3.x code:
received_at = member.timestampExample 4.0.0 code:
from datetime import datetime, timezone
received_at = datetime.fromtimestamp(member.timestamp / 1000, tz=timezone.utc)Stats.interval_time is now an aware datetime in UTC, where 3.x returned a naive one. An
ordering comparison such as < against a naive datetime now raises TypeError, and ==
against one quietly returns False. Compare it with aware datetimes:
Example 3.x code:
if stat.interval_time >= datetime(2024, 1, 1):
...Example 4.0.0 code:
if stat.interval_time >= datetime(2024, 1, 1, tzinfo=timezone.utc):
...This change is silent: comparing error_code with a string, as in == '40101', now
returns False rather than raising. The error_code of the response that request()
returns is now the int parsed from the X-Ably-Errorcode header, where 3.x returned the
header's string. It is still None when the header is absent. Compare it with an int:
Example 3.x code:
if response.error_code == '40101':
...Example 4.0.0 code:
if response.error_code == 40101:
...The headers of the response that request() returns is now a read-only mapping from header
name to value, looked up case-insensitively, where 3.x returned a list of (name, value)
pairs. As before, names are lower-cased and a header sent more than once has its values
joined with ', '. Iterating over the mapping itself now yields only the names, so look a
header up by name, or iterate over .items():
Example 3.x code:
for name, value in response.headers:
...Example 4.0.0 code:
request_id = response.headers.get('X-Request-Id')
for name, value in response.headers.items():
...remove and remove_where on push.admin.device_registrations and
push.admin.channel_subscriptions now return None, where 3.x returned the underlying HTTP
response. A removal that fails raises an AblyException, so there is no status code to check:
Example 3.x code:
response = await rest.push.admin.device_registrations.remove(device_id)
assert response.status_code == 204Example 4.0.0 code:
await pubsub_http_client.push.admin.device_registrations.remove(device_id)For the same reason a PaginatedResult no longer has a response attribute. The
HttpPaginatedResponse that request() returns keeps status_code, success,
error_code, error_message and headers, so read those instead of response.
DeviceDetails.push is now a DevicePushDetails, with recipient, state and
error_reason attributes, where 3.x returned the dict the server sent. error_reason is an
AblyException. Indexing it, as in device.push['recipient'], now raises TypeError.
Read the attributes instead. A device can still be built or saved with push given as a
dict:
Example 3.x code:
token = device.push['recipient']['deviceToken']Example 4.0.0 code:
token = device.push.recipient['deviceToken']DeviceDetails and PushChannelSubscription now also ignore fields they do not know when
decoding a dict, where 3.x raised TypeError on any field the server added. A misspelled key
in a dict you pass to save() is therefore ignored rather than rejected.
set_options(ChannelOptions(params=...)) on an encrypted channel now turns encryption off.
channel.set_options(options), and channels.get(name, options) on a channel that already
exists, replace the channel's options whole rather than merging them, so options without a
cipher leave the channel unencrypted. 3.x kept the old cipher whenever the new options left
it out, and on a realtime channel even when they gave cipher=None. Pass the cipher again
whenever you set the options of an encrypted channel:
Example 3.x code:
channel = realtime.channels.get('my-channel', ChannelOptions(cipher=cipher_params))
await channel.set_options(ChannelOptions(params={'rewind': '1'}))Example 4.0.0 code:
channel = pubsub_realtime_client.channels.get('my-channel', ChannelOptions(cipher=cipher_params))
await channel.set_options(ChannelOptions(cipher=cipher_params, params={'rewind': '1'}))channel.options is now the ChannelOptions that was last set, rather than a dict, so read
its attributes rather than its keys:
Example 3.x code:
rewind = channel.options['params']['rewind']Example 4.0.0 code:
rewind = channel.options.params['rewind']HTTP channels now take a ChannelOptions too, through channels.get(name, options) or
await channel.set_options(options). channels.get raises when given options that would make
an attaching or attached realtime channel reattach, which only a change to params or modes
does; a change to the cipher alone no longer raises.
client.channels.my_channel used to get or create the channel called my_channel, because the
collection answered any attribute it did not define with a channel of that name. That made a
typo, or even hasattr(client.channels, ...), create a channel. In 4.0.0 an unknown attribute
raises AttributeError, so get the channel by name instead:
Example 3.x code:
channel = rest.channels.my_channelExample 4.0.0 code:
channel = pubsub_http_client.channels.get('my_channel')3.x wrote a space in a channel name as + in HTTP request paths, which the Ably service reads
as a literal plus, so HTTP publishes, history, presence, annotations, message reads and
status() for my channel all went to a different channel, my+channel, from the one
realtime clients attach to. 4.0.0 writes the space as %20, so they reach my channel.
Messages that 3.x stored over HTTP for such a name are in the + channel, and stay reachable
by that name:
Example 4.0.0 code:
stored_by_3x = pubsub_http_client.channels.get('my+channel')
history = await stored_by_3x.history()status() now escapes the channel name too, so a name containing /, ? or # no longer
addresses the wrong resource.
await channel.attach() raised only when the channel became SUSPENDED or FAILED. When the
connection closed while the channel was attaching, the channel became DETACHED and attach()
returned as if it had succeeded; so did an attach() overtaken by a detach(). In 4.0.0 it
raises an AblyException whenever the channel moves to any state but ATTACHED, carrying the
reason for that state, or code 90001 when there is none. subscribe(), which attaches, raises
in the same cases. Code that closes the client, or detaches, while an attach may be pending
should expect the exception:
Example 4.0.0 code:
try:
await channel.attach()
except AblyException:
# the channel did not attach, for instance because the client was closed meanwhile
...await connection.ping() raised an AblyException with code 40000 at once when the connection
was DISCONNECTED. In 4.0.0 it waits for the connection to reconnect, as it already did while
CONNECTING, and then times out realtime_request_timeout_seconds after its heartbeat goes out. It
still fails if the connection becomes SUSPENDED, CLOSED or FAILED first, which can be up to
two minutes later. Bound the wait yourself if you need an answer sooner:
Example 4.0.0 code:
response_time_ms = await asyncio.wait_for(pubsub_realtime_client.connection.ping(), timeout=5)Passing an old name raises TypeError, so a missed rename fails at construction rather
than silently scheduling a timeout a thousand times too long. Four realtime timing options
and fallback_retry_timeout, which the HTTP client reads too, took milliseconds, where the
HTTP timeouts took seconds. They now take seconds, as an int or a float, and each is
renamed with a _seconds suffix so the unit is in the name. They read back from
client.options under the new names:
| 3.x option | Unit | 4.0.0 option | Unit | Default |
|---|---|---|---|---|
realtime_request_timeout |
ms | realtime_request_timeout_seconds |
s | 10 |
disconnected_retry_timeout |
ms | disconnected_retry_timeout_seconds |
s | 15 |
suspended_retry_timeout |
ms | suspended_retry_timeout_seconds |
s | 30 |
channel_retry_timeout |
ms | channel_retry_timeout_seconds |
s | 15 |
fallback_retry_timeout |
ms | fallback_retry_timeout_seconds |
s | 600 |
Divide a 3.x value by 1000, or pass a timedelta's total_seconds(). The values the Ably
protocol carries are still milliseconds: a token's ttl, connection.connection_details,
auth.time_offset, every timestamp and the round trip connection.ping() returns.
Example 3.x code:
realtime = AblyRealtime(key='xxx', disconnected_retry_timeout=5000, realtime_request_timeout=2500)Example 4.0.0 code:
from datetime import timedelta
pubsub_realtime_client = create_realtime_client(
key='xxx',
disconnected_retry_timeout_seconds=5,
realtime_request_timeout_seconds=timedelta(milliseconds=2500).total_seconds(),
)Passing an old name raises TypeError. The HTTP timing options were already seconds, and
still are, but their names did not say so where the other timing options were milliseconds.
Each is renamed with a _seconds suffix, to match the options above, and reads back from
client.options under the new name. Only the name changes:
| 3.x option | Unit | 4.0.0 option | Unit | Default |
|---|---|---|---|---|
http_open_timeout |
s | http_open_timeout_seconds |
s | 4 |
http_request_timeout |
s | http_request_timeout_seconds |
s | 10 |
http_max_retry_duration |
s | http_max_retry_duration_seconds |
s | 15 |
Keep a 3.x value as it is, or pass a timedelta's total_seconds().
Example 3.x code:
rest = AblyRest(key='xxx', http_open_timeout=2, http_request_timeout=5)Example 4.0.0 code:
from datetime import timedelta
pubsub_http_client = create_http_client(
key='xxx',
http_open_timeout_seconds=2,
http_request_timeout_seconds=timedelta(seconds=5).total_seconds(),
)Passing a timing option under its 3.x name raises a TypeError that names its replacement
and, where the unit changed, converts the value you passed, so a 3.x configuration fails at
construction with the fix in the message. Unknown options already raised TypeError, so
code that caught it still does:
TypeError: `disconnected_retry_timeout` was renamed to `disconnected_retry_timeout_seconds` in 4.0 and is now in seconds (15000 ms → 15)
TypeError: `http_request_timeout` was renamed to `http_request_timeout_seconds` in 4.0; it is still in seconds
All eight renames together:
| 3.x option | Unit | 4.0.0 option | Unit |
|---|---|---|---|
realtime_request_timeout |
ms | realtime_request_timeout_seconds |
s |
disconnected_retry_timeout |
ms | disconnected_retry_timeout_seconds |
s |
suspended_retry_timeout |
ms | suspended_retry_timeout_seconds |
s |
channel_retry_timeout |
ms | channel_retry_timeout_seconds |
s |
fallback_retry_timeout |
ms | fallback_retry_timeout_seconds |
s |
http_open_timeout |
s | http_open_timeout_seconds |
s |
http_request_timeout |
s | http_request_timeout_seconds |
s |
http_max_retry_duration |
s | http_max_retry_duration_seconds |
s |
A value held as a timedelta converts with total_seconds(), whichever option it is for.
The eight _seconds timing options must each be a finite, non-negative int or float.
Anything else raises an InvalidArgumentException, an AblyException with code 40000 that
is also a ValueError, when the client is created. In 3.x nothing was checked: a string or
a negative number failed later or not at all, and a timedelta passed to a realtime option
was accepted and then raised inside the timer, so the retry it scheduled never ran. Zero is
accepted, and no value is too small or too large to pass.
| 3.x option | Unit | 4.0.0 option | Unit | Accepts |
|---|---|---|---|---|
realtime_request_timeout |
ms | realtime_request_timeout_seconds |
s | int or float, ≥ 0 |
disconnected_retry_timeout |
ms | disconnected_retry_timeout_seconds |
s | int or float, ≥ 0 |
suspended_retry_timeout |
ms | suspended_retry_timeout_seconds |
s | int or float, ≥ 0 |
channel_retry_timeout |
ms | channel_retry_timeout_seconds |
s | int or float, ≥ 0 |
fallback_retry_timeout |
ms | fallback_retry_timeout_seconds |
s | int or float, ≥ 0 |
http_open_timeout |
s | http_open_timeout_seconds |
s | int or float, ≥ 0 |
http_request_timeout |
s | http_request_timeout_seconds |
s | int or float, ≥ 0 |
http_max_retry_duration |
s | http_max_retry_duration_seconds |
s | int or float, ≥ 0 |
Convert a timedelta with total_seconds():
Example 3.x code:
realtime = AblyRealtime(key='xxx', disconnected_retry_timeout=timedelta(seconds=5))Example 4.0.0 code:
pubsub_realtime_client = create_realtime_client(
key='xxx', disconnected_retry_timeout_seconds=timedelta(seconds=5).total_seconds())A token ttl given as a timedelta is now converted to milliseconds exactly. 3.x went through
total_seconds() and truncated, so many values were sent one millisecond short: a ttl of
timedelta(milliseconds=1001) was requested as 1000. Whole seconds were unaffected. This applies
to token requests and to the params sent to an auth_url. A ttl given as a number of
milliseconds is sent as before.
No change is needed.
PresenceAction is now an IntEnum, as MessageAction and AnnotationAction are, and the
action of a PresenceMessage received from Ably is one of its members, where 3.x gave a
bare int from a plain class of constants. A member is still an int: it compares equal to
its wire value, so member.action == 2 and member.action == PresenceAction.ENTER both still
hold, it hashes like that value, and it is still sent to Ably as the number. Two things do
change. type(member.action) is int no longer holds, and repr() of an action is now
<PresenceAction.ENTER: 2> rather than 2; before Python 3.11, str() and %s give
PresenceAction.ENTER too, though an f-string still gives 2. Use int() where you need the
plain number, and .name for the name. An action this version does not know is left as the
int Ably sent.
Example 3.x code:
if type(member.action) is int:
log.info('presence action %s', member.action)Example 4.0.0 code:
from ably.pubsub.server import PresenceAction
if isinstance(member.action, PresenceAction):
log.info('presence action %s', member.action.name)The synchronous client's presence, annotations and push admin objects are now instances of
PresenceSync, HttpAnnotationsSync, PushDeviceRegistrationsSync and
PushChannelSubscriptionsSync, as its auth, channels, channel and push objects already were of
AuthSync, ChannelsSync, ChannelSync, PushSync and PushAdminSync. In 3.x these four
were named Presence, RestAnnotations, PushDeviceRegistrations and
PushChannelSubscriptions, the same names as the asynchronous classes, so a traceback, a
repr() or type(x).__name__ could not say which flavour an object came from. Data types,
enums, exceptions and the PubSubHttpClient prototype keep their names in both flavours.
Only code that names these classes, in an annotation or an isinstance check, needs a change:
Example 3.x code:
from ably.sync.types.presence import Presence
def members(presence: Presence) -> list: ...Example 4.0.0 code:
from ably.pubsub.server.sync import PresenceSync
def members(presence: PresenceSync) -> list: ...ably.pubsub.server now exports the types a client returns, hands to a listener or accepts,
so no import needs to reach into the packages beneath it. 3.x exported only some of them from
ably, and left the rest, such as ConnectionState and PresenceMessage, in submodules. These
names are new:
| Names | |
|---|---|
| States | ChannelState, ChannelStateChange, ConnectionState, ConnectionEvent, ConnectionStateChange |
| Presence and stats | PresenceMessage, PresenceAction, Stats |
| Auth and channel status | TokenRequest, ChannelDetails, ChannelStatus, ChannelOccupancy, ChannelMetrics |
| Objects a client hands out | Channel, Presence, HttpAnnotations, RealtimeChannel, RealtimePresence, RealtimeAnnotations, PushAdmin, PushDeviceRegistrations, PushChannelSubscriptions |
| Errors and push | InvalidArgumentException, DevicePushDetails |
Channel, Presence and HttpAnnotations are the HTTP client's channel and its presence
and annotations, and RealtimeChannel is a subclass of Channel. Like the clients, the
objects these classes describe come from a client: the classes are exported for annotations
and isinstance checks, not to be constructed. A few types reached only through an attribute
of another, such as connection.connection_details, message.annotations and
response.headers, are not exported yet; read their attributes without naming the type.
Example 3.x code:
from ably.realtime.channel import RealtimeChannel
from ably.types.connectionstate import ConnectionState, ConnectionStateChange
from ably.types.presence import PresenceMessage
def on_state(change: ConnectionStateChange) -> None:
if change.current == ConnectionState.FAILED:
...
async def members(channel: RealtimeChannel) -> list[PresenceMessage]:
return await channel.presence.get()Example 4.0.0 code:
from ably.pubsub.server import ConnectionState, ConnectionStateChange, PresenceMessage, RealtimeChannel
def on_state(change: ConnectionStateChange) -> None:
if change.current == ConnectionState.FAILED:
...
async def members(channel: RealtimeChannel) -> list[PresenceMessage]:
return await channel.presence.get()ably.pubsub.server.sync exports the same names, apart from the realtime client's:
ChannelState, ChannelStateChange, ConnectionState, ConnectionEvent,
ConnectionStateChange, RealtimeChannel, RealtimePresence and RealtimeAnnotations, as
well as Connection, RealtimeChannels, PubSubRealtimeClient and create_realtime_client,
which it never had. The classes of the objects the synchronous client hands out end in
Sync, so its new names are ChannelSync, PresenceSync, HttpAnnotationsSync,
PushAdminSync, PushDeviceRegistrationsSync and PushChannelSubscriptionsSync, beside
PresenceMessage, PresenceAction, Stats, TokenRequest, ChannelDetails,
ChannelStatus, ChannelOccupancy, ChannelMetrics, InvalidArgumentException and
DevicePushDetails under their plain names:
Example 3.x code:
from ably.sync.rest.channel import ChannelSync
from ably.sync.types.presence import PresenceMessageExample 4.0.0 code:
from ably.pubsub.server.sync import ChannelSync, PresenceMessageThe 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include:
- The realtime channel publish method now uses WebSocket connection instead of REST
ably.realtime.realtime_channelmodule renamed toably.realtime.channelChannelOptionsmoved toably.types.channeloptions- REST publish returns publish result with message serials instead of Response object
In previous versions, publishing messages on a realtime channel would use the REST API. In version 3.0.0, realtime channels now publish messages over the WebSocket connection, which is more efficient and provides better consistency.
This change is mostly transparent to users, but you should be aware that:
- Messages are now published through the realtime connection
- You will receive publish results containing message serials
- The behavior is now consistent with other Ably SDKs
If you were importing from ably.realtime.realtime_channel, you will need to update your imports:
Example 2.x code:
from ably.realtime.realtime_channel import RealtimeChannelExample 3.0.0 code:
from ably.realtime.channel import RealtimeChannelThe ChannelOptions class has been moved to a new location for better organization.
Example 2.x code:
from ably.realtime.realtime_channel import ChannelOptionsExample 3.0.0 code:
from ably.types.channeloptions import ChannelOptionsThe REST publish method now returns a publish result object containing the message serial(s) instead of a raw Response object with status_code.
Example 2.x code:
response = await channel.publish('event', 'message')
print(response.status_code) # 201Example 3.0.0 code:
result = await channel.publish('event', 'message')
print(result.serials) # message serialsThe environment, rest_host, and realtime_host client options have been deprecated in favor of a single endpoint option for better consistency and simplicity.
Example 2.x code:
# Using environment
rest_client = AblyRest(key='api:key', environment='custom')
# Or using rest_host
rest_client = AblyRest(key='api:key', rest_host='custom.ably.net')
# For realtime
realtime_client = AblyRealtime(key='api:key', realtime_host='custom.ably.net')Example 3.0.0 code:
# Using environment
rest_client = AblyRest(key='api:key', endpoint='custom')
# Using endpoint for REST
rest_client = AblyRest(key='api:key', endpoint='custom.ably.net')
# Using endpoint for Realtime
realtime_client = AblyRealtime(key='api:key', endpoint='custom.ably.net')The 2.0 version of ably-python introduces our first Python realtime client. For guidance on how to use the realtime client, refer to the usage examples in the README.
In addition to this, we have also made some minor breaking changes, these include:
- Added mandatory version param to
AblyRest.request - Changed return type of
AblyRest.stats - Removed
Auth.authorise(in favour ofAuth.authorize) - Removed
Options.fallback_hosts_use_default - Removed
Crypto.get_default_params(key)signature. - Removed the
client_idandextraskwargs fromChannel.publish - Calling
channels.release()no longer raises aKeyErrorif the channel does not yet exist
If you were using the generic request method to query the Ably REST API, you will now need to pass a version string as the third parameter. The version string represents the version of the Ably REST API to use, allowing you to upgrade to newer versions of REST endpoints as soon as they are released.
await rest.request("GET", "/time", "1.2")The return type of the stats method has changed so that all statistics are now contained in a single dict[string, int] and the json schema for the entries is included in the response:
stats_pages = rest.stats(params)
stat = stats_pages.items[0]
print(stat.schema) # contains the canonical url for the statistics json schema
print(stat.entries["messages.inbound.realtime.all.count"]) # all statistics are now included as fields in the Stats.entries dictIf you were using Auth.authorise before, all you need to do to migrate is switch over to Auth.authorize (with a 'z')
This option is no longer required since the correct fallback hosts are inferred from the environment option. If you are still using it then you can safely remove it.
This method now requires a params argument and will raise an error if it is called with just a key. If you were using this signature, you can still call the method using {'key': key} as the params argument.
In order to use these options when publishing a message, you will now need to create an instance of the Message class.
Example 1.2.x code:
await channel.publish(name='name', data='data', client_id='client_id', extras={'some': 'extras'})Example 2.x code:
from ably.types.message import Message
message = Message(name='name', data='data', client_id='client_id', extras={'some': 'extras'})
await channel.publish(message)We have made breaking changes in the version 1.2 release of this SDK.
In this guide we aim to highlight the main differences you will encounter when migrating your code from the interfaces we were offering prior to the version 1.2.0 release.
These include:
- Deprecation of support for Python versions 3.4, 3.5 and 3.6
- New, asynchronous API
- Deprecated synchronous API
The minimum version of Python has increased to 3.7. You may need to upgrade your environment in order to use this newer version of this SDK. To see which versions of Python we test the SDK against, please look at our GitHub workflows.
The 1.2.0 version introduces a breaking change, which changes the way of interacting with the SDK from synchronous to asynchronous, using the asyncio foundational library to provide support for async/await syntax.
Because of this breaking change, every call that interacts with the Ably REST API must be refactored to this asynchronous way.
For backwards compatibility, in ably-python 2.0.2 we have added a backwards compatible REST client so that you can still use the synchronous version of the REST interface if you are migrating forwards from version 1.1.
In order to use the synchronous variant, you can import the AblyRestSync constructor from ably.sync:
from ably.sync import AblyRestSync
def main():
ably = AblyRestSync('api:key')
channel = ably.channels.get("channel_name")
channel.publish('event', 'message')
if __name__ == "__main__":
main()This old style, synchronous example:
from ably import AblyRest
def main():
ably = AblyRest('api:key')
channel = ably.channels.get("channel_name")
channel.publish('event', 'message')
if __name__ == "__main__":
main()Must now be replaced with this new style, asynchronous form:
import asyncio
from ably import AblyRest
async def main():
async with AblyRest('api:key') as ably:
channel = ably.channels.get("channel_name")
await channel.publish('event', 'message')
if __name__ == "__main__":
asyncio.run(main())This old style, synchronous example:
message_page = channel.history() # Returns a PaginatedResult
message_page.items # List with messages from this page
message_page.has_next() # => True, indicates there is another page
message_page.next().items # List with messages from the second pageMust now be replaced with this new style, asynchronous form:
message_page = await channel.history() # Returns a PaginatedResult
message_page.items # List with messages from this page
message_page.has_next() # => True, indicates there is another page
next_page = await message_page.next() # Returns a next page
next_page.items # List with messages from the second pageThis old style, synchronous example:
members_page = channel.presence.get() # Returns a PaginatedResult
members_page.items
members_page.items[0].client_id # client_id of first member presentMust now be replaced with this new style, asynchronous form:
members_page = await channel.presence.get() # Returns a PaginatedResult
members_page.items
members_page.items[0].client_id # client_id of first member presentThis old style, synchronous example:
presence_page = channel.presence.history() # Returns a PaginatedResult
presence_page.items
presence_page.items[0].client_id # client_id of first memberMust now be replaced with this new style, asynchronous form:
presence_page = await channel.presence.history() # Returns a PaginatedResult
presence_page.items
presence_page.items[0].client_id # client_id of first memberThis old style, synchronous example:
token_details = client.auth.request_token()
token_details.token # => "xVLyHw.CLchevH3hF....MDh9ZC_Q"
new_client = AblyRest(token=token_details)Must now be replaced with this new style, asynchronous form:
token_details = await client.auth.request_token()
token_details.token # => "xVLyHw.CLchevH3hF....MDh9ZC_Q"
new_client = AblyRest(token=token_details)
await new_client.close()This old style, synchronous example:
token_request = client.auth.create_token_request(
{
'client_id': 'jim',
'capability': {'channel1': '"*"'},
'ttl': 3600 * 1000, # ms
}
)
new_client = AblyRest(token=token_request)Must now be replaced with this new style, asynchronous form:
token_request = await client.auth.create_token_request(
{
'client_id': 'jim',
'capability': {'channel1': '"*"'},
'ttl': 3600 * 1000, # ms
}
)
new_client = AblyRest(token=token_request)
await new_client.close()This old style, synchronous example:
stats = client.stats() # Returns a PaginatedResult
stats.itemsMust now be replaced with this new style, asynchronous form:
stats = await client.stats() # Returns a PaginatedResult
stats.items
await client.close()This old style, synchronous example:
client.time()Must now be replaced with this new style, asynchronous form:
await client.time()
await client.close()