cryptochief-crypto-processing-python 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.
Files changed (39) hide show
  1. cryptochief/__init__.py +336 -0
  2. cryptochief/_models.py +93 -0
  3. cryptochief/_version.py +3 -0
  4. cryptochief/amount.py +97 -0
  5. cryptochief/assets.py +32 -0
  6. cryptochief/chains.py +115 -0
  7. cryptochief/client.py +189 -0
  8. cryptochief/contract/__init__.py +69 -0
  9. cryptochief/contract/base58.py +40 -0
  10. cryptochief/contract/borsh.py +141 -0
  11. cryptochief/contract/evm_abi.py +321 -0
  12. cryptochief/contract/keccak.py +16 -0
  13. cryptochief/contract/tron_address.py +60 -0
  14. cryptochief/errors.py +111 -0
  15. cryptochief/pagination.py +32 -0
  16. cryptochief/poll.py +56 -0
  17. cryptochief/rsa.py +70 -0
  18. cryptochief/services/__init__.py +1 -0
  19. cryptochief/services/base.py +24 -0
  20. cryptochief/services/blockchain.py +76 -0
  21. cryptochief/services/currencies.py +55 -0
  22. cryptochief/services/payins.py +145 -0
  23. cryptochief/services/payouts.py +175 -0
  24. cryptochief/services/static_deposits.py +77 -0
  25. cryptochief/services/sweeps.py +85 -0
  26. cryptochief/services/transactions.py +470 -0
  27. cryptochief/services/wallets.py +86 -0
  28. cryptochief/services/withdrawals.py +51 -0
  29. cryptochief/sign.py +112 -0
  30. cryptochief/ton/__init__.py +19 -0
  31. cryptochief/ton/address.py +109 -0
  32. cryptochief/ton/messages.py +105 -0
  33. cryptochief/ton/rpc.py +159 -0
  34. cryptochief/transport.py +48 -0
  35. cryptochief/webhook.py +191 -0
  36. cryptochief_crypto_processing_python-0.1.0.dist-info/METADATA +346 -0
  37. cryptochief_crypto_processing_python-0.1.0.dist-info/RECORD +39 -0
  38. cryptochief_crypto_processing_python-0.1.0.dist-info/WHEEL +4 -0
  39. cryptochief_crypto_processing_python-0.1.0.dist-info/licenses/LICENSE +21 -0
