Skip to content

Commit abd194d

Browse files
owenpearsonclaude
andcommitted
feat: read the current time through a clock which tests can replace
The client reads the wall clock for the fallback-host cache, the cumulative retry budget and the timestamp a token request is signed with, so a test which drives any of them has to spend the interval in real seconds. A Clock passed as a test option supplies now_ms, monotonic_ms and timer together, so that substituting one moves every reading the client makes; clients which supply none read the system clock. The cached fallback host's expiry is held in milliseconds, matching fallbackRetryTimeout and the rest of the library, and the retry budget is converted where it is compared, since httpMaxRetryDuration is in seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3f1776c commit abd194d

5 files changed

Lines changed: 181 additions & 8 deletions

File tree

‎ably/http/http.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import functools
22
import json
33
import logging
4-
import time
54
from urllib.parse import urljoin
65

76
import httpx
@@ -10,6 +9,7 @@
109
from ably.http.httputils import HttpUtils
1110
from ably.rest.auth import Auth
1211
from ably.transport.defaults import Defaults
12+
from ably.util.clock import select_clock
1313
from ably.util.exceptions import AblyException
1414
from ably.util.helper import extract_url_params, is_token_error
1515

@@ -129,6 +129,7 @@ def __init__(self, ably, options):
129129
# Cached fallback host (RSC15f)
130130
self.__host = None
131131
self.__host_expires = None
132+
self.__clock = select_clock(options)
132133
self.__client = self.__create_client(options)
133134

134135
@staticmethod
@@ -154,7 +155,7 @@ def get_hosts(self):
154155
return hosts
155156

