pmwallets 0.1.2__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pmwallets
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: Official PMWallets SDK: Polymarket smart-money leaderboard, real-time wallet fills over WebSocket with gap replay, webhooks and exports.
5
5
  Project-URL: Homepage, https://pmwallets.com/docs
6
6
  Project-URL: Repository, https://github.com/polymarketwallets/pmwallets-python
@@ -53,6 +53,8 @@ async def main():
53
53
  client=pmw,
54
54
  store=FileStateStore("stream.json"), # a restart resumes exactly where it stopped
55
55
  on_fill=lambda fill, meta: print(meta.source, fill["side"], fill["price"], fill["tokenId"]),
56
+ on_event=lambda e: e["type"] == "fatal" and print(e["error"]), # e.g. another bot already holds the stream
57
+ # takeover=True, # replace whichever API client holds this account's stream (it stops with "replaced")
56
58
  )
57
59
  await stream.run()
58
60
 
@@ -67,9 +69,29 @@ asyncio.run(main())
67
69
  | `FillStream` | asyncio WebSocket with reconnect and keep-alive; detects missed frames by `session`/`seq` and replays them from the last fill delivered; de-duplicates by `eventId`; anchors behind the chain head on first start; persisted cursor |
68
70
  | `verify_webhook(raw_body, signature, secret)` | checks `x-pmw-signature` (HMAC-SHA256 of the raw body) |
69
71
 
70
- One stream per account. An API-key connection takes priority over the pmwallets.com feed page (the page never takes the stream from it); between two API connections the newest wins, so run one consumer per account. `HTTPS_PROXY` is honoured.
72
+ One stream per account:
71
73
 
72
- PMWallets' servers are in the United Kingdom: a consumer hosted in the UK or elsewhere in Europe receives fills soonest.
74
+ - An API-key connection takes priority over the pmwallets.com feed page (the page never takes the stream from it).
75
+ - Between two API connections **the first one keeps the stream**. A second one — another key of the same account, or the same bot started twice — is refused: `blocked`, then `fatal`, and the stream stops. Pass `takeover=True` to replace the holder instead; the holder gets `replaced`, then `fatal`, and stops (it does not fight back).
76
+ - A `FillStream` identifies itself with a random `client_id`, so its own reconnects after a network drop replace its own dead connection without needing `takeover`.
77
+ - When the server restarts it closes the socket with `1012`; the stream reconnects at once (event `restarting`) and replays anything it missed.
78
+
79
+ `HTTPS_PROXY` is honoured.
80
+
81
+ PMWallets' servers are in the United Kingdom. A consumer that also trades on Polymarket should run from Ireland (AWS eu-west-1): Polymarket does not accept API orders from the UK, the US and several EU countries.
82
+
83
+ ## Trade-history exports
84
+
85
+ A purchase is access to files: one zstd-compressed CSV per wallet per UTC day, downloadable at once and again later.
86
+
87
+ ```python
88
+ order = client.create_export(["0x…"], "2026-09-01", "2026-09-30")
89
+ for f in client.export_files(order["id"])["files"]:
90
+ data = client.download_export_file(f["wallet"], f["day"]) # .csv.zst
91
+ # pandas: pd.read_csv(io.BytesIO(data), compression="zstd")
92
+ ```
93
+
94
+ `export_file_url(wallet, day)` returns the short-lived link instead; the API key is never sent to the storage host.
73
95
 
74
96
  ## Resources
75
97
 
@@ -35,6 +35,8 @@ async def main():
35
35
  client=pmw,
36
36
  store=FileStateStore("stream.json"), # a restart resumes exactly where it stopped
37
37
  on_fill=lambda fill, meta: print(meta.source, fill["side"], fill["price"], fill["tokenId"]),
38
+ on_event=lambda e: e["type"] == "fatal" and print(e["error"]), # e.g. another bot already holds the stream
39
+ # takeover=True, # replace whichever API client holds this account's stream (it stops with "replaced")
38
40
  )
