pipsocket 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.
@@ -0,0 +1,916 @@
1
+ """The stream helper's state machine. No I/O.
2
+
3
+ The shells in stream.py feed it inputs (the socket opened, a message arrived, a timer fired, a
4
+ REST read answered, the caller set ticks or closed) and carry out the actions it returns (open
5
+ or close the socket, send a text, start or cancel a timer, make a REST read, hand an event to the
6
+ caller, notify a wait(), stop with an error).
7
+
8
+ connecting --> failed refused (401, 404, 426 or another 4xx)
9
+ connecting --> backoff network error, or a 5xx from the upgrade
10
+ connecting --> syncing subscribed
11
+ syncing --> live ticks set, snapshot read, resync event sent
12
+ syncing --> syncing lagged, or the local queue overflowed: start again
13
+ syncing --> host_away engine_disconnected: start again once the host is back
14
+ syncing --> failed 401 or 404 on a resync read
15
+ syncing --> backoff socket closed, no pong, or the resync not done in 120 s
16
+ live --> syncing lagged, or the local queue overflowed
17
+ live --> host_away engine_disconnected
18
+ host_away --> syncing an event arrives, or the probe read answers
19
+ host_away --> failed 401 or 404 on the probe read
20
+ host_away --> backoff socket closed, or no pong
21
+ live --> backoff socket closed, or no pong
22
+ backoff --> connecting 0.5 s doubling to 30 s, with jitter
23
+ any --> closed close()
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from collections import deque
30
+ from collections.abc import Callable, Iterable
31
+ from dataclasses import dataclass, field
32
+ from enum import Enum
33
+ from typing import Any, Literal, cast
34
+
35
+ from ._core import refusal_from_body
36
+ from .errors import AccountUnavailable, AuthenticationError, NotFound, PipsocketError, RangeTooLarge
37
+
38
+ Json = dict[str, Any]
39
+
40
+ # Timings, in seconds.
41
+ PING_EVERY = 20.0
42
+ PONG_WITHIN = 10.0
43
+ SUBSCRIBED_WITHIN = 30.0
44
+ RESYNC_WITHIN = 120.0
45
+ FIRST_BACKOFF = 0.5
46
+ LONGEST_BACKOFF = 30.0
47
+ FIRST_PROBE = 2.0
48
+ LONGEST_PROBE = 30.0
49
+ # History: read back from the checkpoint less this margin, up to the newest time plus a day.
50
+ HISTORY_MARGIN = 300
51
+ HISTORY_AHEAD = 86400
52
+ SMALLEST_SLICE = 60
53
+ # How many deal and order tickets to remember for deduplicating history.
54
+ REMEMBERED_TICKETS = 10_000
55
+ # The API's events. Anything else (answers to calls, refusals of them) isn't one.
56
+ EVENTS = frozenset({"request", "deal", "transaction", "account", "status", "tick"})
57
+
58
+
59
+ class State(Enum):
60
+ CONNECTING = "connecting"
61
+ SYNCING = "syncing"
62
+ LIVE = "live"
63
+ HOST_AWAY = "host_away"
64
+ BACKOFF = "backoff"
65
+ FAILED = "failed"
66
+ CLOSED = "closed"
67
+
68
+
69
+ Reason = Literal["connected", "reconnected", "lagged", "host_back", "overflow"]
70
+
71
+
72
+ # Inputs
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Start:
77
+ """The caller opened the stream."""
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Opened:
82
+ """The upgrade succeeded."""
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Refused:
87
+ """The upgrade was answered with a status other than 101."""
88
+
89
+ status: int
90
+ error: PipsocketError
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Unreachable:
95
+ """The upgrade failed for a network reason: no status."""
96
+
97
+ error: Exception
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Closed:
102
+ """The socket closed."""
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Text:
107
+ """A text message that isn't JSON (the stream's "pong")."""
108
+
109
+ text: str
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Message:
114
+ """A JSON message from the stream."""
115
+
116
+ body: Json
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Timer:
121
+ name: str
122
+ generation: int
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Answered:
127
+ """A REST read the machine asked for: its value, or the error it raised."""
128
+
129
+ read: Read
130
+ value: Any = None
131
+ error: PipsocketError | None = None
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class SetTicks:
136
+ symbols: tuple[str, ...]
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Overflowed:
141
+ """The caller fell behind and the shell's queue of events filled up: it was emptied.
142
+ `dropped` are the events that never reached the caller, oldest first."""
143
+
144
+ dropped: tuple[Json, ...] = ()
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class Fatal:
149
+ """A read made outside the machine (a wait() lookup) failed. A 401 or 404 for the
150
+ account stops the stream too: it can no longer resync."""
151
+
152
+ error: PipsocketError
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Watch:
157
+ request_id: str
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class Unwatch:
162
+ request_id: str
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class Close:
167
+ """The caller closed the stream."""
168
+
169
+
170
+ Input = (
171
+ Start
172
+ | Opened
173
+ | Refused
174
+ | Unreachable
175
+ | Closed
176
+ | Text
177
+ | Message
178
+ | Timer
179
+ | Answered
180
+ | SetTicks
181
+ | Overflowed
182
+ | Fatal
183
+ | Watch
184
+ | Unwatch
185
+ | Close
186
+ )
187
+
188
+
189
+ # Actions
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class Connect:
194
+ pass
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class Disconnect:
199
+ pass
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class Send:
204
+ text: str
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class StartTimer:
209
+ name: str
210
+ seconds: float
211
+ generation: int
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class Read:
216
+ """A REST read: kind is account, positions, orders, deals, history_orders or request."""
217
+
218
+ kind: str
219
+ generation: int
220
+ request_id: str | None = None
221
+ from_: int | None = None
222
+ to: int | None = None
223
+
224
+
225
+ @dataclass(frozen=True)
226
+ class Emit:
227
+ event: Json
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class Notify:
232
+ """A recorded request seen on the stream or in a resync lookup, for wait() and for
233
+ trade calls to reconcile a slower REST answer with."""
234
+
235
+ body: Json
236
+
237
+
238
+ @dataclass(frozen=True)
239
+ class Fail:
240
+ error: PipsocketError
241
+
242
+
243
+ Action = Connect | Disconnect | Send | StartTimer | Read | Emit | Notify | Fail
244
+
245
+
246
+ # The resync in progress.
247
+
248
+
249
+ @dataclass
250
+ class Resync:
251
+ reason: Reason
252
+ generation: int
253
+ # Waiting for the socket's latest requested tick set to be acknowledged before reading.
254
+ waiting_ticks: bool = False
255
+ account: Json | None = None
256
+ positions: list[Json] | None = None
257
+ orders: list[Json] | None = None
258
+ terminal_away: bool = False
259
+ history_reason: str | None = None
260
+ history_from: int | None = None
261
+ history_to: int | None = None
262
+ deals: list[Json] = field(default_factory=list[Json])
263
+ history_orders: list[Json] = field(default_factory=list[Json])
264
+ # Ranges still to read, as (kind, from, to).
265
+ ranges: deque[tuple[str, int, int]] = field(default_factory=deque[tuple[str, int, int]])
266
+ lookups: set[str] = field(default_factory=set[str])
267
+ waiting: Read | None = None
268
+ # A different login or server than the checkpoint's: committed when the resync finishes.
269
+ new_identity: tuple[Any, Any] | None = None
270
+
271
+
272
+ @dataclass
273
+ class Checkpoint:
274
+ server_time: int | None = None
275
+ login: int | None = None
276
+ server: str | None = None
277
+
278
+
279
+ class StreamMachine:
280
+ def __init__(
281
+ self,
282
+ *,
283
+ ticks: Iterable[str] = (),
284
+ read_state: bool = True,
285
+ queue_limit: int = 1000,
286
+ random: Callable[[], float],
287
+ ) -> None:
288
+ self.state = State.CONNECTING
289
+ self.ticks: tuple[str, ...] = tuple(ticks)
290
+ self.read_state = read_state
291
+ self.queue_limit = queue_limit
292
+ self.random = random
293
+ self.watched: set[str] = set()
294
+ self.checkpoint = Checkpoint()
295
+ self.connected_before = False
296
+ self.socket_open = False
297
+ self.subscribed = False
298
+ self.failures = 0
299
+ self.probe_every = FIRST_PROBE
300
+ self.resync: Resync | None = None
301
+ self.probe: Read | None = None
302
+ self.pending_reason: Reason | None = None
303
+ self.held: list[Json] = []
304
+ self.newest_ticks: dict[str, Json] = {}
305
+ self.terminal_away = False
306
+ self.calls = 0
307
+ # One /ticks call at a time: calls on a stream run side by side, so the set last sent
308
+ # must be the one the stream keeps.
309
+ self.ticks_call: str | None = None
310
+ self.ticks_sending: tuple[str, ...] = ()
311
+ self.ticks_acknowledged: tuple[str, ...] = ()
312
+ self.generations: dict[str, int] = {}
313
+ self.read_generation = 0
314
+ self.recent_deals: deque[int] = deque(maxlen=REMEMBERED_TICKETS)
315
+ self.recent_orders: deque[int] = deque(maxlen=REMEMBERED_TICKETS)
316
+ # Account events emitted, with the checkpoint before each: an overflow takes back
317
+ # the ones the caller never got.
318
+ self.account_events: deque[tuple[Json, int | None]] = deque(maxlen=4 * queue_limit + 64)
319
+ self.out: list[Action] = []
320
+
321
+ # The one entry point.
322
+
323
+ def handle(self, event: Input) -> list[Action]:
324
+ self.out = []
325
+ if self.state in (State.FAILED, State.CLOSED):
326
+ return []
327
+ match event:
328
+ case Start():
329
+ self.connect()
330
+ case Close():
331
+ self.close()
332
+ case Opened():
333
+ self.opened()
334
+ case Refused(status=status, error=error):
335
+ self.refused(status, error)
336
+ case Unreachable():
337
+ self.lost()
338
+ case Closed():
339
+ self.lost()
340
+ case Text(text=text):
341
+ if text == "pong":
342
+ self.bump("pong")
343
+ case Message(body=body):
344
+ self.message(body)
345
+ case Timer(name=name, generation=generation):
346
+ if self.generations.get(name) == generation:
347
+ self.timer(name)
348
+ case Answered():
349
+ self.answered(event)
350
+ case SetTicks(symbols=symbols):
351
+ self.set_ticks(symbols)
352
+ case Overflowed(dropped=dropped):
353
+ self.take_back(dropped)
354
+ self.overflowed()
355
+ case Fatal(error=error):
356
+ if fatal(error):
357
+ self.fail(error)
358
+ case Watch(request_id=request_id):
359
+ self.watched.add(request_id)
360
+ case Unwatch(request_id=request_id):
361
+ self.watched.discard(request_id)
362
+ return self.out
363
+
364
+ # Timers: starting one with a name replaces the one before; bump() cancels it.
365
+
366
+ def start_timer(self, name: str, seconds: float) -> None:
367
+ generation = self.generations.get(name, 0) + 1
368
+ self.generations[name] = generation
369
+ self.out.append(StartTimer(name, seconds, generation))
370
+
371
+ def bump(self, name: str) -> None:
372
+ self.generations[name] = self.generations.get(name, 0) + 1
373
+
374
+ def stop_timers(self) -> None:
375
+ for name in list(self.generations):
376
+ self.bump(name)
377
+
378
+ # Connecting and losing the socket.
379
+
380
+ def connect(self) -> None:
381
+ self.state = State.CONNECTING
382
+ self.subscribed = False
383
+ self.out.append(Connect())
384
+
385
+ def opened(self) -> None:
386
+ if self.state is not State.CONNECTING:
387
+ return
388
+ self.socket_open = True
389
+ self.start_timer("subscribed", SUBSCRIBED_WITHIN)
390
+ self.start_timer("ping", PING_EVERY)
391
+
392
+ def refused(self, status: int, error: PipsocketError) -> None:
393
+ if status >= 500 or status == 429:
394
+ self.lost()
395
+ else:
396
+ self.fail(error)
397
+
398
+ def lost(self) -> None:
399
+ """The socket closed or never opened: wait, then connect again from scratch."""
400
+ if self.state in (State.BACKOFF, State.CLOSED, State.FAILED):
401
+ return
402
+ if self.socket_open:
403
+ self.out.append(Disconnect())
404
+ self.socket_open = False
405
+ self.subscribed = False
406
+ self.stop_timers()
407
+ self.abandon_resync()
408
+ self.ticks_call = None
409
+ self.ticks_acknowledged = ()
410
+ self.state = State.BACKOFF
411
+ self.start_timer("backoff", self.backoff())
412
+
413
+ def backoff(self) -> float:
414
+ """0.5 s doubling to 30 s, each times a random factor from 0.5 to 1."""
415
+ nominal = FIRST_BACKOFF * 2 ** min(self.failures, 16)
416
+ self.failures += 1
417
+ return min(nominal, LONGEST_BACKOFF) * (0.5 + 0.5 * self.random())
418
+
419
+ def close(self) -> None:
420
+ if self.socket_open:
421
+ self.out.append(Disconnect())
422
+ self.socket_open = False
423
+ self.stop_timers()
424
+ self.state = State.CLOSED
425
+
426
+ def fail(self, error: PipsocketError) -> None:
427
+ if self.socket_open:
428
+ self.out.append(Disconnect())
429
+ self.socket_open = False
430
+ self.stop_timers()
431
+ self.state = State.FAILED
432
+ self.out.append(Fail(error))
433
+
434
+ def timer(self, name: str) -> None:
435
+ if name == "backoff" and self.state is State.BACKOFF:
436
+ self.connect()
437
+ elif name == "ping" and self.socket_open:
438
+ self.out.append(Send("ping"))
439
+ self.start_timer("pong", PONG_WITHIN)
440
+ self.start_timer("ping", PING_EVERY)
441
+ elif name in ("pong", "subscribed", "resync_deadline"):
442
+ # The socket is dead, never subscribed, or a resync is stuck: start over.
443
+ self.lost()
444
+ elif name == "ticks_retry":
445
+ self.send_ticks_if_due()
446
+ elif name == "resync_retry" and self.state is State.SYNCING:
447
+ self.begin_resync_reads()
448
+ elif name == "probe" and self.state is State.HOST_AWAY:
449
+ self.read_generation += 1
450
+ self.probe = Read("account", self.read_generation)
451
+ self.out.append(self.probe)
452
+
453
+ # Messages.
454
+
455
+ def message(self, body: Json) -> None:
456
+ kind = body.get("type")
457
+ if kind == "subscribed":
458
+ if self.state is State.CONNECTING and not self.subscribed:
459
+ self.subscribed = True
460
+ self.bump("subscribed")
461
+ reason: Reason = "reconnected" if self.connected_before else "connected"
462
+ self.connected_before = True
463
+ self.begin_resync(reason)
464
+ return
465
+ if kind in ("response", "error") and body.get("id") == self.ticks_call:
466
+ self.ticks_answered(body)
467
+ return
468
+ if kind == "request":
469
+ self.out.append(Notify(body))
470
+ if self.state is State.SYNCING:
471
+ self.during_resync(body)
472
+ elif self.state is State.LIVE:
473
+ self.while_live(body)
474
+ elif self.state is State.HOST_AWAY:
475
+ self.host_back_with(body)
476
+
477
+ def while_live(self, body: Json) -> None:
478
+ kind = body.get("type")
479
+ if kind == "lagged":
480
+ self.emit(body)
481
+ self.begin_resync("lagged")
482
+ elif kind == "engine_disconnected":
483
+ self.emit(body)
484
+ self.host_away()
485
+ else:
486
+ self.deliver(body)
487
+ if kind == "status" and self.terminal_away and body.get("status") == "connected":
488
+ # The last snapshot had no positions or orders: the terminal is back now.
489
+ self.begin_resync("host_back")
490
+
491
+ def during_resync(self, body: Json) -> None:
492
+ kind = body.get("type")
493
+ if kind == "lagged":
494
+ self.begin_resync("lagged")
495
+ elif kind == "engine_disconnected":
496
+ self.host_away()
497
+ else:
498
+ self.hold(body)
499
+
500
+ def host_back_with(self, body: Json) -> None:
501
+ if body.get("type") not in EVENTS:
502
+ return
503
+ self.begin_resync("host_back")
504
+ self.hold(body)
505
+
506
+ def hold(self, body: Json) -> None:
507
+ """Keep an event that arrives during a resync, to deliver after it."""
508
+ kind = body.get("type")
509
+ if kind == "account":
510
+ return
511
+ if kind == "tick":
512
+ symbol = str(body.get("symbol"))
513
+ kept = self.newest_ticks.get(symbol)
514
+ if kept is None or (body.get("time_msc") or 0) >= (kept.get("time_msc") or 0):
515
+ self.newest_ticks[symbol] = body
516
+ return
517
+ if kind in ("response", "error"):
518
+ return
519
+ self.held.append(body)
520
+ if len(self.held) > self.queue_limit:
521
+ self.overflowed()
522
+
523
+ def host_away(self) -> None:
524
+ self.abandon_resync()
525
+ self.state = State.HOST_AWAY
526
+ self.probe_every = FIRST_PROBE
527
+ self.start_timer("probe", self.probe_every)
528
+
529
+ def overflowed(self) -> None:
530
+ if self.state not in (State.LIVE, State.SYNCING):
531
+ return
532
+ self.held.clear()
533
+ self.newest_ticks.clear()
534
+ self.begin_resync("overflow")
535
+
536
+ def take_back(self, dropped: tuple[Json, ...]) -> None:
537
+ """Events the caller never got don't count as delivered: the checkpoint goes back to
538
+ before the first dropped account event, and dropped deals aren't remembered."""
539
+ for body in dropped:
540
+ if body.get("type") == "deal":
541
+ forget(self.recent_deals, deal_ticket(body))
542
+ elif body.get("type") == "resync" and isinstance(body.get("history"), dict):
543
+ history = cast(Json, body["history"])
544
+ for deal in history.get("deals", []):
545
+ forget(self.recent_deals, deal_ticket(deal))
546
+ for order in history.get("orders", []):
547
+ forget(self.recent_orders, order_ticket(order))
548
+ dropped_ids = {id(body) for body in dropped if body.get("type") == "account"}
549
+ while self.account_events and id(self.account_events[-1][0]) in dropped_ids:
550
+ _, before = self.account_events.pop()
551
+ self.checkpoint.server_time = before
552
+
553
+ # Delivering.
554
+
555
+ def deliver(self, body: Json) -> None:
556
+ kind = body.get("type")
557
+ if kind == "account":
558
+ server_time = body.get("server_time")
559
+ if isinstance(server_time, int) and not isinstance(server_time, bool):
560
+ before = self.checkpoint.server_time
561
+ self.account_events.append((body, before))
562
+ self.checkpoint.server_time = max(before or 0, server_time)
563
+ elif kind == "deal":
564
+ ticket = deal_ticket(body)
565
+ if ticket is not None:
566
+ self.recent_deals.append(ticket)
567
+ self.emit(body)
568
+
569
+ def emit(self, body: Json) -> None:
570
+ self.out.append(Emit(body))
571
+
572
+ # Ticks.
573
+
574
+ def set_ticks(self, symbols: tuple[str, ...]) -> None:
575
+ """Remember the set, and send it once the socket is subscribed and no other /ticks
576
+ call is unanswered."""
577
+ self.ticks = symbols
578
+ self.send_ticks_if_due()
579
+
580
+ def send_ticks_if_due(self) -> None:
581
+ if not self.subscribed or self.ticks_call is not None:
582
+ return
583
+ if self.ticks == self.ticks_acknowledged:
584
+ return
585
+ self.calls += 1
586
+ self.ticks_call = f"ticks-{self.calls}"
587
+ self.ticks_sending = self.ticks
588
+ body = {
589
+ "id": self.ticks_call,
590
+ "method": "PUT",
591
+ "path": "/ticks",
592
+ "body": {"symbols": list(self.ticks)},
593
+ }
594
+ self.out.append(Send(json.dumps(body)))
595
+
596
+ def ticks_settled(self) -> bool:
597
+ return self.ticks_call is None and self.ticks == self.ticks_acknowledged
598
+
599
+ def ticks_answered(self, body: Json) -> None:
600
+ self.ticks_call = None
601
+ status = body.get("status")
602
+ if body.get("type") == "response" and status == 200:
603
+ self.ticks_acknowledged = self.ticks_sending
604
+ self.send_ticks_if_due()
605
+ resync = self.resync
606
+ if resync is not None and resync.waiting_ticks and self.ticks_settled():
607
+ resync.waiting_ticks = False
608
+ self.after_ticks()
609
+ return
610
+ if body.get("type") == "response" and isinstance(status, int) and 400 <= status < 500:
611
+ self.fail(ticks_refused(status, body.get("body")))
612
+ return
613
+ # Refused by the stream itself (too_many_calls): try the resync again, or send the
614
+ # set again later.
615
+ if self.resync is not None and self.resync.waiting_ticks:
616
+ self.resync_failed()
617
+ else:
618
+ self.start_timer("ticks_retry", self.backoff())
619
+
620
+ # Resync.
621
+
622
+ def begin_resync(self, reason: Reason) -> None:
623
+ restarting = self.state is State.SYNCING and self.resync is not None
624
+ if restarting and self.resync is not None:
625
+ # A gap during a resync: start again, so the snapshot is read after it. A new
626
+ # socket's resync keeps its reason, and its ticks if they aren't set yet. The 120 s
627
+ # deadline still counts from the first start, so gaps can't keep it syncing.
628
+ if self.resync.reason in ("connected", "reconnected"):
629
+ reason = self.resync.reason
630
+ self.bump("resync_retry")
631
+ else:
632
+ self.abandon_resync()
633
+ self.start_timer("resync_deadline", RESYNC_WITHIN)
634
+ self.state = State.SYNCING
635
+ self.read_generation += 1
636
+ self.resync = Resync(reason, self.read_generation)
637
+ self.begin_resync_reads()
638
+
639
+ def abandon_resync(self) -> None:
640
+ if self.resync is not None:
641
+ self.bump("resync_retry")
642
+ self.bump("resync_deadline")
643
+ self.resync = None
644
+
645
+ def begin_resync_reads(self) -> None:
646
+ resync = self.resync
647
+ assert resync is not None
648
+ self.read_generation += 1
649
+ resync.generation = self.read_generation
650
+ # Every resync reads after the socket's latest tick set is acknowledged: on a new socket
651
+ # that is the set sent again, and a set asked for meanwhile waits its turn too.
652
+ self.send_ticks_if_due()
653
+ if not self.ticks_settled():
654
+ resync.waiting_ticks = True
655
+ return
656
+ self.after_ticks()
657
+
658
+ def after_ticks(self) -> None:
659
+ resync = self.resync
660
+ assert resync is not None
661
+ if self.read_state:
662
+ self.read(resync, Read("account", resync.generation))
663
+ else:
664
+ self.finish_resync()
665
+
666
+ def read(self, resync: Resync, read: Read) -> None:
667
+ resync.waiting = read
668
+ self.out.append(read)
669
+
670
+ def answered(self, answer: Answered) -> None:
671
+ read = answer.read
672
+ if answer.error is not None and fatal(answer.error):
673
+ # The key was rolled or the account is gone: that holds whether or not this read's
674
+ # answer is still wanted.
675
+ self.fail(answer.error)
676
+ return
677
+ if read == self.probe:
678
+ self.probe = None
679
+ if self.state is State.HOST_AWAY:
680
+ self.probe_answered(answer)
681
+ return
682
+ resync = self.resync
683
+ if self.state is not State.SYNCING or resync is None or read != resync.waiting:
684
+ return
685
+ resync.waiting = None
686
+ error = answer.error
687
+ if error is not None:
688
+ if fatal(error):
689
+ self.fail(error)
690
+ elif read.kind in ("positions", "orders") and isinstance(error, AccountUnavailable):
691
+ resync.terminal_away = True
692
+ self.next_after_snapshot(resync)
693
+ elif read.kind in ("deals", "history_orders") and isinstance(error, RangeTooLarge):
694
+ self.split_range(resync, read)
695
+ elif read.kind in ("deals", "history_orders") and isinstance(error, AccountUnavailable):
696
+ self.history_unavailable(resync, "unavailable")
697
+ elif read.kind == "request":
698
+ resync.lookups.discard(read.request_id or "")
699
+ self.next_lookup(resync)
700
+ else:
701
+ self.resync_failed()
702
+ return
703
+
704
+ value = answer.value
705
+ if read.kind == "account":
706
+ resync.account = value
707
+ self.read(resync, Read("positions", resync.generation))
708
+ elif read.kind == "positions":
709
+ resync.positions = value
710
+ self.read(resync, Read("orders", resync.generation))
711
+ elif read.kind == "orders":
712
+ resync.orders = value
713
+ self.next_after_snapshot(resync)
714
+ elif read.kind in ("deals", "history_orders"):
715
+ target = resync.deals if read.kind == "deals" else resync.history_orders
716
+ target.extend(value)
717
+ self.next_range(resync)
718
+ elif read.kind == "request":
719
+ resync.lookups.discard(read.request_id or "")
720
+ if isinstance(value, dict):
721
+ self.out.append(Notify(cast(Json, value)))
722
+ self.next_lookup(resync)
723
+
724
+ def next_after_snapshot(self, resync: Resync) -> None:
725
+ if resync.terminal_away:
726
+ resync.positions = None
727
+ resync.orders = None
728
+ # History needs the terminal too: report it unavailable, keep the checkpoint.
729
+ self.history_unavailable(resync, "unavailable")
730
+ return
731
+ self.plan_history(resync)
732
+
733
+ def plan_history(self, resync: Resync) -> None:
734
+ account = resync.account or {}
735
+ login, server = account.get("login"), account.get("server")
736
+ checkpoint = self.checkpoint
737
+ if checkpoint.login is not None and (login, server) != (
738
+ checkpoint.login,
739
+ checkpoint.server,
740
+ ):
741
+ resync.new_identity = (login, server)
742
+ self.history_unavailable(resync, "account_changed")
743
+ return
744
+ if checkpoint.server_time is None:
745
+ self.history_unavailable(resync, "no_checkpoint")
746
+ return
747
+ newest = checkpoint.server_time
748
+ server_time = account.get("server_time")
749
+ if isinstance(server_time, int) and not isinstance(server_time, bool):
750
+ newest = max(newest, server_time)
751
+ start = min(checkpoint.server_time, server_time) - HISTORY_MARGIN
752
+ else:
753
+ start = checkpoint.server_time - HISTORY_MARGIN
754
+ resync.history_from = max(start, 0)
755
+ resync.history_to = newest + HISTORY_AHEAD
756
+ resync.ranges = deque(
757
+ [
758
+ ("deals", resync.history_from, resync.history_to),
759
+ ("history_orders", resync.history_from, resync.history_to),
760
+ ]
761
+ )
762
+ self.next_range(resync)
763
+
764
+ def split_range(self, resync: Resync, read: Read) -> None:
765
+ assert read.from_ is not None
766
+ assert read.to is not None
767
+ if read.to - read.from_ < 2 * SMALLEST_SLICE:
768
+ self.history_unavailable(resync, "range_too_large")
769
+ return
770
+ middle = (read.from_ + read.to) // 2
771
+ resync.ranges.appendleft((read.kind, middle + 1, read.to))
772
+ resync.ranges.appendleft((read.kind, read.from_, middle))
773
+ self.next_range(resync)
774
+
775
+ def next_range(self, resync: Resync) -> None:
776
+ if resync.ranges:
777
+ kind, start, end = resync.ranges.popleft()
778
+ self.read(resync, Read(kind, resync.generation, from_=start, to=end))
779
+ return
780
+ self.begin_lookups(resync)
781
+
782
+ def history_unavailable(self, resync: Resync, reason: str) -> None:
783
+ resync.history_reason = reason
784
+ resync.ranges.clear()
785
+ resync.deals.clear()
786
+ resync.history_orders.clear()
787
+ self.begin_lookups(resync)
788
+
789
+ def begin_lookups(self, resync: Resync) -> None:
790
+ resync.lookups = set(self.watched)
791
+ self.next_lookup(resync)
792
+
793
+ def next_lookup(self, resync: Resync) -> None:
794
+ if resync.lookups:
795
+ request_id = sorted(resync.lookups)[0]
796
+ self.read(resync, Read("request", resync.generation, request_id=request_id))
797
+ return
798
+ self.finish_resync()
799
+
800
+ def resync_failed(self) -> None:
801
+ """A resync read failed in a way that may mend: try the resync again after a wait."""
802
+ resync = self.resync
803
+ assert resync is not None
804
+ resync.waiting = None
805
+ self.start_timer("resync_retry", self.backoff())
806
+ # Start the next attempt from a clean slate; held events are kept.
807
+ self.resync = Resync(resync.reason, resync.generation)
808
+
809
+ def finish_resync(self) -> None:
810
+ resync = self.resync
811
+ assert resync is not None
812
+ self.bump("resync_deadline")
813
+ self.failures = 0
814
+ if resync.new_identity is not None:
815
+ # A different login: its own checkpoint, and nothing remembered from the old one.
816
+ login, server = resync.new_identity
817
+ self.checkpoint = Checkpoint(None, login, server)
818
+ self.recent_deals.clear()
819
+ self.recent_orders.clear()
820
+ self.account_events.clear()
821
+ history: Json | None = None
822
+ if self.read_state and resync.history_reason is None and resync.history_from is not None:
823
+ deals = self.fresh(resync.deals, self.recent_deals, deal_ticket)
824
+ orders = self.fresh(resync.history_orders, self.recent_orders, order_ticket)
825
+ history = {
826
+ "deals": deals,
827
+ "orders": orders,
828
+ "from": resync.history_from,
829
+ "to": resync.history_to,
830
+ }
831
+ if self.read_state:
832
+ account = resync.account or {}
833
+ if self.checkpoint.login is None:
834
+ self.checkpoint.login = account.get("login")
835
+ self.checkpoint.server = account.get("server")
836
+ self.terminal_away = resync.terminal_away
837
+ event: Json = {
838
+ "type": "resync",
839
+ "reason": resync.reason,
840
+ "account": resync.account,
841
+ "positions": resync.positions,
842
+ "orders": resync.orders,
843
+ "history": history,
844
+ "history_reason": resync.history_reason if self.read_state else None,
845
+ }
846
+ self.resync = None
847
+ self.state = State.LIVE
848
+ self.emit(event)
849
+ waiting = self.held + list(self.newest_ticks.values())
850
+ self.held, self.newest_ticks = [], {}
851
+ for index, body in enumerate(waiting):
852
+ self.while_live(body)
853
+ if self.state is not State.LIVE:
854
+ # That event started another resync (or the host went): the rest wait for it.
855
+ for rest in waiting[index + 1 :]:
856
+ self.hold(rest)
857
+ return
858
+
859
+ def fresh(
860
+ self, entries: list[Json], seen: deque[int], ticket: Callable[[Json], int | None]
861
+ ) -> list[Json]:
862
+ """History entries not already delivered, in order, each ticket once."""
863
+ known = set(seen)
864
+ kept: list[Json] = []
865
+ for entry in entries:
866
+ number = ticket(entry)
867
+ if number is not None and number in known:
868
+ continue
869
+ if number is not None:
870
+ known.add(number)
871
+ seen.append(number)
872
+ kept.append(entry)
873
+ return kept
874
+
875
+ # The host away.
876
+
877
+ def probe_answered(self, answer: Answered) -> None:
878
+ if answer.error is None:
879
+ self.begin_resync("host_back")
880
+ return
881
+ if fatal(answer.error):
882
+ self.fail(answer.error)
883
+ return
884
+ self.probe_every = min(self.probe_every * 2, LONGEST_PROBE)
885
+ self.start_timer("probe", self.probe_every)
886
+
887
+
888
+ def fatal(error: PipsocketError) -> bool:
889
+ """A failure that won't mend: the key was rolled, or the account is gone."""
890
+ if isinstance(error, AuthenticationError):
891
+ return True
892
+ return isinstance(error, NotFound) and error.code == "no_such_account"
893
+
894
+
895
+ def forget(remembered: deque[int], ticket: int | None) -> None:
896
+ if ticket is not None and ticket in remembered:
897
+ remembered.remove(ticket)
898
+
899
+
900
+ def deal_ticket(body: Json) -> int | None:
901
+ """A deal's ticket, from a deal event ({"deal": {...}}) or a history entry."""
902
+ deal: object = body.get("deal", body)
903
+ if not isinstance(deal, dict):
904
+ return None
905
+ value: object = cast(Json, deal).get("ticket")
906
+ return value if isinstance(value, int) else None
907
+
908
+
909
+ def order_ticket(body: Json) -> int | None:
910
+ value = body.get("ticket")
911
+ return value if isinstance(value, int) else None
912
+
913
+
914
+ def ticks_refused(status: int, body: Any) -> PipsocketError:
915
+ """The stream refused the tick set, such as 422 too_many_symbols: raised as it is."""
916
+ return refusal_from_body(status, body)