Skip to content

Commit ffe4d9d

Browse files
owenpearsonclaude
andcommitted
feat: allow an HTTP transport to be injected via test options
The REST client builds its own httpx.AsyncClient, leaving no way for a test to observe or serve the requests it makes. A TestOptions holder passed as a client option supplies an httpx.AsyncBaseTransport which handles requests in place of the network, so request building, host fallback and response parsing all still run against the real code paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 576c6f9 commit ffe4d9d

4 files changed

Lines changed: 79 additions & 2 deletions

File tree

‎ably/http/http.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,14 @@ def __init__(self, ably, options):
129129
# Cached fallback host (RSC15f)
130130
self.__host = None
131131
self.__host_expires = None
132-
self.__client = httpx.AsyncClient(http2=True)
132+
self.__client = self.__create_client(options)
133+
134+
@staticmethod
135+
def __create_client(options):
136+
test_options = getattr(options, 'test_options', None)
137+
if test_options is not None and test_options.http_transport is not None:
138+
return httpx.AsyncClient(transport=test_options.http_transport)
139+
return httpx.AsyncClient(http2=True)
133140

134141
async def close(self):
135142
await self.__client.aclose()

‎ably/types/options.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ def __init__(self, client_id=None, log_level=0, tls=True, rest_host=None, realti
3434
idempotent_rest_publishing=None, loop=None, auto_connect=True,
3535
suspended_retry_timeout=None, connectivity_check_url=None,
3636
channel_retry_timeout=Defaults.channel_retry_timeout, add_request_ids=False,
37-
vcdiff_decoder: VCDiffDecoder = None, transport_params=None, **kwargs):
37+
vcdiff_decoder: VCDiffDecoder = None, transport_params=None, test_options=None,
38+
**kwargs):
3839

3940
super().__init__(**kwargs)
4041

@@ -129,6 +130,7 @@ def __init__(self, client_id=None, log_level=0, tls=True, rest_host=None, realti
129130
self.__add_request_ids = add_request_ids
130131
self.__vcdiff_decoder = vcdiff_decoder
131132
self.__transport_params = transport_params or {}
133+
self.__test_options = test_options
132134
self.__hosts = self.__get_hosts()
133135

134136
@property
@@ -306,6 +308,10 @@ def vcdiff_decoder(self):
306308
def transport_params(self):
307309
return self.__transport_params
308310

311+
@property
312+
def test_options(self):
313+
return self.__test_options
314+
309315
def __get_hosts(self):
310316
"""
311317
Return the list of hosts as they should be tried. First comes the main

‎ably/types/testoptions.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
class TestOptions:
2+
"""Substitutes for the infrastructure a client uses to perform I/O.
3+
4+
Hooks left as None use the production implementation.
5+
6+
:Parameters:
7+
- `http_transport`: an `httpx.AsyncBaseTransport` which handles every
8+
HTTP request the client makes, in place of the network.
9+
"""
10+
11+
# Excludes the class from pytest collection, which would otherwise treat
12+
# any module importing it as declaring a test suite.
13+
__test__ = False
14+
15+
def __init__(self, http_transport=None):
16+
self.http_transport = http_transport

‎test/unit/http_test.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import httpx
2+
import pytest
3+
14
from ably import AblyRest
5+
from ably.types.testoptions import TestOptions
6+
from ably.util.exceptions import AblyException
27

38

49
def test_http_get_rest_hosts_works_when_fallback_realtime_host_is_set():
@@ -17,3 +22,46 @@ def test_http_get_rest_hosts_works_when_fallback_realtime_host_is_not_set():
1722
hosts = ably.http.get_hosts()
1823
assert isinstance(hosts, list)
1924
assert all(isinstance(host, str) for host in hosts)
25+
26+
27+
class RecordingTransport(httpx.AsyncBaseTransport):
28+
def __init__(self, response_factory):
29+
self.requests = []
30+
self.__response_factory = response_factory
31+
32+
async def handle_async_request(self, request):
33+
self.requests.append(request)
34+
return self.__response_factory(request)
35+
36+
37+
async def test_http_sends_requests_through_an_injected_transport():
38+
transport = RecordingTransport(lambda request: httpx.Response(200, json=[1500000000000]))
39+
ably = AblyRest(token="foo", test_options=TestOptions(http_transport=transport))
40+
41+
server_time = await ably.time()
42+
43+
assert server_time == 1500000000000
44+
assert len(transport.requests) == 1
45+
assert transport.requests[0].method == 'GET'
46+
assert transport.requests[0].url.path == '/time'
47+
await ably.close()
48+
49+
50+
async def test_http_surfaces_transport_connection_errors():
51+
def refuse(request):
52+
raise httpx.ConnectError("connection refused", request=request)
53+
54+
transport = RecordingTransport(refuse)
55+
ably = AblyRest(token="foo", test_options=TestOptions(http_transport=transport))
56+
57+
with pytest.raises(AblyException):
58+
await ably.time()
59+
60+
# Every host is tried before the error is surfaced (RSC15l)
61+
assert len(transport.requests) == len(ably.http.get_hosts())
62+
await ably.close()
63+
64+
65+
def test_http_uses_a_network_transport_without_test_options():
66+
ably = AblyRest(token="foo")
67+
assert isinstance(ably.http._Http__client._transport, httpx.AsyncHTTPTransport)

0 commit comments

Comments
 (0)