39
41
  await stream.run()
40
42
 
@@ -49,9 +51,29 @@ asyncio.run(main())
49
51
  | `FillStream` | asyncio WebSocket with reconnect and keep-alive; detects missed frames by `session`/`seq` and replays them from the last fill delivered; de-duplicates by `eventId`; anchors behind the chain head on first start; persisted cursor |
50
52
  | `verify_webhook(raw_body, signature, secret)` | checks `x-pmw-signature` (HMAC-SHA256 of the raw body) |
51
53
 
52
- One stream per account. An API-key connection takes priority over the pmwallets.com feed page (the page never takes the stream from it); between two API connections the newest wins, so run one consumer per account. `HTTPS_PROXY` is honoured.
54
+ One stream per account:
53
55
 
54
- PMWallets' servers are in the United Kingdom: a consumer hosted in the UK or elsewhere in Europe receives fills soonest.
56
+ - An API-key connection takes priority over the pmwallets.com feed page (the page never takes the stream from it).
57
+ - Between two API connections **the first one keeps the stream**. A second one — another key of the same account, or the same bot started twice — is refused: `blocked`, then `fatal`, and the stream stops. Pass `takeover=True` to replace the holder instead; the holder gets `replaced`, then `fatal`, and stops (it does not fight back).
58
+ - A `FillStream` identifies itself with a random `client_id`, so its own reconnects after a network drop replace its own dead connection without needing `takeover`.
59
+ - When the server restarts it closes the socket with `1012`; the stream reconnects at once (event `restarting`) and replays anything it missed.
60
+
61
+ `HTTPS_PROXY` is honoured.
62
+
63
+ PMWallets' servers are in the United Kingdom. A consumer that also trades on Polymarket should run from Ireland (AWS eu-west-1): Polymarket does not accept API orders from the UK, the US and several EU countries.
64
+
65
+ ## Trade-history exports
66
+
67
+ A purchase is access to files: one zstd-compressed CSV per wallet per UTC day, downloadable at once and again later.
68
+
69
+ ```python
70
+ order = client.create_export(["0x…"], "2026-09-01", "2026-09-30")
71
+ for f in client.export_files(order["id"])["files"]:
72
+ data = client.download_export_file(f["wallet"], f["day"]) # .csv.zst
73
+ # pandas: pd.read_csv(io.BytesIO(data), compression="zstd")
74
+ ```
75
+
76
+ `export_file_url(wallet, day)` returns the short-lived link instead; the API key is never sent to the storage host.
55
77
 
56
78
  ## Resources
57
79
 
@@ -26,7 +26,20 @@ pip install pmwallets # Python ≥ 3.10
26
26
 
27
27
  每个账户只有一条推送流。API key 建立的连接优先于 pmwallets.com 的推送页;两条 API 连接之间仍是后连的顶掉先连的。支持 `HTTPS_PROXY`。
28
28
 
29
- PMWallets 的服务器在英国:消费者部署在英国或欧洲其他地区,收到成交最快。
29
+ PMWallets 的服务器在英国。若还要在 Polymarket 下单,请部署在爱尔兰(AWS eu-west-1):Polymarket 不接受来自英国、美国及部分欧盟国家的 API 订单。
30
+
31
+ ## 历史成交导出
32
+
33
+ 购买得到的是文件的访问权:每个钱包每个 UTC 日一个 zstd 压缩的 CSV,付款后立即可下载,之后也能再下。
34
+
35
+ ```python
36
+ order = client.create_export(["0x…"], "2026-09-01", "2026-09-30")
37
+ for f in client.export_files(order["id"])["files"]:
38
+ data = client.download_export_file(f["wallet"], f["day"]) # .csv.zst
39
+ # pandas: pd.read_csv(io.BytesIO(data), compression="zstd")
40
+ ```
41
+
42
+ `export_file_url(wallet, day)` 只返回短时效链接;API key 不会发给存储服务。
30
43
 
