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.
pipsocket/_core.py ADDED
@@ -0,0 +1,712 @@
1
+ """Everything that decides what a call returns, raises or retries. No I/O.
2
+
3
+ A call is a `Spec`. `drive()` is a generator that the sync and async clients run: it yields
4
+ `SEND` (make one attempt and send back what happened) or a `Wait` (sleep that long), and ends by
5
+ returning the call's value or raising its error. The clients only do the I/O.
6
+
7
+ The rules, in short (the README has them for users):
8
+
9
+ - One idempotency key per trade call, sent unchanged on every attempt. A retry changes nothing:
10
+ same method, path, body and key.
11
+ - A recorded request is returned, whatever its state.
12
+ - Once any attempt of a trade call may have reached the account's host, the call ends either
13
+ with a recorded request or with OutcomeUnknown carrying the key. Never with an error that says
14
+ nothing was sent.
15
+ - No new attempt starts after the retry deadline, counted from the start of the first attempt.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import json
22
+ import math
23
+ import re
24
+ import uuid
25
+ from collections.abc import Callable, Generator, Mapping
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ from typing import Any, Literal, cast
29
+ from urllib.parse import quote
30
+
31
+ from .errors import (
32
+ ERROR_CLASSES,
33
+ AccountUnavailable,
34
+ APIConnectionError,
35
+ APIError,
36
+ APITimeoutError,
37
+ AuthenticationError,
38
+ InvalidRequest,
39
+ NotFound,
40
+ OutcomeUnknown,
41
+ PipsocketError,
42
+ SymbolBusy,
43
+ )
44
+ from .models import TradeRequest, is_recorded_request
45
+ from .types import Blocking, KillSwitchView, RequestView
46
+
47
+ ACCOUNT_PATH = "/v1/accounts/{account}"
48
+
49
+ # Waits between attempts: 0.5 s doubling, capped, each times a random factor from 0.5 to 1.
50
+ FIRST_WAIT = 0.5
51
+ LONGEST_WAIT = 8.0
52
+
53
+
54
+ class Kind(Enum):
55
+ """What sort of call a spec is, which decides how its answers are read and retried."""
56
+
57
+ # A read, check, or the kill switch: safe to repeat, answers are JSON returned as they are.
58
+ SAFE = "safe"
59
+ # requests.get(): safe to repeat, the answer is a recorded request.
60
+ LOOKUP = "lookup"
61
+ # The six trade calls: keyed, and the answer is a recorded request.
62
+ TRADE = "trade"
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Spec:
67
+ operation: str
68
+ method: str
69
+ path: str
70
+ kind: Kind
71
+ query: tuple[tuple[str, str], ...] = ()
72
+ body: bytes | None = None
73
+ idempotency_key: str | None = None
74
+
75
+
76
+ # What one attempt came to. The client builds one of these and sends it into drive().
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Answer:
81
+ """An HTTP answer, read to the end."""
82
+
83
+ status: int
84
+ headers: Mapping[str, str]
85
+ content: bytes
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class BodyLost:
90
+ """The status and headers arrived, then reading the body failed. `content` is what arrived."""
91
+
92
+ status: int
93
+ error: Exception
94
+ content: bytes
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class NoAnswer:
99
+ """No answer. `written` is False only when the connection never opened, so nothing was sent."""
100
+
101
+ error: Exception
102
+ written: bool
103
+ timed_out: bool
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Failed:
108
+ """The attempt failed for a reason that isn't the network or the API: the HTTP client was
109
+ closed, or another library raised. `maybe_sent` says whether sending had begun. It isn't
110
+ retried, and its error is raised as it is unless the call may have reached the host."""
111
+
112
+ error: Exception
113
+ maybe_sent: bool
114
+
115
+
116
+ Attempt = Answer | BodyLost | NoAnswer | Failed
117
+
118
+
119
+ class _Send:
120
+ def __repr__(self) -> str:
121
+ return "SEND"
122
+
123
+
124
+ SEND = _Send()
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Wait:
129
+ seconds: float
130
+
131
+
132
+ Step = _Send | Wait
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class Settings:
137
+ max_retries: int
138
+ retry_deadline: float
139
+ clock: Callable[[], float]
140
+ random: Callable[[], float]
141
+
142
+
143
+ # Specs for each call. Paths are under /v1/accounts/{account}.
144
+
145
+
146
+ def safe(operation: str, method: str, account: str, path: str, **extra: Any) -> Spec:
147
+ return Spec(operation, method, account_path(account, path), Kind.SAFE, **extra)
148
+
149
+
150
+ def lookup(account: str, request_id: str) -> Spec:
151
+ if not request_id:
152
+ raise ValueError("A request id is required.")
153
+ path = account_path(account, f"/requests/{segment(request_id)}")
154
+ return Spec("request", "GET", path, Kind.LOOKUP)
155
+
156
+
157
+ def trade(
158
+ operation: str,
159
+ method: str,
160
+ account: str,
161
+ path: str,
162
+ body: Mapping[str, Any] | None,
163
+ idempotency_key: str | None,
164
+ ) -> Spec:
165
+ key = new_key() if idempotency_key is None else check_key(idempotency_key)
166
+ return Spec(
167
+ operation,
168
+ method,
169
+ account_path(account, path),
170
+ Kind.TRADE,
171
+ body=None if body is None else json_body(body),
172
+ idempotency_key=key,
173
+ )
174
+
175
+
176
+ def account_path(account: str, path: str) -> str:
177
+ if not account:
178
+ raise ValueError("An account id is required.")
179
+ base = ACCOUNT_PATH.format(account=segment(account))
180
+ return base if path == "/" else base + path
181
+
182
+
183
+ def segment(value: str) -> str:
184
+ """One path segment, with anything that isn't safe in a path escaped (a "/" included)."""
185
+ return quote(value, safe="")
186
+
187
+
188
+ def ticket_segment(value: int) -> str:
189
+ """A ticket for a path: a whole number, 0 or more."""
190
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0: # pyright: ignore[reportUnnecessaryIsInstance]
191
+ raise ValueError(f"A ticket is a whole number, 0 or more, not {value!r}.")
192
+ return str(value)
193
+
194
+
195
+ def symbol_path(name: str) -> str:
196
+ if not name:
197
+ raise ValueError("A symbol name is required.")
198
+ return f"/symbols/{segment(name)}"
199
+
200
+
201
+ def history_range(from_: int, to: int) -> tuple[tuple[str, str], ...]:
202
+ """A history range's query: broker server time in whole seconds, both ends included."""
203
+ for name, value in (("from_", from_), ("to", to)):
204
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0: # pyright: ignore[reportUnnecessaryIsInstance]
205
+ raise ValueError(f"{name} is broker server time in whole seconds, not {value!r}.")
206
+ return (("from", str(from_)), ("to", str(to)))
207
+
208
+
209
+ def json_body(fields: Mapping[str, Any]) -> bytes:
210
+ """The body sent on every attempt: fields left as None are left out."""
211
+ present = {name: value for name, value in fields.items() if value is not None}
212
+ return json.dumps(present, separators=(",", ":"), allow_nan=False).encode()
213
+
214
+
215
+ def new_key() -> str:
216
+ """A new idempotency key: one per trade. Save it before sending to survive a crash."""
217
+ return str(uuid.uuid4())
218
+
219
+
220
+ _RESERVED_PREFIX = "hk:"
221
+ _KEY_LENGTH = 128
222
+ _VISIBLE = re.compile(r"[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?")
223
+
224
+
225
+ def check_key(key: str) -> str:
226
+ """An idempotency key: 1 to 128 visible ASCII characters, not starting with "hk:".
227
+
228
+ Spaces are allowed inside a key but not at either end (HTTP would drop them).
229
+ """
230
+ if not isinstance(key, str): # pyright: ignore[reportUnnecessaryIsInstance]
231
+ raise ValueError("An idempotency key is a string.")
232
+ if key.startswith(_RESERVED_PREFIX):
233
+ raise ValueError(
234
+ f"An idempotency key can't start with {_RESERVED_PREFIX!r}: Pipsocket uses it."
235
+ )
236
+ if not 1 <= len(key) <= _KEY_LENGTH or not _VISIBLE.fullmatch(key):
237
+ raise ValueError(
238
+ f"An idempotency key is 1 to {_KEY_LENGTH} visible ASCII characters, "
239
+ "with no space at either end."
240
+ )
241
+ return key
242
+
243
+
244
+ # How each error code is retried.
245
+
246
+ Retry = Literal["no", "yes", "once", "if_retry_after"]
247
+
248
+
249
+ @dataclass(frozen=True)
250
+ class Policy:
251
+ retry: Retry
252
+ # On a trade call: this answer leaves open that the trade reached the account's host.
253
+ maybe_sent: bool = False
254
+
255
+
256
+ _STOP = Policy("no")
257
+
258
+ # On a trade call. "maybe sent" is permanent for the call once any attempt is marked.
259
+ TRADE_POLICY: dict[str, Policy] = {
260
+ "missing_api_key": _STOP,
261
+ "invalid_api_key": _STOP,
262
+ "no_such_account": _STOP,
263
+ # The ticket was read fresh and is gone; this attempt recorded nothing.
264
+ "no_such_order": _STOP,
265
+ "no_such_position": _STOP,
266
+ "no_such_request": _STOP,
267
+ "no_such_symbol": _STOP,
268
+ "invalid_request": _STOP,
269
+ "magic_reserved": _STOP,
270
+ "range_too_large": _STOP,
271
+ # Retried after Retry-After: nothing was sent. Without it a blocker is unknown: stop.
272
+ "symbol_busy": Policy("if_retry_after"),
273
+ "kill_switch_engaged": _STOP,
274
+ "billing_hold": _STOP,
275
+ "account_not_connected": _STOP,
276
+ "bridge_outdated": _STOP,
277
+ "account_restarting": _STOP,
278
+ # The host refused it before running it.
279
+ "engine_busy": Policy("yes"),
280
+ # The call may have reached the host. The same key returns the request if it was
281
+ # recorded, and sends it if not.
282
+ "engine_unavailable": Policy("yes", maybe_sent=True),
283
+ "engine_timeout": Policy("yes", maybe_sent=True),
284
+ # From the fresh read that cancel, modify, set levels, close and close by make before
285
+ # recording anything (placing an order has no such read: see _policy()).
286
+ "terminal_timeout": Policy("yes"),
287
+ # These arrive inside a recorded request on a trade call, never as a bare refusal. If one
288
+ # does, it is treated as an unknown 5xx: maybe sent, retried with the same key.
289
+ "terminal_error": Policy("yes", maybe_sent=True),
290
+ "bad_command": Policy("yes", maybe_sent=True),
291
+ "symbol_too_large": Policy("yes", maybe_sent=True),
292
+ "no_price": Policy("yes", maybe_sent=True),
293
+ "not_sent": Policy("yes", maybe_sent=True),
294
+ "trade_rejected": _STOP,
295
+ "not_executed": _STOP,
296
+ }
297
+
298
+ # On a read, check, lookup or the kill switch: these send nothing to the broker.
299
+ SAFE_POLICY: dict[str, Policy] = {
300
+ "missing_api_key": _STOP,
301
+ "invalid_api_key": _STOP,
302
+ "no_such_account": _STOP,
303
+ "no_such_order": _STOP,
304
+ "no_such_position": _STOP,
305
+ "no_such_request": _STOP,
306
+ "no_such_symbol": _STOP,
307
+ "invalid_request": _STOP,
308
+ "magic_reserved": _STOP,
309
+ "range_too_large": _STOP,
310
+ "symbol_busy": _STOP,
311
+ "kill_switch_engaged": _STOP,
312
+ "billing_hold": _STOP,
313
+ "account_not_connected": _STOP,
314
+ "bridge_outdated": _STOP,
315
+ "account_restarting": _STOP,
316
+ "engine_busy": Policy("yes"),
317
+ "engine_unavailable": Policy("yes"),
318
+ "engine_timeout": Policy("yes"),
319
+ "terminal_timeout": Policy("yes"),
320
+ "terminal_error": Policy("once"),
321
+ "bad_command": _STOP,
322
+ "symbol_too_large": _STOP,
323
+ # The terminal already waited 2 s for a price.
324
+ "no_price": _STOP,
325
+ "not_sent": _STOP,
326
+ "trade_rejected": _STOP,
327
+ "not_executed": _STOP,
328
+ }
329
+
330
+ # The two codes that mean a trade may have reached the host; on a trade call their error is a
331
+ # plain APIError (the cause of OutcomeUnknown), not Unavailable, which means "safe to repeat".
332
+ _MAYBE_SENT_CODES = frozenset({"engine_unavailable", "engine_timeout"})
333
+
334
+
335
+ def _policy(spec: Spec, code: str, status: int) -> Policy:
336
+ if spec.kind is Kind.TRADE:
337
+ if code == "terminal_timeout" and spec.operation == "place_order":
338
+ # Not documented for placing an order, so nothing says it recorded nothing.
339
+ return Policy("yes", maybe_sent=True)
340
+ known = TRADE_POLICY.get(code)
341
+ if known is not None:
342
+ return known
343
+ return Policy("yes", maybe_sent=True) if status >= 500 else _STOP
344
+ known = SAFE_POLICY.get(code)
345
+ if known is not None:
346
+ return known
347
+ return Policy("yes") if status >= 500 else _STOP
348
+
349
+
350
+ # Reading one attempt.
351
+
352
+
353
+ @dataclass(frozen=True)
354
+ class Verdict:
355
+ """What one attempt came to: a value, or an error and whether to try again."""
356
+
357
+ value: Any = None
358
+ error: PipsocketError | None = None
359
+ retry: Retry = "no"
360
+ maybe_sent: bool = False
361
+ retry_after: int | None = None
362
+ status: int | None = None
363
+ raw: bytes | None = None
364
+ once_key: str | None = field(default=None)
365
+ # An exception that isn't the SDK's own, raised as it is when nothing may have been sent.
366
+ original: Exception | None = None
367
+
368
+
369
+ def read_attempt(spec: Spec, attempt: Attempt) -> Verdict:
370
+ if isinstance(attempt, Failed):
371
+ error = PipsocketError(f"The attempt failed: {describe(attempt.error)}")
372
+ error.__cause__ = attempt.error
373
+ return Verdict(error=error, maybe_sent=attempt.maybe_sent, original=attempt.error)
374
+ if isinstance(attempt, NoAnswer):
375
+ kind = APITimeoutError if attempt.timed_out else APIConnectionError
376
+ error = kind(f"No answer from the API: {describe(attempt.error)}")
377
+ error.__cause__ = attempt.error
378
+ return Verdict(error=error, retry="yes", maybe_sent=attempt.written)
379
+ if isinstance(attempt, BodyLost):
380
+ error = APIConnectionError(
381
+ f"The answer ({attempt.status}) was cut short: {describe(attempt.error)}"
382
+ )
383
+ error.__cause__ = attempt.error
384
+ return Verdict(
385
+ error=error,
386
+ retry="yes",
387
+ maybe_sent=True,
388
+ status=attempt.status,
389
+ raw=attempt.content,
390
+ )
391
+ return read_answer(spec, attempt)
392
+
393
+
394
+ def read_answer(spec: Spec, answer: Answer) -> Verdict:
395
+ status, raw = answer.status, answer.content
396
+ parsed, body = parse_json(raw)
397
+
398
+ recorded = spec.kind is not Kind.SAFE and parsed and is_recorded_request(body)
399
+ if recorded and (spec.kind is Kind.TRADE or 200 <= status < 300):
400
+ request = TradeRequest(
401
+ cast(RequestView, body), http_status=status, idempotency_key=spec.idempotency_key
402
+ )
403
+ return Verdict(value=request)
404
+
405
+ if 200 <= status < 300:
406
+ if spec.kind is Kind.SAFE and parsed:
407
+ return Verdict(value=body)
408
+ return unreadable(status, raw, body, "The answer isn't what the call returns")
409
+
410
+ if status == 400 and not raw.strip():
411
+ error = InvalidRequest(
412
+ "Refused with no body: a ticket in the path isn't a whole number.",
413
+ status=status,
414
+ code=None,
415
+ body=None,
416
+ raw=raw,
417
+ )
418
+ return Verdict(error=error, status=status, raw=raw)
419
+
420
+ if status == 403 and raw.strip() == b"error code: 1010":
421
+ error = APIError(
422
+ "Cloudflare refused this client before the API saw the call (error code: 1010).",
423
+ status=status,
424
+ code=None,
425
+ body=None,
426
+ raw=raw,
427
+ )
428
+ return Verdict(error=error, status=status, raw=raw)
429
+
430
+ fields = cast(dict[str, Any], body) if parsed and isinstance(body, dict) else {}
431
+ code = fields.get("error")
432
+ if not isinstance(code, str):
433
+ return unreadable(status, raw, body if parsed else None, "The refusal has no error code")
434
+
435
+ policy = _policy(spec, code, status)
436
+ retry_after = parse_retry_after(answer.headers)
437
+ error = refusal(spec, status, code, fields, raw, retry_after)
438
+ retry = policy.retry
439
+ if retry == "if_retry_after":
440
+ retry = "yes" if retry_after is not None else "no"
441
+ return Verdict(
442
+ error=error,
443
+ retry=retry,
444
+ maybe_sent=policy.maybe_sent,
445
+ retry_after=retry_after,
446
+ status=status,
447
+ raw=raw,
448
+ once_key=code if retry == "once" else None,
449
+ )
450
+
451
+
452
+ def unreadable(status: int, raw: bytes, body: Any, what: str) -> Verdict:
453
+ """An answer that isn't the API's JSON, or isn't the shape its status needs.
454
+
455
+ It may have come from past the API (a gateway page, a body cut short), so a trade call
456
+ counts it as maybe sent. Retrying with the same key replays the recorded answer.
457
+ """
458
+ error = APIError(f"{what} ({status}).", status=status, code=None, body=body, raw=raw)
459
+ return Verdict(error=error, retry="yes", maybe_sent=True, status=status, raw=raw)
460
+
461
+
462
+ def refusal(
463
+ spec: Spec,
464
+ status: int,
465
+ code: str,
466
+ body: dict[str, Any],
467
+ raw: bytes,
468
+ retry_after: int | None,
469
+ ) -> APIError:
470
+ message = body.get("message")
471
+ text = message if isinstance(message, str) else code
472
+ kind = ERROR_CLASSES.get(code, APIError)
473
+ if spec.kind is Kind.TRADE and code in _MAYBE_SENT_CODES:
474
+ kind = APIError
475
+ if kind is SymbolBusy:
476
+ blocking = body.get("blocking")
477
+ return SymbolBusy(
478
+ text,
479
+ status=status,
480
+ code=code,
481
+ body=body,
482
+ raw=raw,
483
+ blocking=cast(list[Blocking], blocking) if isinstance(blocking, list) else [],
484
+ retry_after=retry_after,
485
+ )
486
+ if kind is AccountUnavailable:
487
+ switch = body.get("kill_switch")
488
+ return AccountUnavailable(
489
+ text,
490
+ status=status,
491
+ code=code,
492
+ body=body,
493
+ raw=raw,
494
+ kill_switch=cast(KillSwitchView, switch) if isinstance(switch, dict) else None,
495
+ )
496
+ return kind(text, status=status, code=code, body=body, raw=raw)
497
+
498
+
499
+ def refusal_from_body(status: int, body: Any, raw: bytes = b"") -> APIError:
500
+ """The error for a refusal that didn't come from a REST call (a stream's answer or its
501
+ upgrade): the exception its code maps to, or a plain APIError."""
502
+ fields = cast(dict[str, Any], body) if isinstance(body, dict) else {}
503
+ code = fields.get("error")
504
+ if not isinstance(code, str):
505
+ return APIError(f"Refused ({status}).", status=status, code=None, body=body, raw=raw)
506
+ spec = Spec("stream", "GET", "", Kind.SAFE)
507
+ return refusal(spec, status, code, fields, raw, None)
508
+
509
+
510
+ def parse_json(raw: bytes) -> tuple[bool, Any]:
511
+ try:
512
+ return True, json.loads(raw)
513
+ except (ValueError, RecursionError): # RecursionError: nesting too deep for the parser
514
+ return False, None
515
+
516
+
517
+ _WHOLE_SECONDS = re.compile(r"[0-9]{1,12}") # ASCII digits only; longer is no usable wait
518
+
519
+
520
+ def parse_retry_after(headers: Mapping[str, str]) -> int | None:
521
+ """Retry-After in whole seconds. The HTTP-date form isn't used by the API, so it is ignored."""
522
+ value = headers.get("retry-after")
523
+ if value is None or not _WHOLE_SECONDS.fullmatch(value.strip()):
524
+ return None
525
+ return int(value.strip())
526
+
527
+
528
+ def describe(error: BaseException) -> str:
529
+ return str(error) or type(error).__name__
530
+
531
+
532
+ # Running a call.
533
+
534
+
535
+ def drive(spec: Spec, settings: Settings) -> Generator[Step, Attempt | None, Any]:
536
+ """Run one call: yields SEND (send back an Attempt) or a Wait (send back None)."""
537
+ start = settings.clock()
538
+ deadline = start + settings.retry_deadline
539
+ maybe_sent = False
540
+ retries = 0
541
+ retried_once: set[str] = set()
542
+
543
+ while True:
544
+ attempt = yield SEND
545
+ assert attempt is not None
546
+ verdict = read_attempt(spec, attempt)
547
+ if verdict.error is None:
548
+ return verdict.value
549
+
550
+ maybe_sent = maybe_sent or verdict.maybe_sent
551
+ try:
552
+ wait = plan_wait(verdict, retries, retried_once, deadline, settings)
553
+ except Exception as error:
554
+ ending = scheduling_failed(spec, verdict, maybe_sent, error)
555
+ if ending is error:
556
+ raise
557
+ raise ending from error
558
+ if wait is None:
559
+ raise final_error(spec, verdict, maybe_sent)
560
+
561
+ retries += 1
562
+ try:
563
+ yield Wait(wait) # the shell throws a failed sleep back in here
564
+ late = settings.clock() > deadline
565
+ except Exception as error:
566
+ ending = scheduling_failed(spec, verdict, maybe_sent, error)
567
+ if ending is error:
568
+ raise
569
+ raise ending from error
570
+ # The wait may have run long: no new attempt starts after the deadline.
571
+ if late:
572
+ raise final_error(spec, verdict, maybe_sent)
573
+
574
+
575
+ def plan_wait(
576
+ verdict: Verdict,
577
+ retries: int,
578
+ retried_once: set[str],
579
+ deadline: float,
580
+ settings: Settings,
581
+ ) -> float | None:
582
+ """How long to wait before trying again, or None to stop."""
583
+ wait = next_wait(verdict, retries, settings) if retries < settings.max_retries else None
584
+ if verdict.retry == "once":
585
+ assert verdict.once_key is not None
586
+ if verdict.once_key in retried_once:
587
+ wait = None
588
+ retried_once.add(verdict.once_key)
589
+ if wait is not None and settings.clock() + wait > deadline:
590
+ wait = None
591
+ return wait
592
+
593
+
594
+ def scheduling_failed(
595
+ spec: Spec, verdict: Verdict, maybe_sent: bool, error: Exception
596
+ ) -> Exception:
597
+ """The clock, the random source or the sleep failed while a retry was planned. The call
598
+ ends as it stands: OutcomeUnknown if the trade may have been sent, else that failure."""
599
+ if spec.kind is Kind.TRADE and maybe_sent:
600
+ return final_error(spec, verdict, maybe_sent)
601
+ if spec.kind is Kind.TRADE:
602
+ attach_key(error, spec.idempotency_key)
603
+ return error
604
+
605
+
606
+ def final_error(spec: Spec, verdict: Verdict, maybe_sent: bool) -> Exception:
607
+ """How a call ends without a value. On a trade call that may have reached the host, that is
608
+ OutcomeUnknown with the key; otherwise the last attempt's error, carrying the key too."""
609
+ assert verdict.error is not None
610
+ if spec.kind is Kind.TRADE and maybe_sent:
611
+ assert spec.idempotency_key is not None
612
+ unknown = OutcomeUnknown(
613
+ idempotency_key=spec.idempotency_key,
614
+ cause=verdict.error,
615
+ status=verdict.status,
616
+ raw=verdict.raw,
617
+ )
618
+ unknown.__cause__ = verdict.error
619
+ return unknown
620
+ error: Exception = verdict.original or verdict.error
621
+ if spec.kind is Kind.TRADE:
622
+ attach_key(error, spec.idempotency_key)
623
+ return error
624
+
625
+
626
+ def attach_key(error: BaseException, key: str | None) -> None:
627
+ """Set the trade call's idempotency key on an error it raises, where the object can carry
628
+ one. On the SDK's own errors it is always there; on a cancellation or another library's
629
+ error it is a convenience only: the dependable way is a key made with new_key() and saved
630
+ before sending."""
631
+ if key is None:
632
+ return
633
+ with contextlib.suppress(AttributeError, TypeError):
634
+ error.idempotency_key = key # type: ignore[attr-defined]
635
+
636
+
637
+ def next_wait(verdict: Verdict, retries: int, settings: Settings) -> float | None:
638
+ """How long to wait before the next attempt, or None if this answer isn't retried."""
639
+ if verdict.retry == "no":
640
+ return None
641
+ if verdict.retry_after is not None:
642
+ return float(verdict.retry_after)
643
+ nominal = min(FIRST_WAIT * 2 ** min(retries, 16), LONGEST_WAIT)
644
+ return nominal * (0.5 + 0.5 * settings.random())
645
+
646
+
647
+ # requests.wait(): polls the lookup until the request is final or the timeout passes.
648
+
649
+ FIRST_POLL = 0.25
650
+ POLL_GROWTH = 1.5
651
+ LONGEST_POLL = 2.0
652
+
653
+
654
+ @dataclass(frozen=True)
655
+ class Look:
656
+ """Look the request up once, giving the attempt at most `seconds`."""
657
+
658
+ seconds: float
659
+
660
+
661
+ WaitStep = Look | Wait
662
+
663
+
664
+ def drive_wait(
665
+ timeout: float, clock: Callable[[], float]
666
+ ) -> Generator[WaitStep, TradeRequest | PipsocketError | None, TradeRequest]:
667
+ """Yields Look (send back the TradeRequest or the error the lookup raised) or Wait (send
668
+ back None, or a request an open stream saw in the meantime).
669
+
670
+ Ends with the request when it is final, or as it stands when the timeout passes. It never
671
+ raises for "still pending" and only ever looks up. A 401 or 404 is raised at once: those
672
+ won't change. Other failures (the host away, a timeout) are polled through; if no lookup
673
+ has answered by the timeout, the last failure is raised. No lookup starts after the
674
+ timeout, however long a wait between lookups ran, and none runs past it.
675
+ """
676
+ deadline = clock() + timeout
677
+ interval = FIRST_POLL
678
+ latest: TradeRequest | None = None
679
+ last_error: PipsocketError | None = None
680
+ # Each lookup gets only what is left of the timeout, so a short timeout may end with no
681
+ # answer at all. timeout=0 means "look once": that lookup gets the client's own timeout.
682
+ seconds = timeout if timeout > 0 else math.inf
683
+
684
+ while True:
685
+ outcome = yield Look(seconds)
686
+ if isinstance(outcome, TradeRequest):
687
+ latest = outcome
688
+ if outcome.final:
689
+ return outcome
690
+ else:
691
+ assert outcome is not None
692
+ if isinstance(outcome, (AuthenticationError, NotFound)):
693
+ raise outcome
694
+ last_error = outcome
695
+
696
+ left = deadline - clock()
697
+ if left > 0:
698
+ seen = yield Wait(min(interval, left))
699
+ if isinstance(seen, TradeRequest):
700
+ # From a stream's request event: the same request, and events only come when a
701
+ # request settles. A final state is never replaced by an older, outstanding one.
702
+ latest = seen
703
+ if seen.final:
704
+ return seen
705
+ interval = min(interval * POLL_GROWTH, LONGEST_POLL)
706
+ left = deadline - clock()
707
+ if left <= 0:
708
+ if latest is not None:
709
+ return latest
710
+ assert last_error is not None
711
+ raise last_error
712
+ seconds = left