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/__init__.py +68 -0
- pipsocket/_core.py +712 -0
- pipsocket/_generated/__init__.py +1 -0
- pipsocket/_generated/operations.py +35 -0
- pipsocket/_generated/tables.py +95 -0
- pipsocket/_generated/types.py +542 -0
- pipsocket/_http.py +235 -0
- pipsocket/_stream_core.py +916 -0
- pipsocket/_version.py +1 -0
- pipsocket/aio.py +702 -0
- pipsocket/client.py +680 -0
- pipsocket/errors.py +275 -0
- pipsocket/models.py +151 -0
- pipsocket/py.typed +0 -0
- pipsocket/stream.py +713 -0
- pipsocket/types.py +48 -0
- pipsocket-0.1.0.dist-info/METADATA +358 -0
- pipsocket-0.1.0.dist-info/RECORD +20 -0
- pipsocket-0.1.0.dist-info/WHEEL +4 -0
- pipsocket-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|