tgbp-kit 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.
- tgbp_kit/__init__.py +25 -0
- tgbp_kit/_base.py +35 -0
- tgbp_kit/_http.py +398 -0
- tgbp_kit/_version.py +3 -0
- tgbp_kit/client.py +300 -0
- tgbp_kit/errors.py +25 -0
- tgbp_kit/models.py +361 -0
- tgbp_kit/py.typed +0 -0
- tgbp_kit/swap.py +197 -0
- tgbp_kit/swap_models.py +346 -0
- tgbp_kit-0.1.0.dist-info/METADATA +161 -0
- tgbp_kit-0.1.0.dist-info/RECORD +14 -0
- tgbp_kit-0.1.0.dist-info/WHEEL +4 -0
- tgbp_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
tgbp_kit/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""tGBP, the tokenised British pound, for Python: typed clients for the mint, redeem and swap API."""
|
|
2
|
+
|
|
3
|
+
from ._http import HttpRequest, HttpResponse, Transport, generate_idempotency_key
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .client import API_BASE_URLS, Created, PendingApproval, TgbpClient, TgbpEnvironment
|
|
6
|
+
from .errors import TgbpErrorCode, TgbpKitError
|
|
7
|
+
from .swap import SWAP_PRODUCTION_URL, SwapCreateResult, TgbpSwapClient
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"API_BASE_URLS",
|
|
11
|
+
"SWAP_PRODUCTION_URL",
|
|
12
|
+
"Created",
|
|
13
|
+
"HttpRequest",
|
|
14
|
+
"HttpResponse",
|
|
15
|
+
"PendingApproval",
|
|
16
|
+
"SwapCreateResult",
|
|
17
|
+
"TgbpClient",
|
|
18
|
+
"TgbpEnvironment",
|
|
19
|
+
"TgbpErrorCode",
|
|
20
|
+
"TgbpKitError",
|
|
21
|
+
"TgbpSwapClient",
|
|
22
|
+
"Transport",
|
|
23
|
+
"__version__",
|
|
24
|
+
"generate_idempotency_key",
|
|
25
|
+
]
|
tgbp_kit/_base.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The base class every generated model extends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def reject_floats(value: Any, where: str = "value") -> None:
|
|
11
|
+
"""Raises TypeError if a float appears anywhere in `value`.
|
|
12
|
+
|
|
13
|
+
A float cannot hold most decimal amounts exactly, so 0.1 + 0.2 would reach
|
|
14
|
+
the API as 0.30000000000000004. Pass a `Decimal`, a string or an int.
|
|
15
|
+
"""
|
|
16
|
+
if isinstance(value, float):
|
|
17
|
+
raise TypeError(f"{where} is a float ({value!r}); pass a Decimal, a str or an int")
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
for key, item in value.items():
|
|
20
|
+
reject_floats(item, f"{where}.{key}")
|
|
21
|
+
elif isinstance(value, (list, tuple)):
|
|
22
|
+
for index, item in enumerate(value):
|
|
23
|
+
reject_floats(item, f"{where}[{index}]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Model(BaseModel):
|
|
27
|
+
# Fields the pinned spec does not know yet are kept, not dropped.
|
|
28
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
29
|
+
|
|
30
|
+
# A before-validator on the whole input also covers the extra fields.
|
|
31
|
+
@model_validator(mode="before")
|
|
32
|
+
@classmethod
|
|
33
|
+
def _no_floats(cls, data: Any) -> Any:
|
|
34
|
+
reject_floats(data, cls.__name__)
|
|
35
|
+
return data
|
tgbp_kit/_http.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""The one place the API clients talk HTTP.
|
|
2
|
+
|
|
3
|
+
It sets the headers every request carries, sends the idempotency key, and owns
|
|
4
|
+
the retry rule: retry only a network error or a 5xx, and only when the call is
|
|
5
|
+
safe to repeat. A call that creates a record is safe to repeat only because it
|
|
6
|
+
resends the same idempotency key, so a retry returns the first record instead
|
|
7
|
+
of a second mint or redemption.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import http.client
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import random
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import date, datetime
|
|
25
|
+
from decimal import Decimal
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ._version import __version__
|
|
29
|
+
from .errors import TgbpKitError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class HttpRequest:
|
|
34
|
+
method: str
|
|
35
|
+
url: str
|
|
36
|
+
# The headers carry the API key, so they stay out of reprs and error reporters' locals.
|
|
37
|
+
headers: Mapping[str, str] = field(repr=False)
|
|
38
|
+
body: bytes | None
|
|
39
|
+
timeout: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class HttpResponse:
|
|
44
|
+
status: int
|
|
45
|
+
reason: str
|
|
46
|
+
headers: Mapping[str, str]
|
|
47
|
+
"""Header names in lower case."""
|
|
48
|
+
body: bytes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
Transport = Callable[[HttpRequest], HttpResponse]
|
|
52
|
+
"""Sends one request. Raise OSError on a network failure; return any HTTP status as a response."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
56
|
+
# A redirect would carry X-API-Key to wherever it points, so a 3xx is returned as is.
|
|
57
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_opener = urllib.request.build_opener(_NoRedirect)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def urllib_transport(req: HttpRequest) -> HttpResponse:
|
|
65
|
+
"""The default transport, on the standard library."""
|
|
66
|
+
request = urllib.request.Request(req.url, data=req.body, headers=dict(req.headers), method=req.method)
|
|
67
|
+
try:
|
|
68
|
+
with _opener.open(request, timeout=req.timeout) as res:
|
|
69
|
+
return HttpResponse(res.status, res.reason, _lower(res.headers.items()), res.read())
|
|
70
|
+
except urllib.error.HTTPError as err:
|
|
71
|
+
with err:
|
|
72
|
+
return HttpResponse(err.code, err.reason, _lower(err.headers.items()), err.read())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _lower(items: Any) -> dict[str, str]:
|
|
76
|
+
return {name.lower(): value for name, value in items}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class HttpConfig:
|
|
81
|
+
api_key: str = field(repr=False)
|
|
82
|
+
base_url: str
|
|
83
|
+
transport: Transport = urllib_transport
|
|
84
|
+
timeout: float = 30.0
|
|
85
|
+
"""Socket timeout in seconds, for the connect and for each read. A timeout counts as a network error."""
|
|
86
|
+
max_retries: int = 3
|
|
87
|
+
"""Retries after the first attempt."""
|
|
88
|
+
base_delay: float = 0.5
|
|
89
|
+
"""First backoff delay in seconds; each retry doubles it, with jitter."""
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
# Fails at construction, so a bad option never turns into an endless loop or an instant timeout.
|
|
93
|
+
# A stray newline or space would fail inside http.client, whose error quotes the key.
|
|
94
|
+
if not _API_KEY.fullmatch(self.api_key):
|
|
95
|
+
raise ValueError("api_key must be non-empty printable ASCII with no spaces or newlines")
|
|
96
|
+
if isinstance(self.max_retries, bool) or not (
|
|
97
|
+
isinstance(self.max_retries, int) and self.max_retries >= 0
|
|
98
|
+
):
|
|
99
|
+
raise ValueError(f"max_retries must be a non-negative integer: {self.max_retries}")
|
|
100
|
+
if not (_finite(self.base_delay) and self.base_delay >= 0):
|
|
101
|
+
raise ValueError(f"base_delay must be a finite number >= 0: {self.base_delay}")
|
|
102
|
+
if not (_finite(self.timeout) and self.timeout > 0):
|
|
103
|
+
raise ValueError(f"timeout must be a finite number > 0: {self.timeout}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _finite(value: object) -> bool:
|
|
107
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_API_KEY = re.compile(r"[\x21-\x7e]+")
|
|
111
|
+
IDEMPOTENCY_KEY = re.compile(r"[A-Za-z0-9_-]{1,18}")
|
|
112
|
+
_KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def generate_idempotency_key() -> str:
|
|
116
|
+
"""A random 18-character key (108 bits).
|
|
117
|
+
|
|
118
|
+
Store it with your order before you call, so a retry after a crash can reuse it.
|
|
119
|
+
"""
|
|
120
|
+
return "".join(secrets.choice(_KEY_ALPHABET) for _ in range(18))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def assert_idempotency_key(key: str) -> None:
|
|
124
|
+
if not IDEMPOTENCY_KEY.fullmatch(key):
|
|
125
|
+
raise ValueError(f'Invalid idempotency key "{key}": use 1 to 18 letters, digits, "_" or "-"')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def normalise_base_url(base_url: str) -> str:
|
|
129
|
+
"""Refuses plain HTTP, which would send the API key in cleartext, except to this machine."""
|
|
130
|
+
url = urllib.parse.urlsplit(base_url)
|
|
131
|
+
local = url.hostname in ("localhost", "127.0.0.1", "::1")
|
|
132
|
+
if url.scheme != "https" and not (url.scheme == "http" and local):
|
|
133
|
+
raise ValueError(f"base_url must use https: {base_url}")
|
|
134
|
+
if url.query or url.fragment or url.username is not None or url.password is not None:
|
|
135
|
+
# Not echoed: it may carry credentials.
|
|
136
|
+
raise ValueError("base_url must not carry a query, fragment or credentials")
|
|
137
|
+
if not url.netloc:
|
|
138
|
+
raise ValueError(f"base_url has no host: {base_url}")
|
|
139
|
+
return base_url.rstrip("/")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def encode_json(value: Any) -> str:
|
|
143
|
+
"""JSON with every Decimal written as a number, digit for digit."""
|
|
144
|
+
if isinstance(value, Decimal):
|
|
145
|
+
if not value.is_finite():
|
|
146
|
+
raise ValueError(f"{value} is not a JSON number")
|
|
147
|
+
return format(value, "f")
|
|
148
|
+
if isinstance(value, float):
|
|
149
|
+
raise TypeError(f"{value!r} is a float; pass a Decimal, a str or an int")
|
|
150
|
+
if isinstance(value, (datetime, date)):
|
|
151
|
+
return json.dumps(value.isoformat())
|
|
152
|
+
if isinstance(value, Mapping):
|
|
153
|
+
items = (f"{json.dumps(str(key))}:{encode_json(item)}" for key, item in value.items())
|
|
154
|
+
return "{" + ",".join(items) + "}"
|
|
155
|
+
if isinstance(value, (list, tuple)):
|
|
156
|
+
return "[" + ",".join(encode_json(item) for item in value) + "]"
|
|
157
|
+
if value is None or isinstance(value, (str, int, bool)):
|
|
158
|
+
return json.dumps(value)
|
|
159
|
+
raise TypeError(f"cannot send {type(value).__name__} as JSON")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def query_value(value: Any) -> str:
|
|
163
|
+
if isinstance(value, bool):
|
|
164
|
+
return "true" if value else "false"
|
|
165
|
+
if isinstance(value, Decimal):
|
|
166
|
+
return format(value, "f")
|
|
167
|
+
if isinstance(value, float):
|
|
168
|
+
raise TypeError(f"{value!r} is a float; pass a Decimal, a str or an int")
|
|
169
|
+
if isinstance(value, (datetime, date)):
|
|
170
|
+
return value.isoformat()
|
|
171
|
+
return str(value)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class _Context:
|
|
176
|
+
method: str
|
|
177
|
+
path: str
|
|
178
|
+
idempotency_key: str | None = None
|
|
179
|
+
dedupe_key: tuple[str, str] | None = None
|
|
180
|
+
"""A key the API dedupes on that travels in the body, such as a swap intent's id."""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_NOT_JSON = object()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def request(
|
|
187
|
+
config: HttpConfig,
|
|
188
|
+
method: str,
|
|
189
|
+
path: str,
|
|
190
|
+
*,
|
|
191
|
+
retry: bool,
|
|
192
|
+
path_params: Mapping[str, str] | None = None,
|
|
193
|
+
query: Mapping[str, Any] | None = None,
|
|
194
|
+
body: Any = None,
|
|
195
|
+
idempotency_key: str | None = None,
|
|
196
|
+
dedupe_key: tuple[str, str] | None = None,
|
|
197
|
+
) -> tuple[int, Any]:
|
|
198
|
+
"""Sends a request and returns the status and the parsed JSON body of a 2xx.
|
|
199
|
+
|
|
200
|
+
`retry` is True only when repeating the call cannot create or move anything twice.
|
|
201
|
+
JSON numbers with a fraction parse as Decimal, never float.
|
|
202
|
+
"""
|
|
203
|
+
max_retries = config.max_retries if retry else 0
|
|
204
|
+
url = _build_url(config.base_url, path, path_params, query)
|
|
205
|
+
headers = {
|
|
206
|
+
"accept": "application/json",
|
|
207
|
+
"user-agent": f"tgbp-kit-python/{__version__}",
|
|
208
|
+
"x-api-key": config.api_key,
|
|
209
|
+
}
|
|
210
|
+
data = None
|
|
211
|
+
if body is not None:
|
|
212
|
+
headers["content-type"] = "application/json"
|
|
213
|
+
data = encode_json(body).encode()
|
|
214
|
+
if idempotency_key is not None:
|
|
215
|
+
headers["idempotency-key"] = idempotency_key
|
|
216
|
+
context = _Context(method, path, idempotency_key, dedupe_key)
|
|
217
|
+
outgoing = HttpRequest(method, url, headers, data, config.timeout)
|
|
218
|
+
|
|
219
|
+
attempt = 0
|
|
220
|
+
while True:
|
|
221
|
+
if attempt > 0:
|
|
222
|
+
time.sleep(_backoff(config.base_delay, attempt))
|
|
223
|
+
# A failure anywhere before the whole body arrives, timeout included, is a network error.
|
|
224
|
+
try:
|
|
225
|
+
response = config.transport(outgoing)
|
|
226
|
+
except (OSError, http.client.HTTPException) as cause:
|
|
227
|
+
if attempt < max_retries:
|
|
228
|
+
attempt += 1
|
|
229
|
+
continue
|
|
230
|
+
raise TgbpKitError(
|
|
231
|
+
"API_UNREACHABLE",
|
|
232
|
+
f"{method} {path} got no response ({_describe(cause)}){_retry_hint(context)}",
|
|
233
|
+
{
|
|
234
|
+
**_context_details(context),
|
|
235
|
+
"attempts": attempt + 1,
|
|
236
|
+
"mayHaveTakenEffect": method != "GET",
|
|
237
|
+
},
|
|
238
|
+
) from cause
|
|
239
|
+
if response.status >= 500 and attempt < max_retries:
|
|
240
|
+
attempt += 1
|
|
241
|
+
continue
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
attempts = attempt + 1
|
|
245
|
+
text = response.body.decode("utf-8", errors="replace")
|
|
246
|
+
parsed = _parse_json(text)
|
|
247
|
+
# Keep a proxy's HTML or plain-text error, capped, so the caller can see what answered.
|
|
248
|
+
error_body = text[:2000] if parsed is _NOT_JSON else parsed
|
|
249
|
+
if not 200 <= response.status < 300:
|
|
250
|
+
raise api_error(response, error_body, context, attempts)
|
|
251
|
+
if parsed is _NOT_JSON:
|
|
252
|
+
raise api_error(response, error_body, context, attempts, "the response was not JSON")
|
|
253
|
+
# Every success the API documents carries a body, so an empty one is a broken answer.
|
|
254
|
+
if parsed is None:
|
|
255
|
+
raise api_error(response, None, context, attempts, "the response body was empty")
|
|
256
|
+
return response.status, parsed
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _describe(cause: BaseException) -> str:
|
|
260
|
+
"""Names what went wrong without a response: a timeout, a reset, a DNS failure."""
|
|
261
|
+
if isinstance(cause, urllib.error.URLError):
|
|
262
|
+
cause = cause.reason if isinstance(cause.reason, BaseException) else cause
|
|
263
|
+
if isinstance(cause, TimeoutError):
|
|
264
|
+
return "timed out"
|
|
265
|
+
return f"{type(cause).__name__}: {cause}"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _build_url(
|
|
269
|
+
base_url: str,
|
|
270
|
+
path: str,
|
|
271
|
+
path_params: Mapping[str, str] | None,
|
|
272
|
+
query: Mapping[str, Any] | None,
|
|
273
|
+
) -> str:
|
|
274
|
+
def substitute(match: re.Match[str]) -> str:
|
|
275
|
+
name = match.group(1)
|
|
276
|
+
value = (path_params or {}).get(name)
|
|
277
|
+
if value is None:
|
|
278
|
+
raise ValueError(f"Missing path parameter {name}")
|
|
279
|
+
# These would resolve as dot segments and call a different endpoint.
|
|
280
|
+
if value in ("", ".", ".."):
|
|
281
|
+
raise ValueError(f'Invalid path parameter {name}: "{value}"')
|
|
282
|
+
return urllib.parse.quote(value, safe="")
|
|
283
|
+
|
|
284
|
+
url = base_url + re.sub(r"\{(\w+)\}", substitute, path)
|
|
285
|
+
pairs: list[tuple[str, str]] = []
|
|
286
|
+
for name, value in (query or {}).items():
|
|
287
|
+
if value is None:
|
|
288
|
+
continue
|
|
289
|
+
for item in value if isinstance(value, (list, tuple)) else [value]:
|
|
290
|
+
pairs.append((name, query_value(item)))
|
|
291
|
+
return f"{url}?{urllib.parse.urlencode(pairs)}" if pairs else url
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _backoff(base_delay: float, attempt: int) -> float:
|
|
295
|
+
"""Exponential backoff with jitter: 0.5x to 1x of base * 2^(attempt - 1)."""
|
|
296
|
+
return float(base_delay * 2 ** (attempt - 1) * (0.5 + random.random() / 2))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _parse_json(text: str) -> Any:
|
|
300
|
+
if text == "":
|
|
301
|
+
return None
|
|
302
|
+
try:
|
|
303
|
+
return json.loads(text, parse_float=Decimal, parse_constant=_reject_constant)
|
|
304
|
+
except (ValueError, RecursionError):
|
|
305
|
+
return _NOT_JSON
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _reject_constant(name: str) -> Any:
|
|
309
|
+
"""NaN and Infinity are not JSON, and would parse as float."""
|
|
310
|
+
raise ValueError(f"{name} is not a JSON number")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _retry_key(context: _Context) -> tuple[str, str] | None:
|
|
314
|
+
"""The key a retry must resend: the idempotency key, or a dedupe key carried in the body."""
|
|
315
|
+
if context.idempotency_key is not None:
|
|
316
|
+
return ("idempotency key", context.idempotency_key)
|
|
317
|
+
return context.dedupe_key
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _retry_hint(context: _Context) -> str:
|
|
321
|
+
key = _retry_key(context)
|
|
322
|
+
if key is not None:
|
|
323
|
+
return f"; it may have taken effect, so retry only with the same {key[0]} ({key[1]})"
|
|
324
|
+
return "" if context.method == "GET" else "; it may have taken effect, so read the record first"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _context_details(context: _Context) -> dict[str, Any]:
|
|
328
|
+
"""The context as error details, with a dedupe key under its own name."""
|
|
329
|
+
details: dict[str, Any] = {"method": context.method, "path": context.path}
|
|
330
|
+
if context.idempotency_key is not None:
|
|
331
|
+
details["idempotencyKey"] = context.idempotency_key
|
|
332
|
+
if context.dedupe_key is not None:
|
|
333
|
+
details[context.dedupe_key[0]] = context.dedupe_key[1]
|
|
334
|
+
return details
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def api_error(
|
|
338
|
+
response: HttpResponse,
|
|
339
|
+
body: Any,
|
|
340
|
+
context: _Context,
|
|
341
|
+
attempts: int,
|
|
342
|
+
reason: str | None = None,
|
|
343
|
+
) -> TgbpKitError:
|
|
344
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
345
|
+
error = error if isinstance(error, dict) else {}
|
|
346
|
+
details = error.get("details")
|
|
347
|
+
details = details if isinstance(details, dict) else {}
|
|
348
|
+
api_code = error.get("code") if isinstance(error.get("code"), str) else None
|
|
349
|
+
request_id = details.get("request_id")
|
|
350
|
+
if not isinstance(request_id, str):
|
|
351
|
+
request_id = response.headers.get("x-request-id")
|
|
352
|
+
message = reason or (error["message"] if isinstance(error.get("message"), str) else response.reason)
|
|
353
|
+
ok = 200 <= response.status < 300
|
|
354
|
+
# A 409 duplicate_request on a keyed create means an earlier request with that
|
|
355
|
+
# key is still creating the record; resending under a new key would create a
|
|
356
|
+
# second one. Other 409s, such as duplicate_active_mint, created nothing.
|
|
357
|
+
keyed_409 = response.status == 409 and api_code == "duplicate_request" and _retry_key(context) is not None
|
|
358
|
+
hint = _retry_hint(context) if ok or response.status >= 500 or keyed_409 else ""
|
|
359
|
+
code = f" {api_code}" if api_code else ""
|
|
360
|
+
return TgbpKitError(
|
|
361
|
+
"API_ERROR",
|
|
362
|
+
f"{context.method} {context.path} failed with {response.status}{code}: {message}{hint}",
|
|
363
|
+
{
|
|
364
|
+
**_context_details(context),
|
|
365
|
+
"attempts": attempts,
|
|
366
|
+
"status": response.status,
|
|
367
|
+
"apiCode": api_code,
|
|
368
|
+
"requestId": request_id,
|
|
369
|
+
"contentType": response.headers.get("content-type"),
|
|
370
|
+
"mayHaveTakenEffect": hint != "",
|
|
371
|
+
"body": body,
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def unexpected_body(
|
|
377
|
+
method: str,
|
|
378
|
+
path: str,
|
|
379
|
+
status: int,
|
|
380
|
+
body: Any,
|
|
381
|
+
expected: str,
|
|
382
|
+
*,
|
|
383
|
+
idempotency_key: str | None = None,
|
|
384
|
+
dedupe_key: tuple[str, str] | None = None,
|
|
385
|
+
) -> TgbpKitError:
|
|
386
|
+
"""A 2xx without the body it promised. A write may still have taken effect."""
|
|
387
|
+
context = _Context(method, path, idempotency_key, dedupe_key)
|
|
388
|
+
hint = _retry_hint(context)
|
|
389
|
+
return TgbpKitError(
|
|
390
|
+
"API_ERROR",
|
|
391
|
+
f"{method} {path} returned {status} without {expected}{hint}",
|
|
392
|
+
{**_context_details(context), "status": status, "mayHaveTakenEffect": hint != "", "body": body},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def incomplete_create(path: str, status: int, body: Any, idempotency_key: str, missing: str) -> TgbpKitError:
|
|
397
|
+
"""A create answered 2xx without the record it promised; the error carries the key to retry with."""
|
|
398
|
+
return unexpected_body("POST", path, status, body, missing, idempotency_key=idempotency_key)
|
tgbp_kit/_version.py
ADDED