synpath 0.1.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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
synpath/ws/grpc.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""gRPC server streams as `Stream`s, and the venue protos they are read with.
|
|
2
|
+
|
|
3
|
+
The Polymarket US exchange API streams over gRPC rather than WebSockets. A
|
|
4
|
+
`GrpcStream` keeps the promises of `synpath.ws.base.Stream` -- typed events
|
|
5
|
+
on one queue, reconnects with jittered backoff, `gap`, `reconcile_required`,
|
|
6
|
+
a bad message is an `error` and not the end -- over a server-streaming RPC.
|
|
7
|
+
|
|
8
|
+
**Protos are the user's.** Polymarket publishes its `.proto` files as a
|
|
9
|
+
download without a license, so synpath ships neither the protos nor code
|
|
10
|
+
generated from them. Point a stream at the unzipped bundle (or the zip
|
|
11
|
+
itself), or set `SYNPATH_POLYMARKET_US_PROTOS`; the bundle is compiled once
|
|
12
|
+
into a descriptor set cached under `~/.cache/synpath/protos`, keyed by the
|
|
13
|
+
protos' contents, and messages are built from that at run time. Nothing is
|
|
14
|
+
written into the package or onto `sys.path`.
|
|
15
|
+
|
|
16
|
+
Messages are handed to venue code as dicts in the protos' JSON mapping
|
|
17
|
+
(camelCase names, int64s as strings, enums by name, timestamps as RFC 3339),
|
|
18
|
+
the same shapes the exchange's REST API returns, so one set of normalizers
|
|
19
|
+
reads both. Fields left at their proto3 default are written out, because
|
|
20
|
+
the default of an enum is a real value here (`ORDER_STATE_NEW`,
|
|
21
|
+
`INSTRUMENT_STATE_CLOSED`).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import hashlib
|
|
27
|
+
import os
|
|
28
|
+
import zipfile
|
|
29
|
+
from collections import OrderedDict
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Literal
|
|
33
|
+
|
|
34
|
+
from .base import _CLOSED, Stream, log, now_ms
|
|
35
|
+
|
|
36
|
+
PROTO_ENV = "SYNPATH_POLYMARKET_US_PROTOS"
|
|
37
|
+
|
|
38
|
+
CallOpener = Callable[[str, dict[str, Any], list[tuple[str, str]]], Awaitable[AsyncIterator[Any]]]
|
|
39
|
+
"""`(method, request, metadata) -> responses`: how a stream opens one call.
|
|
40
|
+
The default speaks gRPC; tests pass scripted responses."""
|
|
41
|
+
|
|
42
|
+
READY = object()
|
|
43
|
+
"""Yielded by a call once the server has answered (its headers arrived)."""
|
|
44
|
+
|
|
45
|
+
Outcome = Literal["retry", "reauthenticate", "slow_down", "fatal"]
|
|
46
|
+
|
|
47
|
+
RETRY_CODES = {"UNAVAILABLE", "INTERNAL", "UNKNOWN", "DEADLINE_EXCEEDED", "ABORTED", "CANCELLED", "DATA_LOSS"}
|
|
48
|
+
FATAL_CODES = {"PERMISSION_DENIED", "INVALID_ARGUMENT", "UNIMPLEMENTED", "NOT_FOUND", "FAILED_PRECONDITION", "OUT_OF_RANGE"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProtosMissing(RuntimeError):
|
|
52
|
+
"""The venue's protos were not given or cannot be compiled."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Protos
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class RpcMethod:
|
|
61
|
+
path: str
|
|
62
|
+
request: type
|
|
63
|
+
response: type
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cache_root() -> Path:
|
|
67
|
+
base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
|
|
68
|
+
return Path(base) / "synpath" / "protos"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_root(path: Path, anchor: str) -> Path | None:
|
|
72
|
+
if (path / anchor).is_file():
|
|
73
|
+
return path
|
|
74
|
+
for current, dirs, _files in os.walk(path):
|
|
75
|
+
dirs[:] = sorted(d for d in dirs if not d.startswith("."))
|
|
76
|
+
if (Path(current) / anchor).is_file():
|
|
77
|
+
return Path(current)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ProtoBundle:
|
|
82
|
+
"""A venue's protos, compiled into a private descriptor pool.
|
|
83
|
+
|
|
84
|
+
`path` is a directory holding the bundle (at any depth) or the zip it was
|
|
85
|
+
downloaded as. `anchor` is a file whose presence marks the import root.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
_loaded: dict[tuple[str, str], "ProtoBundle"] = {}
|
|
89
|
+
|
|
90
|
+
def __init__(self, path: str | os.PathLike[str] | None = None, *, anchor: str, files: tuple[str, ...], cache_dir: str | os.PathLike[str] | None = None):
|
|
91
|
+
try:
|
|
92
|
+
from google.protobuf import descriptor_pb2, descriptor_pool # noqa: F401
|
|
93
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
94
|
+
raise ImportError("gRPC streams need grpcio and protobuf: pip install synpath[grpc]") from exc
|
|
95
|
+
given = path if path is not None else os.environ.get(PROTO_ENV)
|
|
96
|
+
if not given:
|
|
97
|
+
raise ProtosMissing(
|
|
98
|
+
f"the Polymarket US exchange protos are needed for its gRPC streams: download the bundle from the "
|
|
99
|
+
f"venue's documentation and pass its directory or zip as `protos=`, or set {PROTO_ENV}"
|
|
100
|
+
)
|
|
101
|
+
self.cache_dir = Path(cache_dir) if cache_dir is not None else _cache_root()
|
|
102
|
+
source = Path(given).expanduser()
|
|
103
|
+
if source.is_file() and zipfile.is_zipfile(source):
|
|
104
|
+
source = self._unzip(source)
|
|
105
|
+
if not source.is_dir():
|
|
106
|
+
raise ProtosMissing(f"no proto bundle at {given}")
|
|
107
|
+
root = _find_root(source, anchor)
|
|
108
|
+
if root is None:
|
|
109
|
+
raise ProtosMissing(f"{given} does not contain {anchor}")
|
|
110
|
+
self.root = root
|
|
111
|
+
self.files = files
|
|
112
|
+
self.pool = self._pool(self._descriptor_set())
|
|
113
|
+
self._classes: dict[str, type] = {}
|
|
114
|
+
self._methods: dict[str, RpcMethod] = {}
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def load(cls, path: str | os.PathLike[str] | None = None, **kwargs: Any) -> "ProtoBundle":
|
|
118
|
+
"""One bundle per location per process."""
|
|
119
|
+
key = (str(path or os.environ.get(PROTO_ENV) or ""), repr(sorted(kwargs.items())))
|
|
120
|
+
bundle = cls._loaded.get(key)
|
|
121
|
+
if bundle is None:
|
|
122
|
+
bundle = cls._loaded[key] = cls(path, **kwargs)
|
|
123
|
+
return bundle
|
|
124
|
+
|
|
125
|
+
def _unzip(self, archive: Path) -> Path:
|
|
126
|
+
digest = hashlib.sha256(archive.read_bytes()).hexdigest()[:16]
|
|
127
|
+
target = self.cache_dir / f"bundle-{digest}"
|
|
128
|
+
if not target.is_dir():
|
|
129
|
+
staging = self.cache_dir / f".bundle-{digest}-{os.getpid()}"
|
|
130
|
+
with zipfile.ZipFile(archive) as zf:
|
|
131
|
+
base = staging.resolve()
|
|
132
|
+
for member in zf.namelist():
|
|
133
|
+
if not (base / member).resolve().is_relative_to(base):
|
|
134
|
+
raise ProtosMissing(f"{archive} has an entry outside the archive: {member}")
|
|
135
|
+
zf.extractall(staging)
|
|
136
|
+
if target.exists():
|
|
137
|
+
# Another process unpacked the same archive first.
|
|
138
|
+
import shutil
|
|
139
|
+
|
|
140
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
141
|
+
else:
|
|
142
|
+
staging.replace(target)
|
|
143
|
+
return target
|
|
144
|
+
|
|
145
|
+
def _descriptor_set(self) -> bytes:
|
|
146
|
+
protos = sorted(p for p in self.root.rglob("*.proto") if p.is_file())
|
|
147
|
+
digest = hashlib.sha256()
|
|
148
|
+
for proto in protos:
|
|
149
|
+
digest.update(str(proto.relative_to(self.root)).encode())
|
|
150
|
+
digest.update(proto.read_bytes())
|
|
151
|
+
digest.update(repr(self.files).encode())
|
|
152
|
+
out = self.cache_dir / f"{digest.hexdigest()[:24]}.pb"
|
|
153
|
+
if out.is_file():
|
|
154
|
+
return out.read_bytes()
|
|
155
|
+
try:
|
|
156
|
+
from importlib.resources import files as resource_files
|
|
157
|
+
|
|
158
|
+
from grpc_tools import protoc
|
|
159
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
160
|
+
raise ImportError("compiling the venue's protos needs grpcio-tools: pip install synpath[grpc]") from exc
|
|
161
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
staging = out.with_suffix(f".{os.getpid()}.tmp")
|
|
163
|
+
include = str(resource_files("grpc_tools") / "_proto")
|
|
164
|
+
missing = [f for f in self.files if not (self.root / f).is_file()]
|
|
165
|
+
if missing:
|
|
166
|
+
raise ProtosMissing(f"the bundle at {self.root} has no {missing[0]}")
|
|
167
|
+
code = protoc.main([
|
|
168
|
+
"protoc", f"-I{self.root}", f"-I{include}", "--include_imports", f"--descriptor_set_out={staging}", *self.files,
|
|
169
|
+
])
|
|
170
|
+
if code != 0 or not staging.is_file():
|
|
171
|
+
raise ProtosMissing(f"protoc could not compile the bundle at {self.root} (exit {code})")
|
|
172
|
+
staging.replace(out)
|
|
173
|
+
return out.read_bytes()
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _pool(data: bytes) -> Any:
|
|
177
|
+
from google.protobuf import descriptor_pb2, descriptor_pool
|
|
178
|
+
|
|
179
|
+
pool = descriptor_pool.DescriptorPool()
|
|
180
|
+
for proto in descriptor_pb2.FileDescriptorSet.FromString(data).file:
|
|
181
|
+
pool.Add(proto)
|
|
182
|
+
return pool
|
|
183
|
+
|
|
184
|
+
def message(self, full_name: str) -> type:
|
|
185
|
+
cls = self._classes.get(full_name)
|
|
186
|
+
if cls is None:
|
|
187
|
+
from google.protobuf import message_factory
|
|
188
|
+
|
|
189
|
+
cls = self._classes[full_name] = message_factory.GetMessageClass(self.pool.FindMessageTypeByName(full_name))
|
|
190
|
+
return cls
|
|
191
|
+
|
|
192
|
+
def method(self, name: str) -> RpcMethod:
|
|
193
|
+
"""`"package.Service/Method"` as a path and its message classes."""
|
|
194
|
+
found = self._methods.get(name)
|
|
195
|
+
if found is None:
|
|
196
|
+
service, _, rpc = name.partition("/")
|
|
197
|
+
descriptor = self.pool.FindServiceByName(service).FindMethodByName(rpc)
|
|
198
|
+
found = self._methods[name] = RpcMethod(
|
|
199
|
+
path=f"/{service}/{rpc}",
|
|
200
|
+
request=self.message(descriptor.input_type.full_name),
|
|
201
|
+
response=self.message(descriptor.output_type.full_name),
|
|
202
|
+
)
|
|
203
|
+
return found
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def to_dict(message: Any) -> dict[str, Any]:
|
|
207
|
+
from google.protobuf import json_format
|
|
208
|
+
|
|
209
|
+
return json_format.MessageToDict(message, always_print_fields_with_no_presence=True)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def from_dict(cls: type, data: dict[str, Any]) -> Any:
|
|
213
|
+
from google.protobuf import json_format
|
|
214
|
+
|
|
215
|
+
return json_format.ParseDict(data, cls())
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Helpers
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
class RecentIds:
|
|
223
|
+
"""The last `capacity` keys seen, for at-least-once streams."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, capacity: int = 50_000):
|
|
226
|
+
self.capacity = capacity
|
|
227
|
+
self._keys: OrderedDict[Any, None] = OrderedDict()
|
|
228
|
+
|
|
229
|
+
def seen(self, key: Any) -> bool:
|
|
230
|
+
"""True if `key` was already seen; records it either way."""
|
|
231
|
+
if key in self._keys:
|
|
232
|
+
self._keys.move_to_end(key)
|
|
233
|
+
return True
|
|
234
|
+
self._keys[key] = None
|
|
235
|
+
if len(self._keys) > self.capacity:
|
|
236
|
+
self._keys.popitem(last=False)
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
def __len__(self) -> int:
|
|
240
|
+
return len(self._keys)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def status_code(exc: BaseException) -> str | None:
|
|
244
|
+
"""The gRPC status name of an error, if it is an RPC error."""
|
|
245
|
+
code = getattr(exc, "code", None)
|
|
246
|
+
if not callable(code):
|
|
247
|
+
return None
|
|
248
|
+
try:
|
|
249
|
+
value = code()
|
|
250
|
+
except Exception:
|
|
251
|
+
return None
|
|
252
|
+
return getattr(value, "name", None) or (str(value) if value is not None else None)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def status_details(exc: BaseException) -> str:
|
|
256
|
+
details = getattr(exc, "details", None)
|
|
257
|
+
if callable(details):
|
|
258
|
+
try:
|
|
259
|
+
return str(details() or "")
|
|
260
|
+
except Exception:
|
|
261
|
+
return ""
|
|
262
|
+
return str(exc)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class IdleTimeout(Exception):
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class StreamEnded(Exception):
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Stream
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
class GrpcStream(Stream):
|
|
278
|
+
"""One server-streaming RPC, kept alive, turned into events.
|
|
279
|
+
|
|
280
|
+
Subclasses name the RPC (`method`), build the request (`request()`,
|
|
281
|
+
rebuilt for every call so a resume point or a new symbol set goes out),
|
|
282
|
+
give the call's metadata (`metadata()`), may do asynchronous work before
|
|
283
|
+
reading a message (`prepare()`), and read it (`handle()`, on the message
|
|
284
|
+
as a dict). `resubscribe()` ends the current call and opens a new one.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
method: str = ""
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
target: str,
|
|
292
|
+
*,
|
|
293
|
+
protos: ProtoBundle | None = None,
|
|
294
|
+
call: CallOpener | None = None,
|
|
295
|
+
keepalive_s: float = 300.0,
|
|
296
|
+
insecure: bool = False,
|
|
297
|
+
**kwargs: Any,
|
|
298
|
+
):
|
|
299
|
+
super().__init__(target, **kwargs)
|
|
300
|
+
self.insecure = insecure
|
|
301
|
+
"""Plaintext, for a local test server only."""
|
|
302
|
+
self.protos = protos
|
|
303
|
+
self._call_opener = call
|
|
304
|
+
self.keepalive_s = keepalive_s
|
|
305
|
+
self._channel: Any = None
|
|
306
|
+
self._grpc_call: Any = None
|
|
307
|
+
self._wake = asyncio.Event()
|
|
308
|
+
self._resubscribe = asyncio.Event()
|
|
309
|
+
self.last_status: str | None = None
|
|
310
|
+
|
|
311
|
+
# -- venue hooks ----------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def request(self) -> dict[str, Any] | None:
|
|
314
|
+
"""The request for the next call; `None` waits for `resubscribe()`."""
|
|
315
|
+
return {}
|
|
316
|
+
|
|
317
|
+
async def metadata(self) -> list[tuple[str, str]]:
|
|
318
|
+
return []
|
|
319
|
+
|
|
320
|
+
async def before_call(self, request: dict[str, Any]) -> dict[str, Any] | None:
|
|
321
|
+
"""Asynchronous work before a call opens; may return a changed request,
|
|
322
|
+
or `None` to open none and wait for `resubscribe()`."""
|
|
323
|
+
return request
|
|
324
|
+
|
|
325
|
+
async def prepare(self, message: dict[str, Any]) -> None:
|
|
326
|
+
"""Asynchronous work before `handle` reads a message (loading scales)."""
|
|
327
|
+
|
|
328
|
+
def reconcile_on_reconnect(self) -> bool:
|
|
329
|
+
return self.private
|
|
330
|
+
|
|
331
|
+
def classify(self, code: str | None) -> Outcome:
|
|
332
|
+
if code == "UNAUTHENTICATED":
|
|
333
|
+
return "reauthenticate"
|
|
334
|
+
if code == "RESOURCE_EXHAUSTED":
|
|
335
|
+
return "slow_down"
|
|
336
|
+
if code in FATAL_CODES:
|
|
337
|
+
return "fatal"
|
|
338
|
+
return "retry"
|
|
339
|
+
|
|
340
|
+
async def on_reauthenticate(self) -> None:
|
|
341
|
+
"""Forget the access token so the next call gets a fresh one."""
|
|
342
|
+
|
|
343
|
+
# -- lifecycle ------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def resubscribe(self) -> None:
|
|
346
|
+
"""Open the call again with a fresh `request()`."""
|
|
347
|
+
self._resubscribe.set()
|
|
348
|
+
self._wake.set()
|
|
349
|
+
|
|
350
|
+
async def send(self, frame: Any) -> bool: # pragma: no cover - server streams take no frames
|
|
351
|
+
raise NotImplementedError("a server-streaming RPC takes no frames after its request")
|
|
352
|
+
|
|
353
|
+
async def close(self) -> None:
|
|
354
|
+
await super().close()
|
|
355
|
+
channel, self._channel = self._channel, None
|
|
356
|
+
if channel is not None:
|
|
357
|
+
try:
|
|
358
|
+
await channel.close()
|
|
359
|
+
except Exception: # pragma: no cover - closing a broken channel
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
async def _open_call(self, request: dict[str, Any], metadata: list[tuple[str, str]]) -> AsyncIterator[Any]:
|
|
363
|
+
if self._call_opener is not None:
|
|
364
|
+
return await self._call_opener(self.method, request, metadata)
|
|
365
|
+
if self.protos is None:
|
|
366
|
+
raise ProtosMissing("no protos loaded")
|
|
367
|
+
import grpc
|
|
368
|
+
|
|
369
|
+
if self._channel is None:
|
|
370
|
+
options = [
|
|
371
|
+
("grpc.keepalive_time_ms", int(self.keepalive_s * 1000)),
|
|
372
|
+
("grpc.keepalive_timeout_ms", 20_000),
|
|
373
|
+
("grpc.max_receive_message_length", 64 * 1024 * 1024),
|
|
374
|
+
]
|
|
375
|
+
if self.insecure:
|
|
376
|
+
self._channel = grpc.aio.insecure_channel(self.url, options=options)
|
|
377
|
+
else:
|
|
378
|
+
self._channel = grpc.aio.secure_channel(self.url, grpc.ssl_channel_credentials(), options=options)
|
|
379
|
+
rpc = self.protos.method(self.method)
|
|
380
|
+
multi = self._channel.unary_stream(
|
|
381
|
+
rpc.path, request_serializer=rpc.request.SerializeToString, response_deserializer=rpc.response.FromString,
|
|
382
|
+
)
|
|
383
|
+
grpc_call = multi(self.protos.from_dict(rpc.request, request), metadata=metadata)
|
|
384
|
+
self._grpc_call = grpc_call
|
|
385
|
+
return self._responses(grpc_call)
|
|
386
|
+
|
|
387
|
+
async def _responses(self, grpc_call: Any) -> AsyncIterator[Any]:
|
|
388
|
+
try:
|
|
389
|
+
await grpc_call.initial_metadata()
|
|
390
|
+
# A call refused outright (bad token, too many streams) is already
|
|
391
|
+
# finished when its headers resolve: that is not a connection.
|
|
392
|
+
if not grpc_call.done():
|
|
393
|
+
yield READY
|
|
394
|
+
async for response in grpc_call:
|
|
395
|
+
yield response
|
|
396
|
+
finally:
|
|
397
|
+
grpc_call.cancel()
|
|
398
|
+
|
|
399
|
+
def _to_dict(self, response: Any) -> dict[str, Any]:
|
|
400
|
+
if isinstance(response, dict):
|
|
401
|
+
return response
|
|
402
|
+
if self.protos is not None:
|
|
403
|
+
return self.protos.to_dict(response)
|
|
404
|
+
from google.protobuf import json_format # pragma: no cover - a message without a bundle
|
|
405
|
+
|
|
406
|
+
return json_format.MessageToDict(response, always_print_fields_with_no_presence=True)
|
|
407
|
+
|
|
408
|
+
async def _consume(self, responses: AsyncIterator[Any], on_ready: Callable[[], None]) -> None:
|
|
409
|
+
iterator = responses.__aiter__()
|
|
410
|
+
while True:
|
|
411
|
+
try:
|
|
412
|
+
if self.idle_timeout:
|
|
413
|
+
item = await asyncio.wait_for(iterator.__anext__(), timeout=self.idle_timeout)
|
|
414
|
+
else:
|
|
415
|
+
item = await iterator.__anext__()
|
|
416
|
+
except StopAsyncIteration:
|
|
417
|
+
raise StreamEnded("the venue ended the stream") from None
|
|
418
|
+
except asyncio.TimeoutError:
|
|
419
|
+
raise IdleTimeout(f"no message for {self.idle_timeout}s") from None
|
|
420
|
+
on_ready()
|
|
421
|
+
self._last_seen = now_ms()
|
|
422
|
+
if item is READY:
|
|
423
|
+
continue
|
|
424
|
+
self.stats.messages += 1
|
|
425
|
+
self.stats.last_message_at = now_ms()
|
|
426
|
+
try:
|
|
427
|
+
message = self._to_dict(item)
|
|
428
|
+
await self.prepare(message)
|
|
429
|
+
events = self.handle(message)
|
|
430
|
+
except Exception as exc:
|
|
431
|
+
log.debug("synpath.ws %s %s: unreadable message %r", self.venue, self.name, item, exc_info=True)
|
|
432
|
+
self.status("error", f"unreadable message: {type(exc).__name__}: {exc}")
|
|
433
|
+
continue
|
|
434
|
+
for event in events:
|
|
435
|
+
self.emit(event)
|
|
436
|
+
|
|
437
|
+
def _fail(self, detail: str) -> None:
|
|
438
|
+
self._closing = True
|
|
439
|
+
self.status("failed", detail)
|
|
440
|
+
self._queue.put_nowait(_CLOSED)
|
|
441
|
+
|
|
442
|
+
async def _run(self) -> None:
|
|
443
|
+
attempt = 0
|
|
444
|
+
while not self._closing:
|
|
445
|
+
self._resubscribe.clear()
|
|
446
|
+
request = self.request()
|
|
447
|
+
if request is not None:
|
|
448
|
+
try:
|
|
449
|
+
request = await self.before_call(request)
|
|
450
|
+
except asyncio.CancelledError:
|
|
451
|
+
raise
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
self.status("connect_failed", f"{type(exc).__name__}: {exc}")
|
|
454
|
+
await self._sleep(self.backoff(attempt))
|
|
455
|
+
attempt += 1
|
|
456
|
+
continue
|
|
457
|
+
if request is None:
|
|
458
|
+
self._wake.clear()
|
|
459
|
+
if not self._resubscribe.is_set():
|
|
460
|
+
await self._wake.wait()
|
|
461
|
+
continue
|
|
462
|
+
try:
|
|
463
|
+
metadata = await self.metadata()
|
|
464
|
+
responses = await self._open_call(request, metadata)
|
|
465
|
+
except asyncio.CancelledError:
|
|
466
|
+
raise
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
code = status_code(exc)
|
|
469
|
+
self.last_status = code
|
|
470
|
+
self.status("connect_failed", f"{code or type(exc).__name__}: {status_details(exc) if code else exc}")
|
|
471
|
+
if await self._after_failure(code, f"{code}: {status_details(exc)}", attempt):
|
|
472
|
+
return
|
|
473
|
+
attempt += 1
|
|
474
|
+
continue
|
|
475
|
+
|
|
476
|
+
confirmed = False
|
|
477
|
+
|
|
478
|
+
def ready() -> None:
|
|
479
|
+
nonlocal confirmed, attempt
|
|
480
|
+
if confirmed:
|
|
481
|
+
return
|
|
482
|
+
confirmed = True
|
|
483
|
+
attempt = 0
|
|
484
|
+
reconnect = self.stats.connects > 0
|
|
485
|
+
self.stats.connects += 1
|
|
486
|
+
self.connected.set()
|
|
487
|
+
self.status("connected", "reconnected" if reconnect else "", reconcile=reconnect and self.reconcile_on_reconnect())
|
|
488
|
+
|
|
489
|
+
self._last_seen, self._stale = now_ms(), ""
|
|
490
|
+
loop = asyncio.get_running_loop()
|
|
491
|
+
reader = loop.create_task(self._consume(responses, ready))
|
|
492
|
+
waker = loop.create_task(self._resubscribe.wait())
|
|
493
|
+
watching = loop.create_task(self._stale_after_sleep()) if self.idle_timeout else None
|
|
494
|
+
watched = {reader, waker} | ({watching} if watching else set())
|
|
495
|
+
code: str | None = None
|
|
496
|
+
reason = "closed"
|
|
497
|
+
resubscribing = False
|
|
498
|
+
try:
|
|
499
|
+
done, _ = await asyncio.wait(watched, return_when=asyncio.FIRST_COMPLETED)
|
|
500
|
+
if reader in done:
|
|
501
|
+
exc = reader.exception()
|
|
502
|
+
code = status_code(exc) if exc else None
|
|
503
|
+
reason = f"{code}: {status_details(exc)}" if code else f"{type(exc).__name__}: {exc}" if exc else "ended"
|
|
504
|
+
elif watching is not None and watching in done:
|
|
505
|
+
reason = self._stale
|
|
506
|
+
else:
|
|
507
|
+
resubscribing = True
|
|
508
|
+
reason = "resubscribing"
|
|
509
|
+
finally:
|
|
510
|
+
for task in (reader, waker, watching):
|
|
511
|
+
if task is not None and not task.done():
|
|
512
|
+
task.cancel()
|
|
513
|
+
try:
|
|
514
|
+
await task
|
|
515
|
+
except (asyncio.CancelledError, Exception):
|
|
516
|
+
pass
|
|
517
|
+
aclose = getattr(responses, "aclose", None)
|
|
518
|
+
if aclose is not None:
|
|
519
|
+
try:
|
|
520
|
+
await aclose()
|
|
521
|
+
except (asyncio.CancelledError, Exception):
|
|
522
|
+
pass
|
|
523
|
+
self._grpc_call = None
|
|
524
|
+
if confirmed:
|
|
525
|
+
self.connected.clear()
|
|
526
|
+
self.stats.disconnects += 1
|
|
527
|
+
self.on_disconnect()
|
|
528
|
+
if self._closing:
|
|
529
|
+
return
|
|
530
|
+
self.last_status = code
|
|
531
|
+
# A call the venue refused before answering was never a connection,
|
|
532
|
+
# and one replaced before it answered was neither.
|
|
533
|
+
if confirmed:
|
|
534
|
+
self.status("disconnected", reason)
|
|
535
|
+
elif not resubscribing:
|
|
536
|
+
self.status("connect_failed", reason)
|
|
537
|
+
if resubscribing:
|
|
538
|
+
continue
|
|
539
|
+
if await self._after_failure(code, reason, attempt):
|
|
540
|
+
return
|
|
541
|
+
if not confirmed:
|
|
542
|
+
attempt += 1
|
|
543
|
+
|
|
544
|
+
async def _after_failure(self, code: str | None, reason: str, attempt: int) -> bool:
|
|
545
|
+
"""Wait as the failure deserves; `True` when the stream must stop."""
|
|
546
|
+
outcome = self.classify(code)
|
|
547
|
+
if outcome == "fatal":
|
|
548
|
+
self._fail(reason)
|
|
549
|
+
return True
|
|
550
|
+
if outcome == "reauthenticate":
|
|
551
|
+
await self.on_reauthenticate()
|
|
552
|
+
await self._sleep(self.backoff(attempt) if attempt else 0)
|
|
553
|
+
elif outcome == "slow_down":
|
|
554
|
+
await self._sleep(self.backoff_max)
|
|
555
|
+
else:
|
|
556
|
+
await self._sleep(self.backoff(attempt))
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
async def _stale_after_sleep(self) -> None:
|
|
560
|
+
"""Wall-clock silence, for the same reason as the WebSocket streams'
|
|
561
|
+
watchdog: a suspended machine freezes the event loop's timers while
|
|
562
|
+
the connection dies underneath them."""
|
|
563
|
+
step = max(1.0, (self.idle_timeout or 0) / 4)
|
|
564
|
+
while True:
|
|
565
|
+
await asyncio.sleep(step)
|
|
566
|
+
silent = (now_ms() - self._last_seen) / 1000
|
|
567
|
+
if self.idle_timeout and silent > self.idle_timeout:
|
|
568
|
+
self._stale = f"no message for {round(silent)}s (wall clock)"
|
|
569
|
+
return
|
|
570
|
+
|
|
571
|
+
async def _sleep(self, seconds: float) -> None:
|
|
572
|
+
"""Back off, cut short by `resubscribe()`."""
|
|
573
|
+
if seconds <= 0:
|
|
574
|
+
return
|
|
575
|
+
try:
|
|
576
|
+
await asyncio.wait_for(self._resubscribe.wait(), timeout=seconds)
|
|
577
|
+
except asyncio.TimeoutError:
|
|
578
|
+
pass
|