31
44
  ## 相关链接
32
45
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pmwallets"
7
- version = "0.1.2"
7
+ version = "0.3.0"
8
8
  description = "Official PMWallets SDK: Polymarket smart-money leaderboard, real-time wallet fills over WebSocket with gap replay, webhooks and exports."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,13 @@
1
+ """Official PMWallets SDK — https://pmwallets.com/docs"""
2
+
3
+ from .client import DEFAULT_BASE_URL, AsyncClient, Client, PmwError
4
+ from .stream import CLOSE_API_SEAT_TAKEN, CLOSE_SERVICE_RESTART, FileStateStore, FillMeta, FillStream, MemoryStateStore, StreamState, UpgradeRefused, websockets_connector
5
+ from .types import Fill, FillCursor, FillsPage
6
+ from .webhook import verify_webhook
7
+
8
+ __all__ = [
9
+ "DEFAULT_BASE_URL", "AsyncClient", "Client", "PmwError",
10
+ "CLOSE_API_SEAT_TAKEN", "CLOSE_SERVICE_RESTART", "FileStateStore", "FillMeta", "FillStream", "MemoryStateStore", "StreamState", "UpgradeRefused", "websockets_connector",
11
+ "Fill", "FillCursor", "FillsPage", "verify_webhook",
12
+ ]
13
+ __version__ = "0.2.0"
@@ -63,6 +63,15 @@ class _Base:
63
63
  raise PmwError(res.status_code, body)
64
64
  return body if text else None
65
65
 
66
+ @classmethod
67
+ def _location(cls, res: httpx.Response) -> str:
68
+ """The redirect target of a purchased-file request; anything else is raised as PmwError."""
69
+ loc = res.headers.get("location")
70
+ if 300 <= res.status_code < 400 and loc:
71
+ return loc
72
+ cls._parse(res)
73
+ raise PmwError(res.status_code, res.text, "expected a redirect to the file")
74
+
66
75
  @staticmethod
67
76
  def _fills_query(since_block: int, since_log_index: int, limit: int) -> dict[str, Any]:
68
77
  return {"sinceBlock": since_block, "sinceLogIndex": since_log_index, "limit": limit}
@@ -154,9 +163,22 @@ class Client(_Base):
154
163
  def get_export(self, export_id: str) -> dict[str, Any]:
155
164
  return self.request("GET", f"/v1/account/exports/{_q(export_id)}")
156
165
 
157
- def export_download(self, export_id: str) -> dict[str, Any]:
158
- """A presigned download URL, valid 15 minutes."""
159
- return self.request("GET", f"/v1/account/exports/{_q(export_id)}/download")
166
+ def export_files(self, export_id: str) -> dict[str, Any]:
167
+ """The daily files an export grants: one per wallet per UTC day."""
168
+ return self.request("GET", f"/v1/account/exports/{_q(export_id)}/files")
169
+
170
+ def export_file_url(self, wallet: str, day: str) -> str:
171
+ """A short-lived (5 minute) link to one purchased daily file. The redirect is read, not followed,
172
+ so the API key is never sent to the storage host."""
173
+ res = self._http.request("GET", self.base_url + f"/v1/account/data/{_q(wallet)}/{_q(day)}", headers=self._headers, follow_redirects=False)
174
+ return self._location(res)
175
+
176
+ def download_export_file(self, wallet: str, day: str) -> bytes:
177
+ """One purchased daily file: zstd-compressed CSV bytes (.csv.zst)."""
178
+ res = self._http.get(self.export_file_url(wallet, day), timeout=max(self.timeout, 60.0))
179
+ if res.status_code != 200:
180
+ raise PmwError(res.status_code, res.text)
181
+ return res.content
160
182
 
161
183
 
162
184
  class AsyncClient(_Base):
