Skip to content

Commit 37bfe83

Browse files
authored
Merge pull request #717 from ably/uts/realtime-integration
test: derive the realtime integration UTS tier
2 parents c838de1 + 9549f44 commit 37bfe83

126 files changed

Lines changed: 31574 additions & 79 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/uts-to-python/SKILL.md‎

Lines changed: 746 additions & 16 deletions
Large diffs are not rendered by default.

‎CLAUDE.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,22 @@
11
- after making any code changes, run `uv ruff check` to make sure linting passes
22
- use `uv` to run any other necessary tasks such as `pytest`
3+
4+
# Time
5+
6+
Production code in `ably/` reads the current time and schedules delayed callbacks through
7+
the clock, never through `time`, `datetime.now` or `asyncio.sleep`:
8+
9+
- `select_clock(options)` in the constructor, from `ably.util.clock`
10+
- `now_ms()` for the time of day
11+
- `monotonic_ms()` for a duration
12+
- `timer(timeout_ms, callback)` for a delayed callback
13+
14+
A test replaces it with `TestOptions(clock=...)`. Converting a caller-supplied value, and
15+
arithmetic on the unix epoch, are not clock readings and stay as they are.
16+
17+
`asyncio.wait_for` in `ConnectionManager.ping` is the one delay that runs on the event
18+
loop's clock rather than this one; a test which waits it out sets a short real
19+
`realtime_request_timeout`.
20+
21+
`Auth._timestamp` signs a token request the server validates, so a client which reads a
22+
fake clock talks to a mock rather than to the sandbox.

‎ably/realtime/channel.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from ably.types.mixins import DecodingContext
2020
from ably.types.operations import MessageOperation, PublishResult, UpdateDeleteResult
2121
from ably.types.presence import PresenceMessage
22+
from ably.util.clock import select_clock
2223
from ably.util.eventemitter import EventEmitter
2324
from ably.util.exceptions import AblyException, IncompatibleClientIdException
2425
from ably.util.helper import Timer, is_callable_or_coroutine, validate_message_size
@@ -58,6 +59,7 @@ def __init__(self, realtime: AblyRealtime, name: str, channel_options: ChannelOp
5859
EventEmitter.__init__(self)
5960
self.__name = name
6061
self.__realtime = realtime
62+
self.__clock = select_clock(realtime.options)
6163
self.__state = ChannelState.INITIALIZED
6264
self.__message_emitter = EventEmitter()
6365
self.__state_timer: Timer | None = None
@@ -846,7 +848,7 @@ def on_timeout() -> None:
846848
self.__state_timer = None
847849
self.__timeout_pending_state()
848850

849-
self.__state_timer = Timer(self.__realtime.options.realtime_request_timeout, on_timeout)
851+
self.__state_timer = self.__clock.timer(self.__realtime.options.realtime_request_timeout, on_timeout)
850852

851853
def __clear_state_timer(self) -> None:
852854
if self.__state_timer:
@@ -866,7 +868,8 @@ def __start_retry_timer(self) -> None:
866868
if self.__retry_timer:
867869
return
868870

869-
self.__retry_timer = Timer(self.ably.options.channel_retry_timeout, self.__on_retry_timer_expire)
871+
self.__retry_timer = self.__clock.timer(
872+
self.ably.options.channel_retry_timeout, self.__on_retry_timer_expire)
870873

871874
def __cancel_retry_timer(self) -> None:
872875
if self.__retry_timer:

‎ably/realtime/connectionmanager.py‎

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

33
import asyncio
44
import logging
5-
import time
65
from collections import deque
76
from itertools import zip_longest
87
from typing import TYPE_CHECKING
@@ -16,6 +15,7 @@
1615
from ably.types.connectionstate import ConnectionEvent, ConnectionState, ConnectionStateChange
1716
from ably.types.operations import PublishResult
1817
from ably.types.tokendetails import TokenDetails
18+
from ably.util.clock import Clock, select_clock
1919
from ably.util.eventemitter import EventEmitter
2020
from ably.util.exceptions import AblyException, IncompatibleClientIdException
2121
from ably.util.helper import Timer, get_random_id, is_token_error
@@ -123,23 +123,25 @@ def clear(self) -> None:
123123
class PendingPing:
124124
"""Represents a ping awaiting its heartbeat echo from the server"""
125125

126-
def __init__(self, id: str):
126+
def __init__(self, id: str, clock: Clock):
127127
self.id = id
128+
self.clock = clock
128129
self.future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
129-
self.start_time: float = time.monotonic()
130+
self.start_time: float = clock.monotonic_ms()
130131

131132
@property
132133
def message(self) -> dict:
133134
return {"action": ProtocolMessageAction.HEARTBEAT, "id": self.id}
134135

135136
@property
136137
def response_time_ms(self) -> float:
137-
return round((time.monotonic() - self.start_time) * 1000, 2)
138+
return round(self.clock.monotonic_ms() - self.start_time, 2)
138139

139140

140141
class ConnectionManager(EventEmitter):
141142
def __init__(self, realtime: AblyRealtime, initial_state):
142143
self.options = realtime.options
144+
self.clock = select_clock(self.options)
143145
self.__ably = realtime
144146
self.__state: ConnectionState = initial_state
145147
self.__pending_pings: dict[str, PendingPing] = {}
@@ -364,7 +366,7 @@ async def ping(self) -> float:
364366
raise AblyException("Cannot send ping request. Calling ping in invalid state", 400, 40000)
365367

366368
# RTN13e: the id tells this ping's echo apart from server heartbeats and other pings
367-
pending_ping = PendingPing(get_random_id())
369+
pending_ping = PendingPing(get_random_id(), self.clock)
368370
self.__pending_pings[pending_ping.id] = pending_ping
369371
try:
370372
# RTN13d: while connecting, the heartbeat goes out once the connection is established
@@ -379,7 +381,7 @@ async def ping(self) -> float:
379381
return pending_ping.response_time_ms
380382

381383
async def __send_heartbeat(self, pending_ping: PendingPing) -> None:
382-
pending_ping.start_time = time.monotonic()
384+
pending_ping.start_time = self.clock.monotonic_ms()
383385
try:
384386
await self.send_protocol_message(pending_ping.message)
385387
except Exception as error:
@@ -718,7 +720,7 @@ def on_transition_timer_expire():
718720

719721
log.debug(f'ConnectionManager.start_transition_timer(): setting timer for {timeout}ms')
720722

721-
self.transition_timer = Timer(timeout, on_transition_timer_expire)
723+
self.transition_timer = self.clock.timer(timeout, on_transition_timer_expire)
722724

723725
def cancel_transition_timer(self):
724726
log.debug('ConnectionManager.cancel_transition_timer()')
@@ -741,7 +743,7 @@ def on_suspend_timer_expire() -> None:
741743
)
742744
self.__fail_state = ConnectionState.SUSPENDED
743745

