positronic-wire 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ """The client side of the gRPC wire, and the calls both ends of it agree on.
2
+
3
+ The stream is untyped bytes on both sides. There is no protobuf schema and no generated code.
4
+ """
5
+
6
+ import json
7
+ import queue
8
+ import threading
9
+ import time
10
+ from collections.abc import Mapping
11
+
12
+ import grpc
13
+ from positronic_wire import wire
14
+
15
+ # The one method every session runs on. gRPC routes by this path alone.
16
+ SERVICE = 'positronic.offboard.v1.Inference'
17
+ METHOD = 'Session'
18
+ METHOD_PATH = f'/{SERVICE}/{METHOD}'
19
+ # The unary method beside the session that resets the server's idle timer. Both ends carry JSON on it.
20
+ KEEPALIVE_METHOD = 'KeepAlive'
21
+ KEEPALIVE_METHOD_PATH = f'/{SERVICE}/{KEEPALIVE_METHOD}'
22
+
23
+ # A path no handler serves: a probe of it opens no session on a server that is up.
24
+ PROBE_PATH = f'/{SERVICE}/ChannelProbe'
25
+
26
+ # The session path and the query cross as metadata: a gRPC call carries no URL path of its own.
27
+ SESSION_PATH_HEADER = 'positronic-session-path'
28
+ SESSION_QUERY_HEADER = 'positronic-session-query'
29
+
30
+ MESSAGE_SIZE_OPTIONS = [
31
+ ('grpc.max_receive_message_length', wire.MAX_MESSAGE_BYTES),
32
+ ('grpc.max_send_message_length', wire.MAX_MESSAGE_BYTES),
33
+ ]
34
+
35
+ # How often the client pings an idle connection. A front drops a connection it reads nothing from.
36
+ PING_EVERY_MS = 20_000
37
+ _PING_ANSWER_TIMEOUT_MS = 10_000
38
+
39
+ # How long ``close`` waits for the server to end the stream and release the session.
40
+ _CLOSE_TIMEOUT_SEC = 5.0
41
+
42
+ # Status details naming a TLS failure that no retry clears: the edge's own configuration, or a certificate
43
+ # that covers an address this client did not dial.
44
+ _UNUSABLE_EDGE_DETAILS = (
45
+ 'CERTIFICATE_VERIFY_FAILED',
46
+ 'missing selected ALPN property',
47
+ 'Hostname Verification Check failed',
48
+ )
49
+
50
+ # Status details carrying an authoritative answer that the host has no address. A resolver that timed out
51
+ # says something else, which reads as no answer: a name can start resolving, where a misspelt one never does.
52
+ _NO_SUCH_HOST_DETAILS = ('Domain name not found', 'DNS server returned answer with no data')
53
+
54
+ # Status details naming a size limit: the metadata a session opens with, or a frame either end refuses.
55
+ # A retry sends the same oversized request, so no wait fixes it. Capacity exhaustion answers
56
+ # RESOURCE_EXHAUSTED too, says something else, and stays cold.
57
+ _HARD_LIMIT_DETAILS = ('exceeds hard limit', 'message larger than max')
58
+
59
+ # Status details gRPC writes where nothing answered: no address took the connection, or the name lookup timed
60
+ # out. A server that answers ``UNAVAILABLE`` says something else.
61
+ _NO_ANSWER_DETAILS = ('failed to connect to all addresses', 'Timeout while contacting DNS servers')
62
+
63
+ _COLD_CODES = (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.RESOURCE_EXHAUSTED)
64
+
65
+
66
+ def _refusal(status: grpc.RpcError) -> wire.Refusal:
67
+ """What a status that ended a call before it opened says about the server.
68
+
69
+ ``PERMISSION_DENIED`` reads as 403, ``UNAVAILABLE`` as 503, ``RESOURCE_EXHAUSTED`` as 429, and
70
+ ``DEADLINE_EXCEEDED`` as no answer before the deadline. Refusals that no wait fixes wear a retryable code
71
+ too — an unusable edge, a host with no address, a breached size limit — and their details tell them from a
72
+ cold backend. So do the details of a connect nothing answered.
73
+ """
74
+ details = status.details() or ''
75
+ if any(marker in details for marker in _UNUSABLE_EDGE_DETAILS + _NO_SUCH_HOST_DETAILS + _HARD_LIMIT_DETAILS):
76
+ return wire.Refusal.FINAL
77
+ code = status.code()
78
+ if code is grpc.StatusCode.DEADLINE_EXCEEDED or any(marker in details for marker in _NO_ANSWER_DETAILS):
79
+ return wire.Refusal.SILENT
80
+ if code is grpc.StatusCode.PERMISSION_DENIED:
81
+ return wire.Refusal.FORBIDDEN
82
+ return wire.Refusal.COLD if code in _COLD_CODES else wire.Refusal.FINAL
83
+
84
+
85
+ class GrpcClientConnection(wire.ClientConnection):
86
+ """A client's end of one gRPC session, over a ready ``channel``.
87
+
88
+ A reader thread drains the response stream into a queue: the stream has no per-message timeout, and
89
+ ``recv`` needs one.
90
+ """
91
+
92
+ def __init__(self, channel: grpc.Channel, target: str, metadata: tuple[tuple[str, str], ...]):
93
+ self._target = target
94
+ self._channel = channel
95
+ self._outbox: queue.SimpleQueue[bytes | None] = queue.SimpleQueue()
96
+ self._inbox: queue.SimpleQueue[bytes | BaseException] = queue.SimpleQueue()
97
+ # One receipt per frame gRPC writes, and a last ``False`` once the call can write no more.
98
+ self._written: queue.SimpleQueue[bool] = queue.SimpleQueue()
99
+ self._closed = False
100
+ self._ended = False
101
+ self._received = False
102
+ self._stopped_by: BaseException | None = None
103
+ call = self._channel.stream_stream(METHOD_PATH, request_serializer=None, response_deserializer=None)
104
+ self._responses = call(self._requests(), metadata=metadata)
105
+ self._reader = threading.Thread(target=self._read, name='grpc-session-reader', daemon=True)
106
+ self._reader.start()
107
+
108
+ def _requests(self):
109
+ """The outbound frames. ``None`` ends the stream, which half-closes the session."""
110
+ while (message := self._outbox.get()) is not None:
111
+ yield message
112
+ # gRPC asks for the next frame only once it has written this one, so the resume is the receipt.
113
+ self._written.put(True)
114
+
115
+ def _read(self) -> None:
116
+ """Drain the response stream into the inbox, and end the inbox with what stopped the stream."""
117
+ try:
118
+ for message in self._responses:
119
+ self._inbox.put(message)
120
+ self._stopped_by = wire.PeerDisconnected(f'{self._target} ended the session')
121
+ self._inbox.put(self._stopped_by)
122
+ except Exception as e:
123
+ self._stopped_by = e
124
+ self._inbox.put(e)
125
+ finally:
126
+ self._responses.cancel()
127
+ # The call is over, so no further frame leaves the iterator. Release a send waiting on one.
128
+ self._written.put(False)
129
+
130
+ def send(self, message: bytes) -> None:
131
+ if self._closed or self._ended:
132
+ raise wire.PeerDisconnected(f'The session on {self._target} has ended')
133
+ self._outbox.put(message)
134
+ # The call's end releases this wait, so a dead connection raises here instead of blocking. The end is
135
+ # recorded here too: the receipt queue says it once, and a later send must not wait for it again.
136
+ if not self._written.get():
137
+ self._ended = True
138
+ raise wire.PeerDisconnected(f'{self._target} ended the session: {self._stopped_by}') from self._stopped_by
139
+
140
+ def recv(self, timeout: float | None = None) -> bytes:
141
+ # A reply that arrived during ``close`` sits in the inbox, and would pair one observation's actions
142
+ # with the next observation.
143
+ if self._closed:
144
+ raise wire.PeerDisconnected(f'The session on {self._target} is closed')
145
+ try:
146
+ answer = self._inbox.get(timeout=timeout)
147
+ except queue.Empty:
148
+ raise TimeoutError(f'No message from {self._target} within {timeout}s') from None
149
+ if isinstance(answer, BaseException):
150
+ # What ended the stream is queued once. The caller reads it before ``send`` refuses a write.
151
+ self._ended = True
152
+ if isinstance(answer, grpc.RpcError):
153
+ # A status before any message crossed is the server refusing the call. A status after one is a
154
+ # lost peer, which the connect retry reads as cold.
155
+ if not self._received:
156
+ raise wire.ConnectRefused(_refusal(answer), str(answer)) from answer
157
+ raise wire.PeerDisconnected(f'{self._target} ended the session: {answer}') from answer
158
+ raise answer
159
+ self._received = True
160
+ return answer
161
+
162
+ def close(self) -> str:
163
+ if self._closed:
164
+ return 'already closed'
165
+ self._closed = True
166
+ self._outbox.put(None)
167
+ # The half-close ends the server's session, and the server then ends the stream. A channel closed
168
+ # before that cuts the server's cleanup short.
169
+ self._reader.join(timeout=_CLOSE_TIMEOUT_SEC)
170
+ server_ended_stream = not self._reader.is_alive()
171
+ self._channel.close()
172
+ # A stream the server never ended means the server still holds this session, and the next session's
173
+ # handshake waits on its slot.
174
+ return (
175
+ f'peer had ended the stream {self._ended}, '
176
+ f'server ended it within {_CLOSE_TIMEOUT_SEC}s {server_ended_stream}'
177
+ )
178
+
179
+
180
+ # The largest share of one connect attempt's budget the refusal probe may spend. Both waits fit inside
181
+ # the caller's ``open_timeout``: a target that drops every connect answers neither.
182
+ _REFUSAL_PROBE_SEC = 1.0
183
+
184
+
185
+ def _client_options() -> list[tuple[str, int]]:
186
+ return [
187
+ *MESSAGE_SIZE_OPTIONS,
188
+ ('grpc.keepalive_time_ms', PING_EVERY_MS),
189
+ ('grpc.keepalive_timeout_ms', _PING_ANSWER_TIMEOUT_MS),
190
+ # The gRPC default sends two pings without data, five minutes apart.
191
+ ('grpc.http2.max_pings_without_data', 0),
192
+ ('grpc.http2.min_time_between_pings_ms', PING_EVERY_MS),
193
+ ]
194
+
195
+
196
+ def target(host: str, port: int) -> str:
197
+ """``host:port`` in gRPC's target syntax, which needs an IPv6 literal in brackets."""
198
+ return f'{wire.bracket_ipv6(host)}:{port}'
199
+
200
+
201
+ def _probe_share(open_timeout: float) -> float:
202
+ """The share of one connect attempt the refusal probe gets; the readiness wait gets the rest.
203
+
204
+ Half at most: an ``open_timeout`` under ``_REFUSAL_PROBE_SEC`` still waits for a healthy server.
205
+ """
206
+ return min(_REFUSAL_PROBE_SEC, open_timeout / 2)
207
+
208
+
209
+ def _metadata(headers: Mapping[str, str] | None) -> tuple[tuple[str, str], ...]:
210
+ """``headers`` as gRPC metadata. The keys are lower case, as the server's authorization check reads them."""
211
+ return tuple((key.lower(), value) for key, value in (headers or {}).items())
212
+
213
+
214
+ def _connect_refusal(
215
+ channel: grpc.Channel, metadata: tuple[tuple[str, str], ...], timeout: float
216
+ ) -> grpc.RpcError | None:
217
+ """What gRPC says stopped a call on ``PROBE_PATH``, which a server that is up answers ``UNIMPLEMENTED``."""
218
+ probe = channel.stream_stream(PROBE_PATH, request_serializer=None, response_deserializer=None)
219
+ try:
220
+ next(probe(iter(()), metadata=metadata, timeout=timeout))
221
+ except grpc.RpcError as e:
222
+ return e
223
+ except StopIteration:
224
+ return None
225
+ return None
226
+
227
+
228
+ def _ready_channel(channel: grpc.Channel, target: str, open_timeout: float) -> grpc.Channel:
229
+ """``channel``, once it is ready. Raises ``wire.ConnectRefused`` when it is not within ``open_timeout``."""
230
+ deadline = time.monotonic() + open_timeout
231
+ try:
232
+ grpc.channel_ready_future(channel).result(timeout=open_timeout - _probe_share(open_timeout))
233
+ except grpc.FutureTimeoutError as not_ready:
234
+ refusal = _connect_refusal(channel, (), timeout=max(0.0, deadline - time.monotonic()))
235
+ # An ``UNIMPLEMENTED`` from the probe path means the channel is up: the readiness wait was too short.
236
+ if refusal is not None and refusal.code() is grpc.StatusCode.UNIMPLEMENTED:
237
+ return channel
238
+ channel.close()
239
+ if refusal is None:
240
+ message = f'gRPC channel to {target} is not ready within {open_timeout}s'
241
+ raise wire.ConnectRefused(wire.Refusal.SILENT, message) from not_ready
242
+ raise wire.ConnectRefused(_refusal(refusal), str(refusal)) from refusal
243
+ return channel
244
+
245
+
246
+ class GrpcClientWire(wire.ClientWire[wire.HostPortAddress]):
247
+ """The client side of the gRPC wire. Its port carries sessions and the keepalive call. The channel is plaintext."""
248
+
249
+ NAME = 'grpc'
250
+ ADDRESS = wire.HostPortAddress
251
+ DEFAULT_PORT = 80
252
+
253
+ def session_url(self, address: wire.HostPortAddress) -> str:
254
+ """gRPC dials a target, not a URL: ``host:port`` and the session route, for the log."""
255
+ query = f'?{address.query}' if address.query else ''
256
+ return f'{target(address.host, address.port)}{address.path}{query}'
257
+
258
+ def channel(self, target: str) -> grpc.Channel:
259
+ return grpc.insecure_channel(target, options=_client_options())
260
+
261
+ def dial(
262
+ self, address: wire.HostPortAddress, headers: Mapping[str, str] | None, open_timeout: float
263
+ ) -> GrpcClientConnection:
264
+ """A client's end of one session on ``address``. Raises ``wire.ConnectRefused`` when it does not open."""
265
+ dialled = target(address.host, address.port)
266
+ channel = _ready_channel(self.channel(dialled), dialled, open_timeout)
267
+ metadata = _metadata(headers) + ((SESSION_PATH_HEADER, address.path), (SESSION_QUERY_HEADER, address.query))
268
+ return GrpcClientConnection(channel, dialled, metadata)
269
+
270
+ def keepalive(self, address: wire.HostPortAddress, headers: Mapping[str, str] | None, timeout: float) -> int | None:
271
+ """Call ``KEEPALIVE_METHOD_PATH`` on its own channel. ``timeout`` bounds the whole call: the channel takes what
272
+ it needs, and the call takes the rest."""
273
+ dialled = target(address.host, address.port)
274
+ deadline = time.monotonic() + timeout
275
+ channel = _ready_channel(self.channel(dialled), dialled, timeout)
276
+ try:
277
+ unary = channel.unary_unary(KEEPALIVE_METHOD_PATH, request_serializer=None, response_deserializer=None)
278
+ answer = unary(b'', metadata=_metadata(headers), timeout=max(0.0, deadline - time.monotonic()))
279
+ except grpc.RpcError as e:
280
+ if e.code() is grpc.StatusCode.UNIMPLEMENTED:
281
+ raise wire.KeepaliveUnsupported(f'{dialled} serves sessions but not {KEEPALIVE_METHOD}') from e
282
+ raise wire.ConnectRefused(_refusal(e), f'{e} (calling {KEEPALIVE_METHOD} on {dialled})') from e
283
+ finally:
284
+ channel.close()
285
+ return json.loads(answer)[wire.ALIVE_SECONDS]
286
+
287
+ def probe(
288
+ self, address: wire.HostPortAddress, headers: Mapping[str, str] | None, open_timeout: float
289
+ ) -> wire.Refusal | None:
290
+ """One call on ``PROBE_PATH``, carrying ``headers``: a server that is up answers it ``UNIMPLEMENTED``."""
291
+ channel = self.channel(target(address.host, address.port))
292
+ try:
293
+ answered = _connect_refusal(channel, _metadata(headers), open_timeout)
294
+ finally:
295
+ channel.close()
296
+ if answered is None or answered.code() is grpc.StatusCode.UNIMPLEMENTED:
297
+ return None
298
+ return _refusal(answered)
299
+
300
+
301
+ class GrpcTlsClientWire(GrpcClientWire):
302
+ """The gRPC wire through a TLS edge in front of the server's plaintext port."""
303
+
304
+ NAME = 'grpc_tls'
305
+ DEFAULT_PORT = 443
306
+
307
+ def channel(self, target: str) -> grpc.Channel:
308
+ # No roots named: the channel verifies the edge against the system's own roots.
309
+ return grpc.secure_channel(target, grpc.ssl_channel_credentials(), options=_client_options())
File without changes
@@ -0,0 +1,37 @@
1
+ """Installed client transports, selected by name."""
2
+
3
+ from importlib.util import find_spec
4
+ from typing import Any
5
+
6
+ from positronic_wire import wire
7
+
8
+ # One stateless wire per name. A wire holds no address: the caller builds the wire's own and hands it in.
9
+ CLIENT_WIRES: dict[str, wire.ClientWire[Any]] = {}
10
+
11
+ if find_spec('websockets') is not None:
12
+ from positronic_wire import roboarena, websocket
13
+
14
+ for _member in (
15
+ websocket.WebsocketClientWire(),
16
+ websocket.WebsocketTlsClientWire(),
17
+ websocket.WebsocketUnixClientWire(),
18
+ roboarena.RoboarenaClientWire(),
19
+ ):
20
+ CLIENT_WIRES[_member.NAME] = _member
21
+
22
+ if find_spec('grpc') is not None:
23
+ from positronic_wire import grpc
24
+
25
+ for _member in (grpc.GrpcClientWire(), grpc.GrpcTlsClientWire()):
26
+ CLIENT_WIRES[_member.NAME] = _member
27
+
28
+
29
+ def client_wire(name: str) -> wire.ClientWire[Any]:
30
+ """Select an installed wire, or raise with the available names and installation extras."""
31
+ try:
32
+ return CLIENT_WIRES[name]
33
+ except KeyError:
34
+ raise ValueError(
35
+ f'No wire is called {name!r}; the installed wires are {", ".join(CLIENT_WIRES)}. '
36
+ 'Install positronic-wire[websocket] or positronic-wire[grpc] for the required transport.'
37
+ ) from None
@@ -0,0 +1,123 @@
1
+ """The client side of the roboarena wire: each frame carried as bytes, on a websocket at the root."""
2
+
3
+ import dataclasses
4
+ import time
5
+ from collections.abc import Mapping
6
+ from typing import Self
7
+
8
+ from positronic_wire import wire
9
+ from positronic_wire.websocket import WebsocketClientConnection, connected_socket, refusal_of
10
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake
11
+ from websockets.sync.client import connect
12
+
13
+
14
+ @dataclasses.dataclass(frozen=True)
15
+ class RoboarenaAddress(wire.SessionAddress):
16
+ """A session on a roboarena server, named by the host and the port the partner published."""
17
+
18
+ host: str
19
+ port: int
20
+ # Bare assignments, so neither is a field: the protocol routes on a key inside each frame, and the
21
+ # server closes any path but the root with `1008 Unsupported WebSocket path`.
22
+ path = ''
23
+ query = ''
24
+
25
+ def at_root(self) -> Self:
26
+ return self
27
+
28
+
29
+ class TextAnswer(Exception):
30
+ """The server answered in text. It serves nothing more on that connection, and a retry does not change the text."""
31
+
32
+ def __init__(self, text: str):
33
+ super().__init__(f'the server answered this text: {text}')
34
+ self.text = text
35
+
36
+
37
+ class RoboarenaClientConnection(WebsocketClientConnection):
38
+ """A client's end of one roboarena connection: a websocket session whose peer may answer in text."""
39
+
40
+ def recv(self, timeout: float | None = None) -> bytes:
41
+ try:
42
+ message = self._websocket.recv(timeout=timeout)
43
+ except ConnectionClosed as e:
44
+ raise wire.PeerDisconnected(str(e)) from e
45
+ if isinstance(message, str):
46
+ raise TextAnswer(message)
47
+ return message
48
+
49
+
50
+ class RoboarenaClientWire(wire.ClientWire[RoboarenaAddress]):
51
+ """The client side of the roboarena wire, whose root carries frames alone.
52
+
53
+ It sends the headers it is given on the handshake, and none where it is given none.
54
+ """
55
+
56
+ NAME = 'roboarena'
57
+ ADDRESS = RoboarenaAddress
58
+ # A server holds one connection open across a run and sends nothing between inferences, so a shorter
59
+ # pong deadline drops a quiet connection.
60
+ PING_INTERVAL_S = 60.0
61
+ PING_TIMEOUT_S = 600.0
62
+
63
+ def session_url(self, address: RoboarenaAddress) -> str:
64
+ """The root this wire dials; a roboarena session names no route under it."""
65
+ return f'ws://{wire.bracket_ipv6(address.host)}:{address.port}'
66
+
67
+ def keepalive(self, address: RoboarenaAddress, headers: Mapping[str, str] | None, timeout: float) -> int | None:
68
+ """Raises ``wire.KeepaliveUnsupported``: a partner's protocol carries no keepalive call."""
69
+ raise wire.KeepaliveUnsupported(f"{self.NAME} carries a partner's frames alone and answers no keepalive")
70
+
71
+ def _open(
72
+ self, address: RoboarenaAddress, headers: Mapping[str, str] | None, open_timeout: float, **settings
73
+ ) -> RoboarenaClientConnection:
74
+ """One opened connection on ``address``. Raises ``wire.ConnectRefused`` when it does not open."""
75
+ url = self.session_url(address)
76
+ try:
77
+ connection = connect(
78
+ url,
79
+ open_timeout=open_timeout,
80
+ additional_headers=headers,
81
+ compression=None,
82
+ ping_interval=self.PING_INTERVAL_S,
83
+ ping_timeout=self.PING_TIMEOUT_S,
84
+ max_size=wire.MAX_MESSAGE_BYTES,
85
+ **settings,
86
+ )
87
+ except (OSError, InvalidHandshake, ConnectionClosed) as e:
88
+ raise wire.ConnectRefused(refusal_of(e), f'{e} (connecting to {url})') from e
89
+ return RoboarenaClientConnection(connection)
90
+
91
+ def dial(
92
+ self, address: RoboarenaAddress, headers: Mapping[str, str] | None, open_timeout: float
93
+ ) -> RoboarenaClientConnection:
94
+ """A client's end of one session on ``address``.
95
+
96
+ The server announces its configuration as the first frame, and ``dial`` leaves it unread.
97
+ """
98
+ return self._open(address, headers, open_timeout)
99
+
100
+ def probe(
101
+ self, address: RoboarenaAddress, headers: Mapping[str, str] | None, open_timeout: float
102
+ ) -> wire.Refusal | None:
103
+ """Whether a server announces itself at ``address``. Raises ``TextAnswer`` when it answers in text.
104
+
105
+ ``open_timeout`` bounds the whole call: the name lookup, the handshake and the announcement.
106
+ """
107
+ deadline = time.monotonic() + open_timeout
108
+ try:
109
+ sock = connected_socket(address.host, address.port, open_timeout)
110
+ # The probe has nothing to finish, so it does not wait for the server's close.
111
+ connection = self._open(address, headers, max(0.0, deadline - time.monotonic()), sock=sock, close_timeout=0)
112
+ except OSError as e:
113
+ return refusal_of(e)
114
+ except wire.ConnectRefused as e:
115
+ return e.refusal
116
+ try:
117
+ connection.recv(timeout=max(0.0, deadline - time.monotonic()))
118
+ except (TimeoutError, wire.PeerDisconnected):
119
+ # The server upgraded and announced nothing: a backend still starting.
120
+ return wire.Refusal.COLD
121
+ finally:
122
+ connection.close()
123
+ return None