@@ -233,5 +255,15 @@ class AsyncClient(_Base):
233
255
  async def get_export(self, export_id: str) -> dict[str, Any]:
234
256
  return await self.request("GET", f"/v1/account/exports/{_q(export_id)}")
235
257
 
236
- async def export_download(self, export_id: str) -> dict[str, Any]:
237
- return await self.request("GET", f"/v1/account/exports/{_q(export_id)}/download")
258
+ async def export_files(self, export_id: str) -> dict[str, Any]:
259
+ return await self.request("GET", f"/v1/account/exports/{_q(export_id)}/files")
260
+
261
+ async def export_file_url(self, wallet: str, day: str) -> str:
262
+ res = await self._http.request("GET", self.base_url + f"/v1/account/data/{_q(wallet)}/{_q(day)}", headers=self._headers, follow_redirects=False)
263
+ return self._location(res)
264
+
265
+ async def download_export_file(self, wallet: str, day: str) -> bytes:
266
+ res = await self._http.get(await self.export_file_url(wallet, day), timeout=max(self.timeout, 60.0))
267
+ if res.status_code != 200:
268
+ raise PmwError(res.status_code, res.text)
269
+ return res.content
@@ -5,14 +5,22 @@ import contextlib
5
5
  import json
6
6
  import math
7
7
  import os
8
+ import re
9
+ import secrets
8
10
  from collections import OrderedDict
9
11
  from dataclasses import asdict, dataclass
10
12
  from pathlib import Path
11
13
  from typing import Any, AsyncContextManager, Awaitable, Callable, Optional, Protocol, Union
14
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
12
15
 
13
16
  from .client import AsyncClient
14
17
  from .types import Fill
15
18
 
19
+ # close codes the server uses (see the WebSocket section of the API docs)
20
+ CLOSE_SERVICE_RESTART = 1012 # the server is restarting: reconnect at once
21
+ CLOSE_API_SEAT_TAKEN = 4411 # another API client of this account holds the stream
22
+ _CLIENT_ID = re.compile(r"[A-Za-z0-9_-]{8,64}")
23
+
16
24
 
17
25
  @dataclass
18
26
  class StreamState:
@@ -135,6 +143,8 @@ class FillStream:
135
143
  ws_options: Optional[dict[str, Any]] = None,
136
144
  replay_without_cursor: bool = False,
137
145
  anchor_lag_blocks: int = 200,
146
+ takeover: bool = False,
147
+ client_id: Optional[str] = None,
138
148
  ) -> None:
139
149
  self.client = client
140
150
  self.on_fill = on_fill
@@ -152,6 +162,16 @@ class FillStream:
152
162
  # your account; anchoring exactly at it could exclude a fill mined earlier but not yet pushed. The extra
153
163
  # blocks are replayed at most once more and dropped by eventId.
154
164
  self.anchor_lag_blocks = anchor_lag_blocks
165
+ # One stream per account, and the API client that connected FIRST keeps it: a second one is refused
166
+ # ("blocked", then "fatal"). takeover=True replaces whichever client holds it — that client is told
167
+ # "replaced" and stops. Only the first connection asks; our own reconnects are recognised by client_id.
168
+ self.takeover = takeover
169
+ self._ask_takeover = takeover
170
+ # Identifies this stream so its own reconnects replace its own dead connection instead of being refused
171
+ # by it. Default: random per FillStream.
172
+ self.client_id = client_id if client_id is not None else secrets.token_urlsafe(16)
173
+ if not _CLIENT_ID.fullmatch(self.client_id):
174
+ raise ValueError("client_id must be 8-64 characters of [A-Za-z0-9_-]")
155
175
  self.connector = connector or websockets_connector(ping_interval, **(ws_options or {}))
156
176
  self.state = StreamState()
157
177
  self._seen: "OrderedDict[str, bool]" = OrderedDict()