744-
self.suspend_timer = Timer(Defaults.connection_state_ttl, on_suspend_timer_expire)
746+
self.suspend_timer = self.clock.timer(Defaults.connection_state_ttl, on_suspend_timer_expire)
745747

746748
def check_suspend_timer(self, state: ConnectionState) -> None:
747749
if state not in (
@@ -764,7 +766,7 @@ def on_retry_timeout():
764766
self.retry_timer = None
765767
self.request_state(ConnectionState.CONNECTING)
766768

767-
self.retry_timer = Timer(interval, on_retry_timeout)
769+
self.retry_timer = self.clock.timer(interval, on_retry_timeout)
768770

769771
def cancel_retry_timer(self) -> None:
770772
if self.retry_timer:

‎ably/transport/websockettransport.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
from ably.transport.defaults import Defaults
1515
from ably.types.connectiondetails import ConnectionDetails
1616
from ably.types.operations import PublishResult
17+
from ably.util.clock import select_clock
1718
from ably.util.eventemitter import EventEmitter
1819
from ably.util.exceptions import AblyException
19-
from ably.util.helper import Timer, unix_time_ms
2020

2121
try:
2222
# websockets 15+ preferred imports
@@ -68,6 +68,8 @@ def __init__(self, connection_manager: ConnectionManager, host: str, params: dic
6868
self.ws_connect_task: asyncio.Task | None = None
6969
self.connection_manager = connection_manager
7070
self.options = self.connection_manager.options
71+
self.connect_func = self.__select_connect_func(self.options)
72+
self.clock = select_clock(self.options)
7173
self.is_connected = False
7274
self.idle_timer = None
7375
self.last_activity = None
@@ -78,6 +80,13 @@ def __init__(self, connection_manager: ConnectionManager, host: str, params: dic
7880
self.format = params.get('format', 'json')
7981
super().__init__()
8082

83+
@staticmethod
84+
def __select_connect_func(options):
85+
test_options = getattr(options, '_test_options', None)
86+
if test_options is not None and test_options.websocket_connect is not None:
87+
return test_options.websocket_connect
88+
return ws_connect
89+
8190
def connect(self):
8291
headers = HttpUtils.default_headers()
8392
query_params = urllib.parse.urlencode(self.params)
@@ -103,11 +112,11 @@ async def ws_connect(self, ws_url, headers):
103112
try:
104113
# Use additional_headers for websockets 15+, fallback to extra_headers for older versions
105114
try:
106-
async with ws_connect(ws_url, additional_headers=headers) as websocket:
115+
async with self.connect_func(ws_url, additional_headers=headers) as websocket:
107116
await self._handle_websocket_connection(ws_url, websocket)
108117
except TypeError:
109118
# Fallback for websockets 14 and earlier
110-
async with ws_connect(ws_url, extra_headers=headers) as websocket:
119+
async with self.connect_func(ws_url, extra_headers=headers) as websocket:
111120
await self._handle_websocket_connection(ws_url, websocket)
112121
except (WebSocketException, socket.gaierror) as e:
113122
exception = AblyException(f'Error opening websocket connection: {e}', 400, 40000)
@@ -294,11 +303,11 @@ async def send(self, message: dict):
294303
def set_idle_timer(self, timeout: float):
295304
if self.idle_timer:
296305
self.idle_timer.cancel()
297-
self.idle_timer = Timer(timeout, self.on_idle_timer_expire)
306+
self.idle_timer = self.clock.timer(timeout, self.on_idle_timer_expire)
298307

299308
async def on_idle_timer_expire(self):
300309
self.idle_timer = None
301-
since_last = unix_time_ms() - self.last_activity
310+
since_last = self.clock.now_ms() - self.last_activity
302311
time_remaining = self.max_idle_interval - since_last
303312
msg = f"No activity seen from realtime in {since_last} ms; assuming connection has dropped"
304313
if time_remaining <= 0:
@@ -310,7 +319,7 @@ async def on_idle_timer_expire(self):
310319
def on_activity(self):
311320
if not self.max_idle_interval:
312321
return
313-
self.last_activity = unix_time_ms()
322+
self.last_activity = self.clock.now_ms()
314323
self.set_idle_timer(self.max_idle_interval + 100)
315324

316325
async def disconnect(self, reason=None):

‎ably/types/testoptions.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ 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+
- `websocket_connect`: a callable which opens every realtime websocket
10+
connection the client makes, in place of `websockets.connect`. It is
11+
called as `websocket_connect(url, additional_headers=headers)`, or with
12+
`extra_headers=headers` if that raises `TypeError`, and returns an async
13+
context manager yielding an object supporting `__aiter__`, `send` and
14+
`close`.
915
- `clock`: the source of time the client reads, in place of
1016
`ably.util.clock.Clock`. It supplies `now_ms()`, `monotonic_ms()` and
1117
`timer(timeout_ms, callback)`, where `callback` is either a coroutine
@@ -17,6 +23,7 @@ class TestOptions:
1723
# any module importing it as declaring a test suite.
1824
__test__ = False
1925

20-
def __init__(self, http_transport=None, clock=None):
26+
def __init__(self, http_transport=None, websocket_connect=None, clock=None):
2127
self.http_transport = http_transport
28+
self.websocket_connect = websocket_connect
2229
self.clock = clock

‎ably/util/clock.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
class Clock:
88
"""The source of time for every decision a client makes from it.
99
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.
10+
Token expiry, the server-time offset, the fallback-host cache and the
11+
transport's idle detection all read the clock, and every delayed callback
12+
is scheduled through it, so replacing one moves all of them together.
1313
1414
`now_ms` is the time of day and may step; `monotonic_ms` only ever moves
1515
forward and is what a duration is measured with.

‎ably/util/helper.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ async def _job(self):
7575
def cancel(self):
7676
self._task.cancel()
7777

78+
7879
def validate_message_size(encoded_messages: list, use_binary_protocol: bool, max_message_size: int) -> None:
7980
"""Validate that encoded messages don't exceed the maximum size limit.
8081

0 commit comments

Comments
 (0)