Skip to content

Commit 32af1b9

Browse files
authored
Merge pull request #737 from ably/v4-exceptions
pubsub: breaking changes to exceptions
2 parents 7032b4c + 9a2e037 commit 32af1b9

31 files changed

Lines changed: 369 additions & 230 deletions

‎UPDATING.md‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,104 @@ await pubsub_realtime_client.channels.get('my-channel').detach()
124124
pubsub_realtime_client.channels.release('my-channel')
125125
```
126126

127+
### Some errors had their `code` and `status_code` the wrong way round
128+
129+
Ten errors the library raised itself carried the Ably error code in `status_code` and the
130+
HTTP status in `code`. They now carry them the right way round:
131+
132+
| Error | 3.x `status_code`, `code` | 4.0.0 `status_code`, `code` |
133+
|---|---|---|
134+
| Detaching a `FAILED` realtime channel | 90001, 400 | 400, 90001 |
135+
| A detach superseded by an attach | 90000, 409 | 409, 90000 |
136+
| A queued message failed with no reason given | 80000, 500 | 500, 80000 |
137+
| A NACK with no error | 50001, 500 | 500, 50001 |
138+
| A vcdiff delta with no decoder configured | 40019, 40019 | 400, 40019 |
139+
| A vcdiff delta that cannot be decoded | 40018, 40018 | 400, 40018 |
140+
| `ChannelOptions` given params or options that are not a dict | 40000, 400 | 400, 40000 |
141+
142+
If you branch on `code` or `status_code` for any of these, compare against the 4.0.0 values.
143+
144+
### Network errors and timeouts are raised as `AblyException`
145+
146+
In 3.x a request that got no response, because the host could not be reached or did not
147+
answer in time, raised the `httpx` exception itself from most methods, such as `request`,
148+
`publish`, `history` and `auth.authorize`. In 4.0.0 every method raises an `AblyException`:
149+
code 50003 and status 504 for a timeout, and code 80000 and status 500 for any other
150+
network error. The `httpx` exception is its `cause`. A timeout or network error reaching
151+
an `auth_url` is an `AblyException` with code 40170 and status 401, as for any other
152+
`auth_url` failure.
153+
154+
Example 3.x code:
155+
```python
156+
try:
157+
await rest.channels.get('my-channel').publish('event', 'data')
158+
except httpx.TimeoutException:
159+
...
160+
```
161+
162+
Example 4.0.0 code:
163+
```python
164+
try:
165+
await pubsub_http_client.channels.get('my-channel').publish('event', 'data')
166+
except AblyException as e:
167+
if e.code == 50003:
168+
...
169+
```
170+
171+
### An `AblyException` raised by an `auth_callback` is no longer rewritten
172+
173+
In 3.x any exception an `auth_callback` raised reached the caller as an `AblyException` with
174+
code 40170 and status 401, the original only in its `cause`. In 4.0.0 an `AblyException` the
175+
callback raises reaches the caller unchanged; any other exception is still wrapped as 40170.
176+
On a realtime client, a callback error with status 403 now fails the connection, with an
177+
80019 whose status is 403 and whose `cause` is the callback's error, whether it happens while
178+
connecting, on a reauth or in an explicit `authorize()` call. 3.x went `DISCONNECTED` and
179+
retried. Every 80019 the connection reports now carries the auth provider's error as its
180+
`cause`.
181+
182+
If you check for 40170 after `request_token` or `authorize` to find a failure in your own
183+
callback, check the code your callback raised instead. Raise a status 403 only for a client
184+
that should stop connecting:
185+
186+
Example 4.0.0 code:
187+
```python
188+
async def auth_callback(token_params):
189+
if not await user_may_connect():
190+
raise AblyException('Access revoked', 403, 40300)
191+
return await fetch_token(token_params)
192+
```
193+
194+
### Invalid arguments raise `AblyException`
195+
196+
The factories raised a bare `ValueError` in 3.x when given no means of authenticating, a
197+
`key` together with `key_name` or `key_secret`, a `token_details` that is not a
198+
`TokenDetails`, or `use_token_auth=False` without a key. In 4.0.0 each raises an
199+
`AblyException` with code 40106 and status 401. Combining `endpoint` with `environment`,
200+
`rest_host` or `realtime_host` still raises an `AblyException` with code 40106 and status
201+
400. `push.admin.publish` raised `TypeError` for a `recipient` or `data` that is not a dict
202+
and `ValueError` for an empty one, and now raises an `AblyException` with code 40000 and
203+
status 400 for all four.
204+
205+
These exceptions are also a `ValueError`, so `except ValueError` still catches them, but
206+
`except TypeError` no longer catches the `push.admin.publish` ones:
207+
208+
Example 3.x code:
209+
```python
210+
try:
211+
await rest.push.admin.publish(recipient, data)
212+
except TypeError:
213+
...
214+
```
215+
216+
Example 4.0.0 code:
217+
```python
218+
try:
219+
await pubsub_http_client.push.admin.publish(recipient, data)
220+
except AblyException as e:
221+
if e.code == 40000:
222+
...
223+
```
224+
127225
## Version 2.x to 3.0.0
128226

129227
The 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:

‎ably/pubsub/http/auth.py‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from datetime import timedelta
77
from typing import TYPE_CHECKING
88

9+
import httpx
10+
911
from ably.pubsub.types.options import Options
1012

1113
if TYPE_CHECKING:
@@ -16,7 +18,12 @@
1618
from ably.pubsub.types.tokendetails import TokenDetails
1719
from ably.pubsub.types.tokenrequest import TokenRequest
1820
from ably.pubsub.util.clock import select_clock
19-
from ably.pubsub.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException
21+
from ably.pubsub.util.exceptions import (
22+
AblyAuthException,
23+
AblyException,
24+
IncompatibleClientIdException,
25+
InvalidArgumentException,
26+
)
2027
from ably.pubsub.util.helper import extract_url_params
2128

2229
__all__ = ["Auth"]
@@ -60,7 +67,7 @@ def __init__(self, ably: DefaultPubSubHttpClient | DefaultPubSubRealtimeClient,
6067
self.__basic_credentials = basic_key.decode('ascii')
6168
return
6269
elif must_not_use_token_auth and not can_use_basic_auth:
63-
raise ValueError('If use_token_auth is False you must provide a key')
70+
raise InvalidArgumentException('If use_token_auth is False you must provide a key', 401, 40106)
6471

6572
# Using token auth
6673
self.__auth_mechanism = Auth.Method.TOKEN
@@ -83,8 +90,9 @@ def __init__(self, ably: DefaultPubSubHttpClient | DefaultPubSubRealtimeClient,
8390
elif options.token_details:
8491
log.debug("using token auth with supplied token_details")
8592
else:
86-
raise ValueError("Can't authenticate via token, must provide "
87-
"auth_callback, auth_url, key, token or a TokenDetail")
93+
# RSC1b
94+
raise InvalidArgumentException("Can't authenticate via token, must provide "
95+
"auth_callback, auth_url, key, token or a TokenDetail", 401, 40106)
8896

8997
async def get_auth_transport_param(self):
9098
auth_credentials = {}
@@ -151,7 +159,14 @@ def token_details_has_expired(self):
151159
return expires < timestamp + token_details.TOKEN_EXPIRY_BUFFER
152160

153161
async def authorize(self, token_params: dict | None = None, auth_options=None):
154-
return await self.__authorize_when_necessary(token_params, auth_options, force=True)
162+
try:
163+
return await self.__authorize_when_necessary(token_params, auth_options, force=True)
164+
except AblyException as e:
165+
# RSA4d, RSA4d1: a 403 from the auth provider fails the connection, even
166+
# while the token it holds is still valid
167+
if self.ably._is_realtime and e.status_code == 403:
168+
self.ably.connection.connection_manager.on_error_from_authorize(e)
169+
raise
155170

156171
async def request_token(self, token_params: dict | None = None,
157172
# auth_options
@@ -184,6 +199,9 @@ async def request_token(self, token_params: dict | None = None,
184199
log.debug("using token auth with authCallback")
185200
try:
186201
token_request = await auth_callback(token_params)
202+
except AblyException:
203+
# RSA4d, RSA4e: an error the callback gives as an AblyException is kept as it is
204+
raise
187205
except Exception as e:
188206
raise AblyException("auth_callback raised an exception", 401, 40170, cause=e) from e
189207
elif auth_url:
@@ -212,7 +230,7 @@ async def request_token(self, token_params: dict | None = None,
212230
raise AblyAuthException(msg, 401, 40170, cause=e) from e
213231
elif isinstance(token_request, str):
214232
if len(token_request) == 0:
215-
raise AblyAuthException("Token string is empty", 401, 4017)
233+
raise AblyAuthException("Token string is empty", 401, 40170)
216234
return TokenDetails(token=token_request)
217235
elif token_request is None:
218236
raise AblyAuthException("Token string was None", 401, 40170)
@@ -410,8 +428,12 @@ async def token_request_from_auth_url(self, method: str, url: str, token_params,
410428
url = clean_url
411429

412430
from ably.pubsub.request.http import Response
413-
resp = await self.ably.http.request_external(
414-
method=method, url=url, headers=headers, params=params, body=body)
431+
try:
432+
resp = await self.ably.http.request_external(
433+
method=method, url=url, headers=headers, params=params, body=body)
434+
except httpx.RequestError as e:
435+
# RSA4e: a timeout or network error reaching the auth_url is a 40170
436+
raise AblyAuthException("auth_url request failed", 401, 40170, cause=e) from e
415437
response = Response(resp)
416438

417439
AblyException.raise_for_response(response)

‎ably/pubsub/http/http.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from ably.pubsub.types.stats import stats_response_processor
2323
from ably.pubsub.types.tokendetails import TokenDetails
2424
from ably.pubsub.util.construction import reject_direct_construction
25-
from ably.pubsub.util.exceptions import AblyException, catch_all
25+
from ably.pubsub.util.exceptions import AblyException, InvalidArgumentException, catch_all
2626

2727
log = logging.getLogger(__name__)
2828

@@ -67,20 +67,22 @@ def __init__(self, key: Optional[str] = None, token: Optional[str] = None,
6767
reject_direct_construction(type(self), 'ably.pubsub.server.create_http_client')
6868

6969
if key is not None and ('key_name' in kwargs or 'key_secret' in kwargs):
70-
raise ValueError("key and key_name or key_secret are mutually exclusive. "
71-
"Provider either a key or key_name & key_secret")
70+
raise InvalidArgumentException("key and key_name or key_secret are mutually exclusive. "
71+
"Provider either a key or key_name & key_secret", 401, 40106)
7272
if key is not None:
7373
options = Options(key=key, **kwargs)
7474
elif token is not None:
7575
options = Options(auth_token=token, **kwargs)
7676
elif token_details is not None:
7777
if not isinstance(token_details, TokenDetails):
78-
raise ValueError("token_details must be an instance of TokenDetails")
78+
raise InvalidArgumentException("token_details must be an instance of TokenDetails", 401, 40106)
7979
options = Options(token_details=token_details, **kwargs)
8080
elif not ('auth_callback' in kwargs or 'auth_url' in kwargs or
8181
# and don't have both key_name and key_secret
8282
('key_name' in kwargs and 'key_secret' in kwargs)):
83-
raise ValueError("key is missing. Either an API key, token, or token auth method must be provided")
83+
# RSC1b
84+
raise InvalidArgumentException(
85+
"key is missing. Either an API key, token, or token auth method must be provided", 401, 40106)
8486
else:
8587
options = Options(**kwargs)
8688

‎ably/pubsub/http/push.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
channels_response_processor,
88
)
99
from ably.pubsub.types.device import DeviceDetails, device_details_response_processor
10+
from ably.pubsub.util.exceptions import InvalidArgumentException
1011

1112

1213
class Push:
@@ -47,16 +48,16 @@ async def publish(self, recipient: dict, data: dict, timeout: Optional[float] =
4748
- `data`: the data of the notification
4849
"""
4950
if not isinstance(recipient, dict):
50-
raise TypeError(f'Unexpected {type(recipient)} recipient, expected a dict')
51+
raise InvalidArgumentException(f'Unexpected {type(recipient)} recipient, expected a dict', 400, 40000)
5152

5253
if not isinstance(data, dict):
53-
raise TypeError(f'Unexpected {type(data)} data, expected a dict')
54+
raise InvalidArgumentException(f'Unexpected {type(data)} data, expected a dict', 400, 40000)
5455

5556
if not recipient:
56-
raise ValueError('recipient is empty')
57+
raise InvalidArgumentException('recipient is empty', 400, 40000)
5758

5859
if not data:
59-
raise ValueError('data is empty')
60+
raise InvalidArgumentException('data is empty', 400, 40000)
6061

6162
body = data.copy()
6263
body.update({'recipient': recipient})

‎ably/pubsub/realtime/channel.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ async def detach(self) -> None:
244244
self._notify_state(ChannelState.DETACHED)
245245
return
246246
elif self.state == ChannelState.FAILED:
247-
raise AblyException("Unable to detach; channel state = failed", 90001, 400)
247+
raise AblyException("Unable to detach; channel state = failed", 400, 90001)
248248
else:
249249
self._request_state(ChannelState.DETACHING)
250250

@@ -258,7 +258,7 @@ async def detach(self) -> None:
258258
if new_state == ChannelState.DETACHED:
259259
return
260260
elif new_state == ChannelState.ATTACHING:
261-
raise AblyException("Detach request superseded by a subsequent attach request", 90000, 409)
261+
raise AblyException("Detach request superseded by a subsequent attach request", 409, 90000)
262262
else:
263263
raise state_change.reason
264264

‎ably/pubsub/realtime/connectionmanager.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ def fail_queued_messages(self, err) -> None:
343343
f"ConnectionManager.fail_queued_messages(): discarding {len(self.queued_messages)} messages;" +
344344
f" reason = {err}"
345345
)
346-
error = err or AblyException("Connection failed", 80000, 500)
346+
error = err or AblyException("Connection failed", 500, 80000)
347347
while len(self.queued_messages) > 0:
348348
pending_msg = self.queued_messages.pop()
349349
log.exception(
@@ -487,11 +487,11 @@ def on_error_from_authorize(self, exception: AblyException) -> None:
487487
elif exception.status_code == 403:
488488
msg = 'Client configured authentication provider returned 403; failing the connection'
489489
log.error(f'ConnectionManager.on_error_from_authorize(): {msg}')
490-
self.notify_state(ConnectionState.FAILED, AblyException(msg, 403, 80019))
490+
self.notify_state(ConnectionState.FAILED, AblyException(msg, 403, 80019, cause=exception))
491491
else:
492492
msg = 'Client configured authentication provider request failed'
493493
log.warning(f'ConnectionManager.on_error_from_authorize: {msg}')
494-
self.notify_state(self.__fail_state, AblyException(msg, 401, 80019))
494+
self.notify_state(self.__fail_state, AblyException(msg, 401, 80019, cause=exception))
495495

496496
async def on_closed(self) -> None:
497497
if self.transport:
@@ -538,7 +538,7 @@ def on_nack(self, serial: int, count: int, err: AblyException | None) -> None:
538538
err: Error information from the server
539539
"""
540540
if not err:
541-
err = AblyException('Unable to send message; channel not responding', 50001, 500)
541+
err = AblyException('Unable to send message; channel not responding', 500, 50001)
542542

543543
log.error(f'ConnectionManager.on_nack(): serial={serial}, count={count}, err={err}')
544544
self.pending_message_queue.complete_messages(serial, count, None, err)

‎ably/pubsub/request/http.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ async def wrapper(rest, *args, **kwargs):
4343
return wrapper
4444

4545

46+
def wrap_request_error(host: str, e: httpx.RequestError) -> AblyException:
47+
"""Wraps the error httpx raised for a request that got no response (RSC19e).
48+
49+
A timeout is 50003 and any other failure to complete the request is 80000, with
50+
the httpx error kept as the cause.
51+
"""
52+
if isinstance(e, httpx.TimeoutException):
53+
return AblyException(f'Request to {host} timed out', 504, 50003, cause=e)
54+
return AblyException(f'Request to {host} failed with a network error', 500, 80000, cause=e)
55+
56+
4657
class Request:
4758
def __init__(self, method='GET', url='/', version=None, headers=None, body=None,
4859
skip_auth=False, raise_on_error=True):
@@ -219,6 +230,8 @@ def should_stop_retrying(retry_count=retry_count):
219230
response = await self.__client.send(request)
220231
except Exception as e:
221232
if should_stop_retrying():
233+
if isinstance(e, httpx.RequestError):
234+
raise wrap_request_error(host, e) from e
222235
raise e
223236
else:
224237
# RSC15l4

‎ably/pubsub/server/__init__.py‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,10 @@ def create_http_client(**kwargs) -> PubSubHttpClient:
163163
164164
Raises
165165
------
166-
ValueError
167-
If `key` is combined with `key_name` or `key_secret`.
168166
AblyException
169-
If no means of authenticating is given, or if `endpoint` is combined with
170-
`environment`, `rest_host` or `realtime_host`.
167+
With code 40106, if no means of authenticating is given, if `key` is combined
168+
with `key_name` or `key_secret`, or if `endpoint` is combined with
169+
`environment`, `rest_host` or `realtime_host`. It is also a ValueError.
171170
"""
172171
with factory_construction():
173172
return _DefaultPubSubHttpClient(**kwargs)
@@ -243,11 +242,10 @@ def create_realtime_client(**kwargs) -> PubSubRealtimeClient:
243242
244243
Raises
245244
------
246-
ValueError
247-
If `key` is combined with `key_name` or `key_secret`.
248245
AblyException
249-
If no means of authenticating is given, or if `endpoint` is combined with
250-
`environment`, `rest_host` or `realtime_host`.
246+
With code 40106, if no means of authenticating is given, if `key` is combined
247+
with `key_name` or `key_secret`, or if `endpoint` is combined with
248+
`environment`, `rest_host` or `realtime_host`. It is also a ValueError.
251249
"""
252250
with factory_construction():
253251
return _DefaultPubSubRealtimeClient(**kwargs)

‎ably/pubsub/server/sync.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,10 @@ def create_http_client(**kwargs) -> PubSubHttpClient:
5252
5353
Raises
5454
------
55-
ValueError
56-
If `key` is combined with `key_name` or `key_secret`.
5755
AblyException
58-
If no means of authenticating is given, or if `endpoint` is combined with
59-
`environment`, `rest_host` or `realtime_host`.
56+
With code 40106, if no means of authenticating is given, if `key` is combined
57+
with `key_name` or `key_secret`, or if `endpoint` is combined with
58+
`environment`, `rest_host` or `realtime_host`. It is also a ValueError.
6059
"""
6160
with factory_construction():
6261
return _DefaultPubSubHttpClientSync(**kwargs)

0 commit comments

Comments
 (0)