@@ -203,17 +223,29 @@ class FillStream:
203
223
  async def _loop(self) -> None:
204
224
  backoff = self.min_backoff
205
225
  while self._running:
206
- healthy = await self._connect_once()
226
+ healthy, code = await self._connect_once()
207
227
  if not self._running:
208
228
  break
229
+ if code == CLOSE_SERVICE_RESTART: # the server's replacement is taking over now: reconnect at once
230
+ backoff = self.min_backoff
231
+ continue
209
232
  backoff = self.min_backoff if healthy else min(backoff * 2, self.max_backoff)
210
233
  self._wake.clear()
211
234
  with contextlib.suppress(asyncio.TimeoutError):
212
235
  await asyncio.wait_for(self._wake.wait(), backoff)
213
236
 
214
- async def _connect_once(self) -> bool:
215
- """One connection's life; True when it got as far as a hello and did not fail."""
216
- url = self.client.ws_url
237
+ def _url(self) -> str:
238
+ """The stream URL with this client's identity (and, before the first hello, the takeover request)."""
239
+ u = urlsplit(self.client.ws_url)
240
+ q = [(k, v) for k, v in parse_qsl(u.query) if k not in ("clientId", "takeover")]
241
+ q.append(("clientId", self.client_id))
242
+ if self._ask_takeover:
243
+ q.append(("takeover", "1"))
244
+ return urlunsplit(u._replace(query=urlencode(q)))
245
+
246
+ async def _connect_once(self) -> tuple[bool, int]:
247
+ """One connection's life: (got as far as a hello and did not fail, the close code)."""
248
+ url = self._url()
217
249
  self._emit({"type": "connecting", "url": url})
218
250
  greeted = False
219
251
  failed = False
@@ -248,16 +280,25 @@ class FillStream:
248
280
  self._emit({"type": "fatal", "error": RuntimeError(f"WebSocket upgrade refused with {e.status_code}: check the API key")})
249
281
  else:
250
282
  self._emit({"type": "error", "error": e})
251
- return False
283
+ return False, 0
252
284
  except Exception as e:
253
285
  self._emit({"type": "error", "error": e})
254
- return False
286
+ return False, 0
255
287
  finally:
256
288
  self._socket = None
257
- if code == 1000 and "replaced" in reason.lower():
258
- self._emit({"type": "replaced"})
259
289
  self._emit({"type": "disconnected", "code": code, "reason": reason})
260
- return greeted and not failed
290
+ if code == 1000 and "replaced" in reason.lower() and self._running:
291
+ # another client took over: reconnecting would take the stream back and the two would fight
292
+ self._running = False
293
+ self._emit({"type": "replaced"})
294
+ self._emit({"type": "fatal", "error": RuntimeError("another API client of this account took the stream over (takeover); this stream has stopped")})
295
+ elif code == CLOSE_API_SEAT_TAKEN and self._running:
296
+ self._running = False
297
+ self._emit({"type": "blocked"})
298
+ self._emit({"type": "fatal", "error": RuntimeError("another API client of this account already holds the stream (one per account; the first one keeps it). Stop it, or pass takeover=True to replace it")})
299
+ elif code == CLOSE_SERVICE_RESTART:
300
+ self._emit({"type": "restarting"})
301
+ return greeted and not failed, code
261
302
 
262
303
  async def _handle_frame(self, m: dict[str, Any]) -> None:
263
304
  if m.get("type") == "hello" and isinstance(m.get("session"), str):
@@ -270,6 +311,7 @@ class FillStream:
270
311
  self.state.session = m["session"]
271
312
  self.state.seq = int(m.get("seq") or 0)
272
313
  await self.store.save(self.state)
314
+ self._ask_takeover = False # seated: from here on our own client_id is what gets us back in
273
315
  self._emit({"type": "hello", "session": m["session"], "seq": self.state.seq})
274
316
  return
