Skip to content

Commit ecd0c3c

Browse files
owenpearsonclaude
andcommitted
fix: handle the payloads and endpoints the REST unit specifications exercise
Nine faults the derived suite found, each of which raised where an error should have been returned or a value parsed: - a present-but-null `encoding` reached `EncodeDataMixin.decode` as None, raising AttributeError for messages, presence messages and annotations - `TokenDetails.from_json` mutated the dict it was iterating, and rejected wire fields the constructor does not model - `format_params` compared a stringified `limit` against an integer - a 204 carries no Content-Type, which `PaginatedResult` subscripted - an IPv6 endpoint produced `https://::1:443`, which httpx rejects - an undecodable content type on a 2xx surfaced as 50000 rather than 40013 - an error body carrying `error` as a string raised TypeError - `Http.http_max_retry_count` had no entry to fall back to - a JSON auth_url response was taken to be a TokenDetails only when it carried `issued`, so a payload with `token` alone was rejected as 40170 Each is reachable through the public API and independent of the derived suite, which arrives later in this stack and covers all nine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2fbacae commit ecd0c3c

10 files changed

Lines changed: 35 additions & 17 deletions

File tree

‎ably/http/http.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def to_native(self):
104104
elif content_type.startswith('application/json'):
105105
return self.__response.json()
106106

107-
raise ValueError("Unsupported content type")
107+
raise AblyException("Unsupported content type", 400, 40013)
108108

109109
@property
110110
def response(self):
@@ -118,6 +118,7 @@ class Http:
118118
CONNECTION_RETRY_DEFAULTS = {
119119
'http_open_timeout': 4,
120120
'http_request_timeout': 10,
121+
'http_max_retry_count': 3,
121122
'http_max_retry_duration': 15,
122123
}
123124

@@ -200,7 +201,7 @@ def should_stop_retrying(retry_count=retry_count):
200201
# if it's the last try or cumulative timeout is done, we stop retrying
201202
return retry_count == len(hosts) - 1 or time_passed > http_max_retry_duration
202203

203-
base_url = f"{self.preferred_scheme}://{host}:{self.preferred_port}"
204+
base_url = f"{self.preferred_scheme}://{HttpUtils.host_for_url(host)}:{self.preferred_port}"
204205
url = urljoin(base_url, path)
205206

206207
(clean_url, url_params) = extract_url_params(url)

‎ably/http/httputils.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import ipaddress
23
import os
34
import platform
45

@@ -30,6 +31,19 @@ def default_post_headers(binary=False, version=None):
3031
headers["Content-Type"] = headers["Accept"]
3132
return headers
3233