156157
# unstore saved fallback host after fallbackRetryTimeout (RSC15f)
157-
if self.__host_expires is not None and time.time() > self.__host_expires:
158+
if self.__host_expires is not None and self.__clock.now_ms() > self.__host_expires:
158159
self.__host = None
159160
self.__host_expires = None
160161
return hosts
@@ -191,12 +192,12 @@ async def make_request(self, method, path, version=None, headers=None, body=None
191192

192193
timeout = (self.http_open_timeout, self.http_request_timeout)
193194
http_max_retry_duration = self.http_max_retry_duration
194-
requested_at = time.time()
195+
requested_at = self.__clock.now_ms()
195196

196197
hosts = self.get_hosts()
197198
for retry_count, host in enumerate(hosts):
198199
def should_stop_retrying(retry_count=retry_count):
199-
time_passed = time.time() - requested_at
200+
time_passed = (self.__clock.now_ms() - requested_at) / 1000
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

@@ -238,7 +239,7 @@ def should_stop_retrying(retry_count=retry_count):
238239
# Keep fallback host for later (RSC15f)
239240
if retry_count > 0 and host != self.options.get_host():
240241
self.__host = host
241-
self.__host_expires = time.time() + (self.options.fallback_retry_timeout / 1000.0)
242+
self.__host_expires = self.__clock.now_ms() + self.options.fallback_retry_timeout
242243

243244
return Response(response)
244245
except AblyException as e:

‎ably/rest/auth.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import base64
44
import logging
5-
import time
65
import uuid
76
from datetime import timedelta
87
from typing import TYPE_CHECKING
@@ -18,6 +17,7 @@
1817
from ably.types.capability import Capability
1918
from ably.types.tokendetails import TokenDetails
2019
from ably.types.tokenrequest import TokenRequest
20+
from ably.util.clock import select_clock
2121
from ably.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException
2222
from ably.util.helper import extract_url_params
2323

@@ -34,6 +34,7 @@ class Method:
3434
def __init__(self, ably: AblyRest | AblyRealtime, options: Options):
3535
self.__ably = ably
3636
self.__auth_options = options
37+
self.__clock = select_clock(options)
3738

3839
if not self.ably._is_realtime:
3940
self.__client_id = options.client_id
@@ -383,7 +384,7 @@ async def _get_auth_headers(self):
383384

384385
def _timestamp(self):
385386
"""Returns the local time in milliseconds since the unix epoch"""
386-
return int(time.time() * 1000)
387+
return self.__clock.now_ms()
387388

388389
def _random_nonce(self):
389390
return uuid.uuid4().hex[:16]

‎ably/types/testoptions.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,17 @@ class TestOptions:
66
:Parameters:
77
- `http_transport`: an `httpx.AsyncBaseTransport` which handles every
88
HTTP request the client makes, in place of the network.
9+
- `clock`: the source of time the client reads, in place of
10+
`ably.util.clock.Clock`. It supplies `now_ms()`, `monotonic_ms()` and
11+
`timer(timeout_ms, callback)`, where `callback` is either a coroutine
12+
function or a plain callable and the return value has a `cancel()`
13+
method.
914
"""
1015

1116
# Excludes the class from pytest collection, which would otherwise treat
1217
# any module importing it as declaring a test suite.
1318
__test__ = False
1419

15-
def __init__(self, http_transport=None):
20+
def __init__(self, http_transport=None, clock=None):
1621
self.http_transport = http_transport
22+
self.clock = clock

‎ably/util/clock.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import time
2+
from typing import Callable
3+
4+
from ably.util.helper import Timer
5+
6+
7+
class Clock:
8+
"""The source of time for every decision a client makes from it.
9+
10+
Token expiry, the server-time offset and the fallback-host cache all read
11+
the clock, and delayed callbacks are scheduled through it, so replacing one
12+
moves all of them together.
13+
14+
`now_ms` is the time of day and may step; `monotonic_ms` only ever moves
15+
forward and is what a duration is measured with.
16+
"""
17+
18+
def now_ms(self) -> int:
19+
"""The milliseconds elapsed since the unix epoch."""
20+
return round(time.time_ns() / 1_000_000)
21+
22+
def monotonic_ms(self) -> float:
23+
"""The milliseconds elapsed since an arbitrary fixed point."""
24+
return time.monotonic() * 1000
25+
26+
def timer(self, timeout: float, callback: Callable) -> Timer:
27+
"""Schedules `callback` for `timeout` milliseconds from now."""
28+
return Timer(timeout, callback)
29+
30+
31+
def select_clock(options) -> Clock:
32+
"""The clock a client reads its time from.
33+
34+
`TestOptions.clock` substitutes for the real one during tests, letting them
35+
drive time-dependent behaviour without waiting for it. Clients which supply
36+
none get `Clock`.
37+
"""
38+
test_options = getattr(options, 'test_options', None)
39+
if test_options is not None and test_options.clock is not None:
40+
return test_options.clock
41+
return Clock()

‎test/unit/clock_test.py‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import httpx
2+
import pytest
3+
4+
from ably import AblyRest
5+
from ably.types.testoptions import TestOptions
6+
from ably.util.clock import Clock
7+
from ably.util.exceptions import AblyException
8+
9+
# A plausible wall-clock reading for a fake to start from, so that a time taken
10+
# against it looks like a real one.
11+
EPOCH_MS = 1_700_000_000_000
12+
13+
14+
class FakeClock:
15+
"""A clock whose notional time only the test moves.
16+
17+
`advance` moves it by hand; `step_ms` moves it by that much on every read,
18+
which is how a duration measured across several reads is driven.
19+
"""
20+
21+
def __init__(self, now_ms=EPOCH_MS, step_ms=0):
22+
self.__now_ms = now_ms
23+
self.__step_ms = step_ms
24+
25+
def now_ms(self):
26+
reading = self.__now_ms
27+
self.__now_ms += self.__step_ms
28+
return reading
29+
30+
def monotonic_ms(self):
31+
return float(self.__now_ms)
32+
33+
def advance(self, milliseconds):
34+
self.__now_ms += milliseconds
35+
36+
def timer(self, timeout, callback):
37+
raise AssertionError('the REST client schedules no delayed callbacks')
38+
39+
40+
class RecordingTransport(httpx.AsyncBaseTransport):
41+
"""Records the host of every request and refuses the ones in `refusing`.
42+
43+
A host left out of `refusing` is served a server time. `refusing` is set
44+
after the client is built, since the host to refuse is one of its own.
45+
"""
46+
47+
def __init__(self):
48+
self.refusing = ()
49+
self.hosts = []
50+
51+
async def handle_async_request(self, request):
52+
self.hosts.append(request.url.host)
53+
if request.url.host in self.refusing:
54+
raise httpx.ConnectError('connection refused', request=request)
55+
return httpx.Response(200, json=[1500000000000])
56+
57+
58+
def test_auth_timestamps_from_the_injected_clock():
59+
ably = AblyRest(key='name:secret', test_options=TestOptions(clock=FakeClock(1_500_000_000_000)))
60+
assert ably.auth._timestamp() == 1_500_000_000_000
61+
62+
63+
def test_a_client_without_test_options_reads_the_real_clock():
64+
ably = AblyRest(token='foo')
65+
assert isinstance(ably.auth._Auth__clock, Clock)
66+
assert isinstance(ably.http._Http__clock, Clock)
67+
68+
69+
async def test_the_cached_fallback_host_expires_on_the_clock():
70+
# RSC15f: the cache lasts fallback_retry_timeout milliseconds
71+
clock = FakeClock()
72+
transport = RecordingTransport()
73+
ably = AblyRest(token='foo', fallback_retry_timeout=2000,
74+
test_options=TestOptions(http_transport=transport, clock=clock))
75+
primary = ably.options.get_host()
76+
transport.refusing = (primary,)
77+
78+
await ably.time()
79+
80+
fallback = transport.hosts[-1]
81+
assert fallback != primary
82+
assert ably.http._Http__host_expires == EPOCH_MS + 2000
83+
assert ably.http.get_hosts()[0] == fallback
84+
85+
clock.advance(1999)
86+
assert ably.http.get_hosts()[0] == fallback
87+
88+
clock.advance(2)
89+
assert ably.http.get_hosts()[0] == primary
90+
assert ably.http._Http__host_expires is None
91+
92+
await ably.close()
93+
94+
95+
async def test_every_host_is_tried_while_the_retry_budget_lasts():
96+
# A second of clock time per reading, against the default 15 second budget
97+
clock = FakeClock(step_ms=1000)
98+
transport = RecordingTransport()
99+
ably = AblyRest(token='foo', test_options=TestOptions(http_transport=transport, clock=clock))
100+
hosts = ably.http.get_hosts()
101+
transport.refusing = tuple(hosts)
102+
103+
with pytest.raises(AblyException):
104+
await ably.time()
105+
106+
assert transport.hosts == hosts
107+
108+
await ably.close()
109+
110+
111+
async def test_retrying_stops_once_the_retry_budget_is_spent():
112+
# RSC15l: http_max_retry_duration is seconds where the clock is milliseconds
113+
clock = FakeClock(step_ms=1000)
114+
transport = RecordingTransport()
115+
ably = AblyRest(token='foo', http_max_retry_duration=0.5,
116+
test_options=TestOptions(http_transport=transport, clock=clock))
117+
transport.refusing = tuple(ably.http.get_hosts())
118+
119+
with pytest.raises(AblyException):
120+
await ably.time()
121+
122+
assert len(transport.hosts) == 1
123+
124+
await ably.close()

0 commit comments

Comments
 (0)