275
317
  if m.get("type") != "fill" or not m.get("data") or not isinstance(m.get("seq"), int):
@@ -56,3 +56,38 @@ def test_verify_webhook():
56
56
  assert not verify_webhook(body, sig, "other")
57
57
  assert not verify_webhook(body, "zz", "sec")
58
58
  assert not verify_webhook(body, None, "sec")
59
+
60
+
61
+ W = "0x" + "ab" * 20
62
+
63
+
64
+ def _file_transport(calls):
65
+ def handler(req: httpx.Request):
66
+ calls.append(req)
67
+ if req.url.host == "api.example.com":
68
+ return httpx.Response(302, headers={"location": "https://r2.example/signed"})
69
+ return httpx.Response(200, content=b"\x01\x02\x03")
70
+ return httpx.MockTransport(handler)
71
+
72
+
73
+ def test_download_reads_the_redirect_and_never_sends_the_key_to_storage():
74
+ calls = []
75
+ c = Client("pmw_a_b", "https://api.example.com", http=httpx.Client(transport=_file_transport(calls)))
76
+ assert c.download_export_file(W, "2026-09-25") == b"\x01\x02\x03"
77
+ assert str(calls[0].url) == f"https://api.example.com/v1/account/data/{W}/2026-09-25"
78
+ assert str(calls[1].url) == "https://r2.example/signed"
79
+ assert "x-api-key" not in calls[1].headers
80
+
81
+
82
+ async def test_async_download_reads_the_redirect():
83
+ calls = []
84
+ c = AsyncClient("pmw_a_b", "https://api.example.com", http=httpx.AsyncClient(transport=_file_transport(calls)))
85
+ assert await c.download_export_file(W, "2026-09-25") == b"\x01\x02\x03"
86
+ assert "x-api-key" not in calls[1].headers
87
+
88
+
89
+ def test_no_purchased_file_is_pmw_error():
90
+ c = Client("k", http=httpx.Client(transport=transport([{"statusCode": 404, "message": "no purchased file for this wallet and day"}], 404)))
91
+ with pytest.raises(PmwError) as e:
92
+ c.export_file_url(W, "2026-09-25")
93
+ assert e.value.status == 404
@@ -2,6 +2,9 @@ import asyncio
2
2
  import contextlib
3
3
  import json
4
4
 
5
+ import pytest
6
+ from urllib.parse import parse_qs, urlsplit
7
+
5
8
  from pmwallets.stream import FileStateStore, FillStream, MemoryStateStore, UpgradeRefused
6
9
 
7
10
 
@@ -68,13 +71,14 @@ class FakeClient:
68
71
  yield f
69
72
 
70
73
 
71
- def harness(ledger, fail_on=None, refuse=None, head=209):
72
- sockets, delivered, events = [], [], []
74
+ def harness(ledger, fail_on=None, refuse=None, head=209, takeover=False, min_backoff=0.001):
75
+ sockets, delivered, events, urls = [], [], [], []
73
76
  client = FakeClient(ledger, head)
74
77
  state = {"fail": fail_on}
75
78
 
76
79
  @contextlib.asynccontextmanager
77
80
  async def connector(url, headers):
81
+ urls.append(parse_qs(urlsplit(url).query))
78
82
  if refuse:
79
83
  raise UpgradeRefused(refuse)
80
84
  s = FakeSocket()
@@ -90,8 +94,9 @@ def harness(ledger, fail_on=None, refuse=None, head=209):
90
94
  raise RuntimeError("handler failed")
91
95
  delivered.append((f["eventId"], meta.source))
92
96
 
93
- stream = FillStream(client, on_fill, on_event=events.append, store=MemoryStateStore(), min_backoff=0.001,
94
- max_backoff=0.002, connector=connector)
97
+ stream = FillStream(client, on_fill, on_event=events.append, store=MemoryStateStore(), min_backoff=min_backoff,
98
+ max_backoff=max(min_backoff, 0.002), connector=connector, takeover=takeover)
99
+ stream.urls = urls
95
100
  return stream, sockets, client, delivered, events