cryptochief/sign.py ADDED
@@ -0,0 +1,112 @@
1
+ """Canonical JSON + request signing.
2
+
3
+ Crypto Chief signs the *canonical* serialization of a request body. The
4
+ canonical form is fully deterministic:
5
+
6
+ * object keys sorted lexicographically by their UTF-8 bytes, recursively;
7
+ * compact (no insignificant whitespace);
8
+ * the HTML-sensitive characters ``<``, ``>``, ``&`` and the U+2028 / U+2029
9
+ line / paragraph separators emitted as their JSON unicode escapes;
10
+ * standard JSON escapes for ``"``, ``\\``, and control characters (``\\n``,
11
+ ``\\r``, ``\\t`` short forms; everything else below 0x20 as ``\\u00XX``,
12
+ lowercase hex).
13
+
14
+ The gateway re-derives this canonical form from the bytes it receives and
15
+ checks the signature against it, so the client must emit byte-identical output.
16
+ The regression vectors in ``tests/test_sign.py`` lock this down.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import hashlib
23
+ from typing import Any, Tuple
24
+
25
+ from .errors import CryptoChiefError
26
+
27
+ _SHORT_ESCAPES = {
28
+ 0x22: '\\"',
29
+ 0x5C: "\\\\",
30
+ 0x0A: "\\n",
31
+ 0x0D: "\\r",
32
+ 0x09: "\\t",
33
+ 0x3C: "\\u003c",
34
+ 0x3E: "\\u003e",
35
+ 0x26: "\\u0026",
36
+ 0x2028: "\\u2028",
37
+ 0x2029: "\\u2029",
38
+ }
39
+
40
+
41
+ def _encode_string(s: str) -> str:
42
+ out = ['"']
43
+ for ch in s:
44
+ code = ord(ch)
45
+ esc = _SHORT_ESCAPES.get(code)
46
+ if esc is not None:
47
+ out.append(esc)
48
+ elif code < 0x20:
49
+ out.append("\\u%04x" % code)
50
+ else:
51
+ out.append(ch)
52
+ out.append('"')
53
+ return "".join(out)
54
+
55
+
56
+ def _encode_number(n: "int | float") -> str:
57
+ if isinstance(n, bool): # bool is a subclass of int - guard first
58
+ return "true" if n else "false"
59
+ if isinstance(n, int):
60
+ return str(n)
61
+ # Floats are not expected in signed bodies (amounts travel as strings), but
62
+ # match the gateway's shortest-form rendering: integral floats drop the dot.
63
+ if n != n or n in (float("inf"), float("-inf")):
64
+ raise CryptoChiefError(f"cryptochief: cannot canonicalize non-finite number {n}")
65
+ if n == int(n) and abs(n) < 1e21:
66
+ return str(int(n))
67
+ return repr(n)
68
+
69
+
70
+ def _encode_value(v: Any) -> str:
71
+ if v is None:
72
+ return "null"
73
+ if isinstance(v, bool):
74
+ return "true" if v else "false"
75
+ if isinstance(v, str):
76
+ return _encode_string(v)
77
+ if isinstance(v, (int, float)):
78
+ return _encode_number(v)
79
+ if isinstance(v, dict):
80
+ keys = [k for k, val in v.items() if val is not None]
81
+ keys.sort(key=lambda k: str(k).encode("utf-8"))
82
+ parts = [_encode_string(str(k)) + ":" + _encode_value(v[k]) for k in keys]
83
+ return "{" + ",".join(parts) + "}"
84
+ if isinstance(v, (list, tuple)):
85
+ return "[" + ",".join(_encode_value(el) for el in v) + "]"
86
+ raise CryptoChiefError(f"cryptochief: cannot canonicalize value of type {type(v).__name__}")
87
+
88
+
89
+ def canonical_json(value: Any) -> str:
90
+ """Produce the canonical JSON string for a value.
91
+
92
+ ``None`` collapses to an empty body, which signs as ``md5(api_key)``.
93
+ """
94
+ if value is None:
95
+ return ""
96
+ return _encode_value(value)
97
+
98
+
99
+ def sign(canonical_body: str, api_key: str) -> str:
100
+ """Compute the ``Signature`` header for an already-canonical body.
101
+
102
+ ``hex(md5(base64(canonical_body) + api_key))``. An empty body signs as
103
+ ``md5(api_key)``.
104
+ """
105
+ b64 = base64.b64encode(canonical_body.encode("utf-8")).decode("ascii")
106
+ return hashlib.md5((b64 + api_key).encode("utf-8")).hexdigest()
107
+
108
+
109
+ def sign_value(value: Any, api_key: str) -> Tuple[str, str]:
110
+ """Canonicalize then sign a value, returning ``(canonical, signature)``."""
111
+ canonical = canonical_json(value)
112
+ return canonical, sign(canonical, api_key)
@@ -0,0 +1,19 @@
1
+ """TON helpers: offline address parsing (public) and cell builders (used internally)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .address import (
6
+ TonAddress,
7
+ crc16_xmodem,
8
+ parse_ton_address,
9
+ ton_address_to_raw,
10
+ ton_address_to_string,
11
+ )
12
+
13
+ __all__ = [
14
+ "TonAddress",
15
+ "crc16_xmodem",
16
+ "parse_ton_address",
17
+ "ton_address_to_raw",
18
+ "ton_address_to_string",
19
+ ]
@@ -0,0 +1,109 @@
1
+ """Offline parsing / validation of TON addresses.
2
+
3
+ TON addresses come in three skins, all wrapping the same 33 bytes (1 tag +
4
+ 1 workchain + 32 hash):
5
+
6
+ * user-friendly bounceable ``EQ...`` (mainnet) / ``kQ...`` (testnet)
7
+ * user-friendly non-bounceable ``UQ...`` (mainnet) / ``0Q...`` (testnet)
8
+ * raw ``<workchain>:<32-byte-hex>``
9
+
10
+ The user-friendly forms add a 2-byte CRC16-XMODEM checksum, which this parser
11
+ validates. No network access - this is purely for local validation / display.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ from dataclasses import dataclass
18
+
19
+ from ..errors import CryptoChiefError
20
+
21
+
22
+ @dataclass
23
+ class TonAddress:
24
+ workchain: int
25
+ hash: bytes # 32 bytes
26
+ bounceable: bool
27
+ testnet: bool
28
+
29
+
30
+ def crc16_xmodem(data: bytes) -> int:
31
+ """CRC-16/XMODEM (poly 0x1021, init 0x0000, non-reflected) - TON's checksum."""
32
+ crc = 0
33
+ for b in data:
34
+ crc ^= b << 8
35
+ for _ in range(8):
36
+ crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
37
+ return crc & 0xFFFF
38
+
39
+
40
+ def _parse_raw(s: str, colon: int) -> TonAddress:
41
+ try:
42
+ wc = int(s[:colon], 10)
43
+ except ValueError as err:
44
+ raise CryptoChiefError(f"cryptochief/ton: bad raw workchain {s[:colon]!r}") from err
45
+ if wc < -128 or wc > 127:
46
+ raise CryptoChiefError(f"cryptochief/ton: bad raw workchain {s[:colon]!r}")
47
+ hash_hex = s[colon + 1 :]
48
+ if len(hash_hex) != 64:
49
+ raise CryptoChiefError(f"cryptochief/ton: hash hex length {len(hash_hex)}, want 64")
50
+ try:
51
+ h = bytes.fromhex(hash_hex)
52
+ except ValueError as err:
53
+ raise CryptoChiefError("cryptochief/ton: bad hash hex") from err
54
+ return TonAddress(workchain=wc, hash=h, bounceable=True, testnet=False)
55
+
56
+
57
+ def _parse_friendly(s: str) -> TonAddress:
58
+ if len(s) != 48:
59
+ raise CryptoChiefError(f"cryptochief/ton: user-friendly address length {len(s)}, want 48")
60
+ # TON uses URL-safe base64; accept the standard alphabet too.
61
+ try:
62
+ raw = base64.b64decode(s.replace("-", "+").replace("_", "/"))
63
+ except (ValueError, base64.binascii.Error) as err: # type: ignore[attr-defined]
64
+ raise CryptoChiefError(f"cryptochief/ton: bad base64 address: {err}") from err
65
+ if len(raw) != 36:
66
+ raise CryptoChiefError(f"cryptochief/ton: decoded length {len(raw)}, want 36")
67
+ want = crc16_xmodem(raw[:34])
68
+ got = (raw[34] << 8) | raw[35]
69
+ if want != got:
70
+ raise CryptoChiefError("cryptochief/ton: CRC mismatch")
71
+ tag = raw[0]
72
+ workchain = raw[1] - 256 if raw[1] > 127 else raw[1] # sign-extend to int8
73
+ return TonAddress(
74
+ workchain=workchain,
75
+ hash=raw[2:34],
76
+ bounceable=(tag & 0x40) == 0,
77
+ testnet=(tag & 0x80) != 0,
78
+ )
79
+
80
+
81
+ def parse_ton_address(value: str) -> TonAddress:
82
+ """Parse any of the three TON address forms; raises on CRC / length errors."""
83
+ s = value.strip()
84
+ if s == "":
85
+ raise CryptoChiefError("cryptochief/ton: empty address")
86
+ colon = s.find(":")
87
+ if colon > 0:
88
+ return _parse_raw(s, colon)
89
+ return _parse_friendly(s)
90
+
91
+
92
+ def ton_address_to_string(a: TonAddress) -> str:
93
+ """Render the user-friendly form (URL-safe base64, no padding)."""
94
+ tag = 0x11 if a.bounceable else 0x51
95
+ if a.testnet:
96
+ tag |= 0x80
97
+ buf = bytearray(36)
98
+ buf[0] = tag
99
+ buf[1] = a.workchain & 0xFF
100
+ buf[2:34] = a.hash[:32]
101
+ crc = crc16_xmodem(bytes(buf[:34]))
102
+ buf[34] = crc >> 8
103
+ buf[35] = crc & 0xFF
104
+ return base64.urlsafe_b64encode(bytes(buf)).decode("ascii").rstrip("=")
105
+
106
+
107
+ def ton_address_to_raw(a: TonAddress) -> str:
108
+ """Render the raw ``workchain:hex`` form."""
109
+ return f"{a.workchain}:{a.hash.hex()}"
@@ -0,0 +1,105 @@
1
+ """TON message-body builders.
2
+
3
+ BoC (de)serialization is delegated to ``pytoniq-core``; the SDK does not encode
4
+ cells by hand. These helpers produce the raw BoC bytes the contract-call
5
+ ``data`` field expects (base64-encoded by the caller).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from pytoniq_core import Address, Cell, begin_cell
13
+
14
+ from ..errors import CryptoChiefError
15
+
16
+ # TON internal-message op codes from the public TEP standards.
17
+ OP_JETTON_TRANSFER = 0x0F8A7EA5 # TEP-74
18
+ OP_NFT_TRANSFER = 0x5FCC3D14 # TEP-62
19
+ OP_TEXT_COMMENT = 0x00000000
20
+
21
+
22
+ def parse_ton_addr(s: str) -> Address:
23
+ """Parse any TON address form (``EQ`` / ``UQ`` or raw ``workchain:hex``)."""
24
+ try:
25
+ return Address(s)
26
+ except Exception as err: # noqa: BLE001 - library raises various types
27
+ raise CryptoChiefError(
28
+ f"cryptochief/ton: invalid TON address {s!r} "
29
+ f"(expected EQ/UQ or workchain:hex): {err}"
30
+ ) from err
31
+
32
+
33
+ def build_jetton_transfer_body(
34
+ *,
35
+ query_id: int,
36
+ amount: int,
37
+ destination: Address,
38
+ response_dest: Optional[Address],
39
+ custom_payload: Optional[Cell] = None,
40
+ forward_ton: int,
41
+ forward_payload: Optional[Cell] = None,
42
+ ) -> bytes:
43
+ """Standard Jetton "transfer" body (TEP-74, op ``0x0f8a7ea5``).
44
+
45
+ ``destination`` is the recipient's *main* TON wallet; the network handles the
46
+ wallet-to-wallet hop.
47
+ """
48
+ if amount < 0:
49
+ raise CryptoChiefError("cryptochief/ton: jetton amount must be non-negative")
50
+ b = (
51
+ begin_cell()
52
+ .store_uint(OP_JETTON_TRANSFER, 32)
53
+ .store_uint(query_id, 64)
54
+ .store_coins(amount)
55
+ .store_address(destination)
56
+ .store_address(response_dest)
57
+ .store_maybe_ref(custom_payload)
58
+ .store_coins(max(forward_ton, 0))
59
+ )
60
+ # forward_payload: Either Cell ^Cell - ref when supplied, empty-inline otherwise.
61
+ if forward_payload is not None:
62
+ b = b.store_bit(1).store_ref(forward_payload)
63
+ else:
64
+ b = b.store_bit(0)
65
+ return bytes(b.end_cell().to_boc())
66
+
67
+
68
+ def build_nft_transfer_body(
69
+ *,
70
+ query_id: int,
71
+ new_owner: Address,
72
+ response_dest: Optional[Address],
73
+ custom_payload: Optional[Cell] = None,
74
+ forward_ton: int,
75
+ forward_payload: Optional[Cell] = None,
76
+ ) -> bytes:
77
+ """Standard NFT "transfer" body (TEP-62, op ``0x5fcc3d14``)."""
78
+ b = (
79
+ begin_cell()
80
+ .store_uint(OP_NFT_TRANSFER, 32)
81
+ .store_uint(query_id, 64)
82
+ .store_address(new_owner)
83
+ .store_address(response_dest)
84
+ .store_maybe_ref(custom_payload)
85
+ .store_coins(max(forward_ton, 0))
86
+ )
87
+ if forward_payload is not None:
88
+ b = b.store_bit(1).store_ref(forward_payload)
89
+ else:
90
+ b = b.store_bit(0)
91
+ return bytes(b.end_cell().to_boc())
92
+
93
+
94
+ def build_text_comment_cell(text: str) -> Cell:
95
+ """A standalone text-comment cell (op ``0`` + UTF-8 snake string).
96
+
97
+ Used both as a top-level body and as a Jetton transfer's ``forward_payload``
98
+ ref when a memo is supplied.
99
+ """
100
+ return begin_cell().store_uint(OP_TEXT_COMMENT, 32).store_snake_string(text).end_cell()
101
+
102
+
103
+ def build_text_comment_body(text: str) -> bytes:
104
+ """Simple text-comment body (what wallets show as the transfer note)."""
105
+ return bytes(build_text_comment_cell(text).to_boc())
cryptochief/ton/rpc.py ADDED
@@ -0,0 +1,159 @@
1
+ """Internal TON RPC client.
2
+
3
+ Exists only to feed parameters (the sender's Jetton wallet address; whether a
4
+ recipient already has a Jetton wallet) into the high-level TON sign helpers - it
5
+ is not part of the public API surface.
6
+
7
+ URL pattern: ``<base_url>/ton-v3/<merchant_id>/<endpoint>``. The merchant ID is
8
+ the same credential used by the processing API; no separate token.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ from typing import Any, Optional
15
+ from urllib.parse import urlencode
16
+
17
+ import httpx
18
+ from pytoniq_core import Cell, begin_cell
19
+
20
+ from ..errors import CryptoChiefError
21
+ from .messages import parse_ton_addr
22
+
23
+ DEFAULT_TON_RPC_BASE_URL = "https://rpc.crypto-chief.com"
24
+
25
+
26
+ class TonRpc:
27
+ def __init__(
28
+ self,
29
+ *,
30
+ merchant_id: str,
31
+ http: httpx.AsyncClient,
32
+ base_url: Optional[str] = None,
33
+ user_agent: str,
34
+ ) -> None:
35
+ self._merchant_id = merchant_id
36
+ self._http = http
37
+ self._base_url = (base_url or DEFAULT_TON_RPC_BASE_URL).rstrip("/")
38
+ self._user_agent = user_agent
39
+ self._cache: dict[str, str] = {}
40
+
41
+ def _url(self, path: str, query: Optional[dict[str, str]] = None) -> str:
42
+ u = f"{self._base_url}/ton-v3/{self._merchant_id}/{path.lstrip('/')}"
43
+ if query:
44
+ u += "?" + urlencode(query)
45
+ return u
46
+
47
+ async def _get(self, path: str, query: dict[str, str], timeout: float) -> Any:
48
+ resp = await self._http.get(
49
+ self._url(path, query),
50
+ headers={"Accept": "application/json", "User-Agent": self._user_agent},
51
+ timeout=timeout,
52
+ )
53
+ return self._handle(resp, path)
54
+
55
+ async def _post(self, path: str, body: Any, timeout: float) -> Any:
56
+ resp = await self._http.post(
57
+ self._url(path),
58
+ json=body,
59
+ headers={
60
+ "Content-Type": "application/json",
61
+ "Accept": "application/json",
62
+ "User-Agent": self._user_agent,
63
+ },
64
+ timeout=timeout,
65
+ )
66
+ return self._handle(resp, path)
67
+
68
+ @staticmethod
69
+ def _handle(resp: httpx.Response, path: str) -> Any:
70
+ text = resp.text
71
+ if resp.status_code >= 400:
72
+ raise CryptoChiefError(
73
+ f"cryptochief/ton: {path}: HTTP {resp.status_code}: {text[:256]}"
74
+ )
75
+ if not text:
76
+ return None
77
+ try:
78
+ return resp.json()
79
+ except ValueError as err:
80
+ raise CryptoChiefError(f"cryptochief/ton: decode {path}: {err}") from err
81
+
82
+ async def lookup_jetton_wallet(self, jetton_master: str, owner: str) -> str:
83
+ """Resolve the Jetton wallet holding ``owner``'s balance of ``jetton_master``.
84
+
85
+ Primary path: the deterministic ``get_wallet_address`` get-method on the
86
+ master (works even for an owner that never received the Jetton). Fallback:
87
+ the indexer. Cached for the client's lifetime.
88
+ """
89
+ if not jetton_master or not owner:
90
+ raise CryptoChiefError("cryptochief/ton: jetton_master and owner are required")
91
+ cache_key = f"{owner}|{jetton_master}"
92
+ cached = self._cache.get(cache_key)
93
+ if cached:
94
+ return cached
95
+
96
+ resolved = ""
97
+ try:
98
+ resolved = await self._via_run_method(jetton_master, owner)
99
+ except Exception: # noqa: BLE001 - fall back to the indexer
100
+ resolved = ""
101
+ if not resolved:
102
+ resolved = await self._via_index(jetton_master, owner)
103
+ self._cache[cache_key] = resolved
104
+ return resolved
105
+
106
+ async def _via_run_method(self, jetton_master: str, owner: str) -> str:
107
+ owner_cell = begin_cell().store_address(parse_ton_addr(owner)).end_cell()
108
+ owner_boc = base64.b64encode(bytes(owner_cell.to_boc())).decode("ascii")
109
+ out = await self._post(
110
+ "/runGetMethod",
111
+ {
112
+ "address": jetton_master,
113
+ "method": "get_wallet_address",
114
+ "stack": [{"type": "slice", "value": owner_boc}],
115
+ },
116
+ 15.0,
117
+ )
118
+ exit_code = out.get("exit_code") if isinstance(out, dict) else None
119
+ if exit_code not in (0, None):
120
+ raise CryptoChiefError(f"cryptochief/ton: get_wallet_address: exit_code={exit_code}")
121
+ stack = out.get("stack") if isinstance(out, dict) else None
122
+ if not stack:
123
+ raise CryptoChiefError("cryptochief/ton: get_wallet_address: empty stack")
124
+ value = stack[0].get("value")
125
+ result_cell = Cell.one_from_boc(base64.b64decode(value))
126
+ return result_cell.begin_parse().load_address().to_str()
127
+
128
+ async def _via_index(self, jetton_master: str, owner: str) -> str:
129
+ out = await self._get(
130
+ "/jetton/wallets",
131
+ {"owner_address": owner, "jetton_address": jetton_master, "limit": "1"},
132
+ 15.0,
133
+ )
134
+ wallets = (out or {}).get("jetton_wallets") or []
135
+ if not wallets:
136
+ raise CryptoChiefError(
137
+ f"cryptochief/ton: no Jetton wallet found for owner {owner} on master "
138
+ f"{jetton_master} - owner has never received this Jetton"
139
+ )
140
+ wallet = wallets[0]
141
+ address_book = (out or {}).get("address_book") or {}
142
+ friendly = (address_book.get(wallet["address"]) or {}).get("user_friendly")
143
+ return friendly or wallet["address"]
144
+
145
+ async def has_jetton_wallet(self, jetton_master: str, owner: str) -> bool:
146
+ """Whether ``owner`` already holds an initialized Jetton wallet for ``jetton_master``.
147
+
148
+ Used to size the attached gas budget on transfers. Returns ``False`` (the
149
+ conservative answer) on any RPC error.
150
+ """
151
+ try:
152
+ out = await self._get(
153
+ "/jetton/wallets",
154
+ {"owner_address": owner, "jetton_address": jetton_master, "limit": "1"},
155
+ 5.0,
156
+ )
157
+ return len((out or {}).get("jetton_wallets") or []) > 0
158
+ except Exception: # noqa: BLE001 - conservative default
159
+ return False
@@ -0,0 +1,48 @@
1
+ """Transport helpers: error-envelope parsing and retry backoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+
8
+ from .errors import APIError, ErrorCode
9
+
10
+
11
+ def parse_api_error(status: int, body: str) -> APIError:
12
+ """Parse a non-2xx response body into an :class:`APIError` with a stable code.
13
+
14
+ The code is ``msg or error or HTTP_<status>``, and the message prefers
15
+ ``msg`` when it differs from ``error``.
16
+ """
17
+ env: dict = {}
18
+ try:
19
+ parsed = json.loads(body)
20
+ if isinstance(parsed, dict):
21
+ env = parsed
22
+ except ValueError:
23
+ pass # non-JSON error body -> fall back to HTTP_<status>
24
+ code = env.get("msg") or env.get("error") or f"HTTP_{status}"
25
+ message = env.get("error") or ""
26
+ if env.get("msg") and env.get("msg") != env.get("error"):
27
+ message = env.get("msg")
28
+ return APIError(code, http_status=status, message=message, raw=body)
29
+
30
+
31
+ def backoff_delay(attempt: int, base_ms: float, max_ms: float) -> float:
32
+ """Exponential backoff with full jitter, capped at ``max_ms``.
33
+
34
+ ``attempt`` is 1-indexed (first retry = 1). Returns seconds.
35
+ """
36
+ if base_ms <= 0:
37
+ base_ms = 200
38
+ if max_ms <= 0:
39
+ max_ms = 5000
40
+ d = base_ms * (2 ** (attempt - 1))
41
+ if d <= 0 or d > max_ms:
42
+ d = max_ms
43
+ return random.uniform(0, d) / 1000.0 # full jitter, uniform in [0, d] ms
44
+
45
+
46
+ def network_error(message: str) -> APIError:
47
+ """Build an :class:`APIError` for a transport-level (network) failure."""
48
+ return APIError(ErrorCode.NETWORK_ERROR, message=message)