34+
@staticmethod
35+
def host_for_url(host):
36+
"""Renders a host for inclusion in a URL.
37+
38+
An IPv6 literal is bracketed so that its colons are not read as a port
39+
separator; every other form of host is returned unchanged.
40+
"""
41+
try:
42+
is_ipv6 = ipaddress.ip_address(host).version == 6
43+
except ValueError:
44+
is_ipv6 = False
45+
return f'[{host}]' if is_ipv6 else host
46+
3347
@staticmethod
3448
def get_host_header(host):
3549
return {

‎ably/http/paginatedresult.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def format_params(params=None, direction=None, start=None, end=None, limit=None,
3131
if end:
3232
params['end'] = format_time_param(end)
3333
if limit:
34+
limit = int(limit)
3435
if limit > 1000:
3536
raise ValueError("The maximum allowed limit is 1000")
3637
params['limit'] = f'{limit}'
@@ -95,7 +96,7 @@ async def paginated_query_with_request(cls, http, request, response_processor,
9596

9697
items = response_processor(response)
9798

98-
content_type = response.headers['Content-Type']
99+
content_type = response.headers.get('Content-Type')
99100
links = response.links
100101
if 'first' in links:
101102
first_rel_request = request.with_relative_url(links['first']['url'])

‎ably/rest/auth.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ async def request_token(self, token_params: dict | None = None,
200200
raise AblyAuthException(msg, 403, 40171)
201201
if isinstance(token_request, TokenDetails):
202202
return token_request
203-
elif isinstance(token_request, dict) and 'issued' in token_request:
203+
elif isinstance(token_request, dict) and ('issued' in token_request or 'token' in token_request):
204204
return TokenDetails.from_dict(token_request)
205205
elif isinstance(token_request, dict):
206206
try:

‎ably/types/annotation.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ def from_encoded(obj, cipher=None, context=None):
208208
name = obj.get('name')
209209
count = obj.get('count')
210210
data = obj.get('data')
211-
encoding = obj.get('encoding', '')
211+
encoding = obj.get('encoding') or ''
212212
id = obj.get('id')
213213
client_id = obj.get('clientId')
214214
connection_id = obj.get('connectionId')

‎ably/types/message.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ def from_encoded(obj, cipher=None, context=None):
293293
client_id = obj.get('clientId')
294294
connection_id = obj.get('connectionId')
295295
timestamp = obj.get('timestamp')
296-
encoding = obj.get('encoding', '')
296+
encoding = obj.get('encoding') or ''
297297
extras = obj.get('extras', None)
298298
serial = obj.get('serial')
299299
action = obj.get('action')

‎ably/types/presence.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def from_encoded(obj, cipher=None, context=None):
175175
client_id = obj.get('clientId')
176176
connection_id = obj.get('connectionId')
177177
data = obj.get('data')
178-
encoding = obj.get('encoding', '')
178+
encoding = obj.get('encoding') or ''
179179
timestamp = obj.get('timestamp')
180180
# member_key = obj.get('memberKey', None)
181181
extras = obj.get('extras', None)

‎ably/types/tokendetails.py‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,7 @@ def from_json(data):
7171
if isinstance(data, str):
7272
data = json.loads(data)
7373

74-
mapping = {
75-
'clientId': 'client_id',
76-
}
77-
for name in data:
78-
py_name = mapping.get(name)
79-
if py_name:
80-
data[py_name] = data.pop(name)
81-
82-
return TokenDetails(**data)
74+
return TokenDetails.from_dict(data)
8375

8476
def __eq__(self, other):
8577
if isinstance(other, TokenDetails):

‎ably/util/exceptions.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def raise_for_response(response):
5353
status_code=error['statusCode'],
5454
code=int(error['code']),
5555
)
56-
except KeyError:
56+
except (KeyError, TypeError):
5757
msg = "Unexpected exception decoding server response: %s"
5858
msg = msg % response.text
5959
raise AblyException(message=msg, status_code=500, code=50000) from None

‎test/ably/rest/resthttp_test.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from httpx import Response
1111

1212
from ably import AblyRest
13+
from ably.http.httputils import HttpUtils
1314
from ably.transport.defaults import Defaults
1415
from ably.types.options import Options
1516
from ably.util.exceptions import AblyException
@@ -18,6 +19,15 @@
1819

1920

2021
class TestRestHttp(BaseAsyncTestCase):
22+
async def test_host_for_url_brackets_only_ipv6_literals(self):
23+
assert HttpUtils.host_for_url('::1') == '[::1]'
24+
assert HttpUtils.host_for_url('2001:db8::1') == '[2001:db8::1]'
25+
26+
# A colon alone does not make a host an IPv6 literal
27+
assert HttpUtils.host_for_url('rest.ably.io') == 'rest.ably.io'
28+
assert HttpUtils.host_for_url('https://www.google.com') == 'https://www.google.com'
29+
assert HttpUtils.host_for_url('[::1]') == '[::1]'
30+
2131
async def test_max_retry_attempts_and_timeouts_defaults(self):
2232
ably = AblyRest(token="foo")
2333
assert 'http_open_timeout' in ably.http.CONNECTION_RETRY_DEFAULTS

0 commit comments

Comments
 (0)