96
101
 
97
102
 
@@ -171,15 +176,68 @@ async def test_401_is_fatal():
171
176
  assert [e["type"] for e in events].count("connecting") == 1
172
177
 
173
178
 
174
- async def test_reports_takeover():
179
+ async def test_replaced_by_takeover_stops_for_good():
175
180
  stream, sockets, _, _, events = harness([])
176
181
  await stream.start(); await tick()
177
182
  sockets[0].remote_close(1000, "replaced by a newer connection")
183
+ await asyncio.wait_for(stream.wait(), 1)
184
+ types = [e["type"] for e in events]
185
+ assert "replaced" in types and "fatal" in types
186
+ assert len(sockets) == 1 # reconnecting would take the stream back and the two would fight
187
+
188
+
189
+ async def test_refused_4411_stops_and_says_how_to_take_over():
190
+ stream, sockets, _, _, events = harness([])
191
+ await stream.start(); await tick()
192
+ sockets[0].send({"type": "blocked", "reason": "api_seat_taken"})
193
+ sockets[0].remote_close(4411, "another API client is connected")
194
+ await asyncio.wait_for(stream.wait(), 1)
195
+ assert any(e["type"] == "blocked" for e in events)
196
+ fatal = [e for e in events if e["type"] == "fatal"]
197
+ assert fatal and "takeover" in str(fatal[0]["error"])
198
+ assert len(sockets) == 1
199
+
200
+
201
+ async def test_server_restart_1012_reconnects_at_once():
202
+ stream, sockets, _, _, events = harness([], min_backoff=60) # a backoff the test would time out waiting for
203
+ await stream.start(); await tick()
204
+ sockets[0].send({"type": "hello", "session": "A", "seq": 0})
178
205
  await tick()
179
- assert any(e["type"] == "replaced" for e in events)
206
+ sockets[0].remote_close(1012, "service restart; reconnect now")
207
+ await tick(); await tick()
208
+ assert any(e["type"] == "restarting" for e in events)
209
+ assert len(sockets) == 2
180
210
  await stream.stop()
181
211
 
182
212
 
213
+ async def test_client_id_on_every_connection_takeover_only_until_seated():
214
+ stream, sockets, _, _, _ = harness([], takeover=True)
215
+ await stream.start(); await tick()
216
+ sockets[0].send({"type": "hello", "session": "A", "seq": 0})
217
+ await tick()
218
+ sockets[0].remote_close(1006, "")
219
+ await tick(); await tick()
220
+ assert len(stream.urls) == 2
221
+ cid = stream.urls[0]["clientId"][0]
222
+ assert 8 <= len(cid) <= 64
223
+ assert stream.urls[1]["clientId"] == [cid] # the same id, so the server knows it is us coming back
224
+ assert stream.urls[0]["takeover"] == ["1"]
225
+ assert "takeover" not in stream.urls[1] # a reconnect must not snatch the stream from a client that took it meanwhile
226
+ await stream.stop()
227
+
228
+
229
+ async def test_never_asks_to_take_over_unless_told():
230
+ stream, sockets, _, _, _ = harness([])
231
+ await stream.start(); await tick()
232
+ assert "takeover" not in stream.urls[0]
233
+ await stream.stop()
234
+
235
+
236
+ def test_malformed_client_id_is_refused():
237
+ with pytest.raises(ValueError, match="client_id"):
238
+ FillStream(FakeClient([]), lambda f, m: None, client_id="short")
239
+
240
+
183
241
  async def test_anchors_behind_the_chain_head_on_first_hello():
184
242
  # head 300, lag 200 → replay covers blocks ≥ 100. Block 99 is history; block 150 was mined before we connected
185
243
  # but had not been pushed yet (the head ran ahead of the fill index).