raims 0.1.3__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.
- raims/__init__.py +67 -0
- raims/_client.py +560 -0
- raims/_errors.py +80 -0
- raims/_generated/contract.json +6982 -0
- raims/_options.py +65 -0
- raims/_pagination.py +137 -0
- raims/_resource.py +66 -0
- raims/_response.py +29 -0
- raims/_validation.py +197 -0
- raims/_version.py +1 -0
- raims/_webhook.py +111 -0
- raims/enum_values.py +207 -0
- raims/models.py +1791 -0
- raims/py.typed +0 -0
- raims/services.py +666 -0
- raims/types.py +435 -0
- raims-0.1.3.dist-info/METADATA +157 -0
- raims-0.1.3.dist-info/RECORD +20 -0
- raims-0.1.3.dist-info/WHEEL +4 -0
- raims-0.1.3.dist-info/licenses/LICENSE +21 -0
raims/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from ._client import VERSION as __version__
|
|
2
|
+
from ._client import AsyncRaimsClient as AsyncRaimsClient
|
|
3
|
+
from ._client import RaimsClient as RaimsClient
|
|
4
|
+
from ._errors import (
|
|
5
|
+
ApiError as ApiError,
|
|
6
|
+
)
|
|
7
|
+
from ._errors import (
|
|
8
|
+
AuthenticationError as AuthenticationError,
|
|
9
|
+
)
|
|
10
|
+
from ._errors import (
|
|
11
|
+
ConflictError as ConflictError,
|
|
12
|
+
)
|
|
13
|
+
from ._errors import (
|
|
14
|
+
ConnectionError as ConnectionError,
|
|
15
|
+
)
|
|
16
|
+
from ._errors import (
|
|
17
|
+
InvalidRequestError as InvalidRequestError,
|
|
18
|
+
)
|
|
19
|
+
from ._errors import (
|
|
20
|
+
NotFoundError as NotFoundError,
|
|
21
|
+
)
|
|
22
|
+
from ._errors import (
|
|
23
|
+
PermissionError as PermissionError,
|
|
24
|
+
)
|
|
25
|
+
from ._errors import (
|
|
26
|
+
RaimsError as RaimsError,
|
|
27
|
+
)
|
|
28
|
+
from ._errors import (
|
|
29
|
+
RateLimitError as RateLimitError,
|
|
30
|
+
)
|
|
31
|
+
from ._errors import (
|
|
32
|
+
SignatureVerificationError as SignatureVerificationError,
|
|
33
|
+
)
|
|
34
|
+
from ._errors import (
|
|
35
|
+
UnexpectedResponseError as UnexpectedResponseError,
|
|
36
|
+
)
|
|
37
|
+
from ._options import RequestDiagnostics as RequestDiagnostics
|
|
38
|
+
from ._options import RequestOptions as RequestOptions
|
|
39
|
+
from ._pagination import ApiList as ApiList
|
|
40
|
+
from ._pagination import AsyncApiList as AsyncApiList
|
|
41
|
+
from ._resource import ApiResource as ApiResource
|
|
42
|
+
from ._response import LastResponse as LastResponse
|
|
43
|
+
from ._webhook import Webhook as Webhook
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ApiError",
|
|
47
|
+
"ApiList",
|
|
48
|
+
"ApiResource",
|
|
49
|
+
"AsyncApiList",
|
|
50
|
+
"AsyncRaimsClient",
|
|
51
|
+
"AuthenticationError",
|
|
52
|
+
"ConflictError",
|
|
53
|
+
"ConnectionError",
|
|
54
|
+
"InvalidRequestError",
|
|
55
|
+
"LastResponse",
|
|
56
|
+
"NotFoundError",
|
|
57
|
+
"PermissionError",
|
|
58
|
+
"RaimsClient",
|
|
59
|
+
"RaimsError",
|
|
60
|
+
"RateLimitError",
|
|
61
|
+
"RequestDiagnostics",
|
|
62
|
+
"RequestOptions",
|
|
63
|
+
"SignatureVerificationError",
|
|
64
|
+
"UnexpectedResponseError",
|
|
65
|
+
"Webhook",
|
|
66
|
+
"__version__",
|
|
67
|
+
]
|
raims/_client.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any, TypeVar
|
|
15
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from . import _errors as errors
|
|
20
|
+
from . import services
|
|
21
|
+
from ._options import (
|
|
22
|
+
RequestDiagnostics,
|
|
23
|
+
RequestObserver,
|
|
24
|
+
RequestOptions,
|
|
25
|
+
validate_retries,
|
|
26
|
+
validate_timeout,
|
|
27
|
+
visible_ascii,
|
|
28
|
+
)
|
|
29
|
+
from ._pagination import ApiList, AsyncApiList
|
|
30
|
+
from ._response import LastResponse
|
|
31
|
+
from ._validation import CONTRACT, hydrate, parse_json, serialize_params, validate_response
|
|
32
|
+
from ._version import __version__ as VERSION
|
|
33
|
+
|
|
34
|
+
_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
35
|
+
_RETRY_STATUSES = {408, 429, 500, 502, 503, 504}
|
|
36
|
+
_SyncClientT = TypeVar("_SyncClientT", bound="RaimsClient")
|
|
37
|
+
_AsyncClientT = TypeVar("_AsyncClientT", bound="AsyncRaimsClient")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _api_base(value: str) -> str:
|
|
41
|
+
if not isinstance(value, str) or re.search(r"[\x00-\x20\x7f\\]", value):
|
|
42
|
+
raise ValueError("api_base must be an HTTPS origin without credentials or a path.")
|
|
43
|
+
try:
|
|
44
|
+
url = urlsplit(value)
|
|
45
|
+
hostname = url.hostname
|
|
46
|
+
port = url.port
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise ValueError("api_base is invalid.") from None
|
|
49
|
+
loopback = hostname == "localhost"
|
|
50
|
+
try:
|
|
51
|
+
loopback = loopback or ipaddress.ip_address(hostname or "").is_loopback
|
|
52
|
+
except ValueError:
|
|
53
|
+
pass
|
|
54
|
+
if (
|
|
55
|
+
not hostname
|
|
56
|
+
or url.username is not None
|
|
57
|
+
or url.password is not None
|
|
58
|
+
or url.path not in ("", "/")
|
|
59
|
+
or url.query
|
|
60
|
+
or url.fragment
|
|
61
|
+
or "?" in value
|
|
62
|
+
or "#" in value
|
|
63
|
+
or (url.scheme != "https" and not (url.scheme == "http" and loopback))
|
|
64
|
+
or port == 0
|
|
65
|
+
):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"api_base requires HTTPS without credentials, path, query or fragment; HTTP is allowed only on loopback."
|
|
68
|
+
)
|
|
69
|
+
return value.rstrip("/")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _retry_delay(attempt: int, response: LastResponse | None) -> float | None:
|
|
73
|
+
delay = min(2.0, 0.5 * 2**attempt) * random.uniform(0.5, 1.0)
|
|
74
|
+
header = response.retry_after if response else None
|
|
75
|
+
if header:
|
|
76
|
+
try:
|
|
77
|
+
if re.fullmatch(r"[0-9]+", header.strip()):
|
|
78
|
+
wanted = float(header)
|
|
79
|
+
else:
|
|
80
|
+
wanted = parsedate_to_datetime(header).timestamp() - time.time()
|
|
81
|
+
if wanted > 30:
|
|
82
|
+
return None
|
|
83
|
+
delay = max(delay, wanted)
|
|
84
|
+
except (ValueError, TypeError, OverflowError):
|
|
85
|
+
pass
|
|
86
|
+
return delay
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class _Prepared:
|
|
91
|
+
operation: dict[str, Any]
|
|
92
|
+
path_params: dict[str, str]
|
|
93
|
+
params: dict[str, Any]
|
|
94
|
+
options: RequestOptions
|
|
95
|
+
url: str
|
|
96
|
+
headers: dict[str, str]
|
|
97
|
+
body: bytes | None
|
|
98
|
+
timeout: float
|
|
99
|
+
retries: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _ClientBase:
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
api_key: str,
|
|
106
|
+
*,
|
|
107
|
+
api_base: str,
|
|
108
|
+
timeout: float,
|
|
109
|
+
connect_timeout: float,
|
|
110
|
+
max_network_retries: int,
|
|
111
|
+
request_observer: RequestObserver | None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self._api_key = visible_ascii(api_key, 4096, "api_key")
|
|
114
|
+
self._base = _api_base(api_base)
|
|
115
|
+
self._timeout = validate_timeout(timeout)
|
|
116
|
+
self._connect_timeout = validate_timeout(connect_timeout)
|
|
117
|
+
self._retries = validate_retries(max_network_retries)
|
|
118
|
+
if request_observer is not None and not callable(request_observer):
|
|
119
|
+
raise ValueError("request_observer must be callable.")
|
|
120
|
+
self._observer = request_observer
|
|
121
|
+
self._notifying: ContextVar[bool] = ContextVar("raims_observer", default=False)
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
return f"<{type(self).__name__} version={VERSION}>"
|
|
125
|
+
|
|
126
|
+
def _prepare(
|
|
127
|
+
self,
|
|
128
|
+
operation_id: str,
|
|
129
|
+
path_params: dict[str, str],
|
|
130
|
+
params: Mapping[str, Any] | None,
|
|
131
|
+
options: RequestOptions | None,
|
|
132
|
+
) -> _Prepared:
|
|
133
|
+
operation = CONTRACT["operations"][operation_id]
|
|
134
|
+
if options is not None and not isinstance(options, RequestOptions):
|
|
135
|
+
raise ValueError("options must be a RequestOptions instance.")
|
|
136
|
+
options = options or RequestOptions()
|
|
137
|
+
if options.idempotency_key is not None and not operation["idempotency"]:
|
|
138
|
+
raise ValueError("This operation does not accept an idempotency key.")
|
|
139
|
+
path = operation["path"]
|
|
140
|
+
if set(path_params) != set(operation["pathParams"]):
|
|
141
|
+
raise ValueError("The operation received invalid path parameters.")
|
|
142
|
+
for name, value in path_params.items():
|
|
143
|
+
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
144
|
+
raise ValueError("Resource identifiers must be non-empty URL-safe strings.")
|
|
145
|
+
path = path.replace("{" + name + "}", quote(value, safe=""))
|
|
146
|
+
if params is None:
|
|
147
|
+
if operation.get("body_required"):
|
|
148
|
+
raise ValueError("This operation requires request parameters.")
|
|
149
|
+
snapshot: dict[str, Any] = {}
|
|
150
|
+
elif isinstance(params, Mapping):
|
|
151
|
+
snapshot = json.loads(serialize_params(params))
|
|
152
|
+
else:
|
|
153
|
+
raise ValueError("params must be a mapping of JSON field names to values.")
|
|
154
|
+
if (
|
|
155
|
+
snapshot.get("after") is not None
|
|
156
|
+
and snapshot.get("before") is not None
|
|
157
|
+
and operation["paginated"]
|
|
158
|
+
):
|
|
159
|
+
raise ValueError("after and before cannot be used together.")
|
|
160
|
+
headers = {
|
|
161
|
+
"Authorization": "Bearer " + (options.api_key or self._api_key),
|
|
162
|
+
"Accept": "application/json",
|
|
163
|
+
"User-Agent": "raims-python/" + VERSION,
|
|
164
|
+
}
|
|
165
|
+
if options.idempotency_key is not None:
|
|
166
|
+
headers["Idempotency-Key"] = options.idempotency_key
|
|
167
|
+
body = None
|
|
168
|
+
if operation["body"]:
|
|
169
|
+
body = serialize_params(snapshot)
|
|
170
|
+
if operation.get("maxBodyBytes") is not None and len(body) > operation["maxBodyBytes"]:
|
|
171
|
+
raise ValueError("Request body exceeds the published size limit.")
|
|
172
|
+
headers["Content-Type"] = "application/json"
|
|
173
|
+
else:
|
|
174
|
+
query: list[tuple[str, str]] = []
|
|
175
|
+
for name, value in snapshot.items():
|
|
176
|
+
if name not in operation["query"]:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
"This operation received an unsupported query parameter or body."
|
|
179
|
+
)
|
|
180
|
+
if value is None:
|
|
181
|
+
continue
|
|
182
|
+
for item in value if isinstance(value, list) else [value]:
|
|
183
|
+
if type(item) not in (str, int, float, bool):
|
|
184
|
+
raise ValueError("Query values must be scalars or lists of scalars.")
|
|
185
|
+
query.append((name, str(item).lower() if isinstance(item, bool) else str(item)))
|
|
186
|
+
if query:
|
|
187
|
+
path += "?" + urlencode(query)
|
|
188
|
+
retries = (
|
|
189
|
+
options.max_network_retries
|
|
190
|
+
if options.max_network_retries is not None
|
|
191
|
+
else self._retries
|
|
192
|
+
)
|
|
193
|
+
return _Prepared(
|
|
194
|
+
operation,
|
|
195
|
+
dict(path_params),
|
|
196
|
+
snapshot,
|
|
197
|
+
options,
|
|
198
|
+
self._base + path,
|
|
199
|
+
headers,
|
|
200
|
+
body,
|
|
201
|
+
options.timeout if options.timeout is not None else self._timeout,
|
|
202
|
+
retries if operation["method"] in ("GET", "HEAD") else 0,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _http_request(self, prepared: _Prepared) -> httpx.Request:
|
|
206
|
+
timeout = httpx.Timeout(
|
|
207
|
+
prepared.timeout, connect=min(prepared.timeout, self._connect_timeout)
|
|
208
|
+
)
|
|
209
|
+
return httpx.Request(
|
|
210
|
+
prepared.operation["method"],
|
|
211
|
+
prepared.url,
|
|
212
|
+
headers=prepared.headers,
|
|
213
|
+
content=prepared.body,
|
|
214
|
+
extensions={"timeout": timeout.as_dict()},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _notify(
|
|
218
|
+
self,
|
|
219
|
+
prepared: _Prepared,
|
|
220
|
+
attempt: int,
|
|
221
|
+
start: float,
|
|
222
|
+
response: LastResponse | None,
|
|
223
|
+
delay: float | None,
|
|
224
|
+
) -> None:
|
|
225
|
+
if self._observer is None or self._notifying.get():
|
|
226
|
+
return
|
|
227
|
+
token = self._notifying.set(True)
|
|
228
|
+
try:
|
|
229
|
+
self._observer(
|
|
230
|
+
RequestDiagnostics(
|
|
231
|
+
prepared.operation["id"],
|
|
232
|
+
prepared.operation["method"],
|
|
233
|
+
prepared.operation["path"],
|
|
234
|
+
attempt + 1,
|
|
235
|
+
(time.perf_counter() - start) * 1000,
|
|
236
|
+
response.status_code if response else None,
|
|
237
|
+
response.request_id if response else None,
|
|
238
|
+
delay is not None,
|
|
239
|
+
delay * 1000 if delay is not None else None,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
except Exception: # noqa: BLE001, S110
|
|
243
|
+
pass # Observer failures must not change the result of financial requests.
|
|
244
|
+
finally:
|
|
245
|
+
self._notifying.reset(token)
|
|
246
|
+
|
|
247
|
+
def _interpret(self, prepared: _Prepared, response: LastResponse, *, asynchronous: bool) -> Any:
|
|
248
|
+
status = response.status_code
|
|
249
|
+
if 300 <= status < 400:
|
|
250
|
+
raise errors.UnexpectedResponseError(
|
|
251
|
+
"The API returned a redirect; redirects are not followed.", last_response=response
|
|
252
|
+
)
|
|
253
|
+
if not 200 <= status < 300:
|
|
254
|
+
api_error = None
|
|
255
|
+
try:
|
|
256
|
+
data = parse_json(response.body)
|
|
257
|
+
if isinstance(data, dict) and isinstance(data.get("error"), dict):
|
|
258
|
+
api_error = data["error"]
|
|
259
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
260
|
+
pass
|
|
261
|
+
category = {
|
|
262
|
+
400: errors.InvalidRequestError,
|
|
263
|
+
405: errors.InvalidRequestError,
|
|
264
|
+
413: errors.InvalidRequestError,
|
|
265
|
+
415: errors.InvalidRequestError,
|
|
266
|
+
422: errors.InvalidRequestError,
|
|
267
|
+
401: errors.AuthenticationError,
|
|
268
|
+
403: errors.PermissionError,
|
|
269
|
+
404: errors.NotFoundError,
|
|
270
|
+
409: errors.ConflictError,
|
|
271
|
+
429: errors.RateLimitError,
|
|
272
|
+
}.get(status, errors.ApiError)
|
|
273
|
+
raise category(
|
|
274
|
+
f"The API request failed with HTTP {status}.",
|
|
275
|
+
last_response=response,
|
|
276
|
+
api_error=api_error,
|
|
277
|
+
)
|
|
278
|
+
responses = prepared.operation["responses"]
|
|
279
|
+
if str(status) not in responses:
|
|
280
|
+
raise errors.UnexpectedResponseError(
|
|
281
|
+
"The API returned an undocumented success status.", last_response=response
|
|
282
|
+
)
|
|
283
|
+
schema = responses[str(status)]
|
|
284
|
+
if schema is None:
|
|
285
|
+
if response.body:
|
|
286
|
+
raise errors.UnexpectedResponseError(
|
|
287
|
+
"The API returned a body for an empty response.", last_response=response
|
|
288
|
+
)
|
|
289
|
+
return None
|
|
290
|
+
content_type = response.headers.get("content-type")
|
|
291
|
+
if content_type and not re.match(
|
|
292
|
+
r"application/(?:[a-z0-9.+-]+\+)?json(?:\s*;|$)", content_type, re.IGNORECASE
|
|
293
|
+
):
|
|
294
|
+
raise errors.UnexpectedResponseError(
|
|
295
|
+
"The API returned a non-JSON success response.", last_response=response
|
|
296
|
+
)
|
|
297
|
+
try:
|
|
298
|
+
data = parse_json(response.body)
|
|
299
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
300
|
+
raise errors.UnexpectedResponseError(
|
|
301
|
+
"The API returned invalid JSON.", last_response=response
|
|
302
|
+
) from None
|
|
303
|
+
validate_response(data, schema, response)
|
|
304
|
+
resource = hydrate(data, schema, response)
|
|
305
|
+
if prepared.operation["paginated"]:
|
|
306
|
+
page_type = AsyncApiList if asynchronous else ApiList
|
|
307
|
+
return page_type(
|
|
308
|
+
resource,
|
|
309
|
+
self,
|
|
310
|
+
prepared.operation["id"],
|
|
311
|
+
prepared.path_params,
|
|
312
|
+
prepared.params,
|
|
313
|
+
prepared.options,
|
|
314
|
+
)
|
|
315
|
+
return resource
|
|
316
|
+
|
|
317
|
+
def _check_size(self, size: int, status: int, headers: Mapping[str, str]) -> None:
|
|
318
|
+
if size > _MAX_RESPONSE_BYTES:
|
|
319
|
+
raise errors.UnexpectedResponseError(
|
|
320
|
+
"The API response exceeds the supported size.",
|
|
321
|
+
last_response=LastResponse(status, headers, b""),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass(frozen=True)
|
|
326
|
+
class Simulations:
|
|
327
|
+
charges: services.SimulationChargesService
|
|
328
|
+
refunds: services.SimulationRefundsService
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass(frozen=True)
|
|
332
|
+
class AsyncSimulations:
|
|
333
|
+
charges: services.AsyncSimulationChargesService
|
|
334
|
+
refunds: services.AsyncSimulationRefundsService
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class RaimsClient(_ClientBase):
|
|
338
|
+
def __init__(
|
|
339
|
+
self,
|
|
340
|
+
api_key: str,
|
|
341
|
+
*,
|
|
342
|
+
api_base: str = "https://api.raims.com",
|
|
343
|
+
timeout: float = 80.0,
|
|
344
|
+
connect_timeout: float = 10.0,
|
|
345
|
+
max_network_retries: int = 0,
|
|
346
|
+
transport: httpx.BaseTransport | None = None,
|
|
347
|
+
request_observer: RequestObserver | None = None,
|
|
348
|
+
) -> None:
|
|
349
|
+
super().__init__(
|
|
350
|
+
api_key,
|
|
351
|
+
api_base=api_base,
|
|
352
|
+
timeout=timeout,
|
|
353
|
+
connect_timeout=connect_timeout,
|
|
354
|
+
max_network_retries=max_network_retries,
|
|
355
|
+
request_observer=request_observer,
|
|
356
|
+
)
|
|
357
|
+
self._http = httpx.Client(transport=transport, trust_env=False, follow_redirects=False)
|
|
358
|
+
self.accounts = services.AccountsService(self)
|
|
359
|
+
self.balances = services.BalancesService(self)
|
|
360
|
+
self.customers = services.CustomersService(self)
|
|
361
|
+
self.charges = services.ChargesService(self)
|
|
362
|
+
self.payment_links = services.PaymentLinksService(self)
|
|
363
|
+
self.refunds = services.RefundsService(self)
|
|
364
|
+
self.disputes = services.DisputesService(self)
|
|
365
|
+
self.statements = services.StatementsService(self)
|
|
366
|
+
self.withdrawals = services.WithdrawalsService(self)
|
|
367
|
+
self.withdrawal_destinations = services.WithdrawalDestinationsService(self)
|
|
368
|
+
self.webhooks = services.WebhooksService(self)
|
|
369
|
+
self.webhook_event_types = services.WebhookEventTypesService(self)
|
|
370
|
+
self.simulations = Simulations(
|
|
371
|
+
services.SimulationChargesService(self), services.SimulationRefundsService(self)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def _request(
|
|
375
|
+
self,
|
|
376
|
+
operation_id: str,
|
|
377
|
+
*,
|
|
378
|
+
path_params: dict[str, str],
|
|
379
|
+
params: Mapping[str, Any] | None,
|
|
380
|
+
options: RequestOptions | None,
|
|
381
|
+
) -> Any:
|
|
382
|
+
if self._http.is_closed:
|
|
383
|
+
raise RuntimeError("The API client is closed.")
|
|
384
|
+
prepared = self._prepare(operation_id, path_params, params, options)
|
|
385
|
+
for attempt in range(prepared.retries + 1):
|
|
386
|
+
start = time.perf_counter()
|
|
387
|
+
info = None
|
|
388
|
+
try:
|
|
389
|
+
response = self._http.send(
|
|
390
|
+
self._http_request(prepared), stream=True, follow_redirects=False
|
|
391
|
+
)
|
|
392
|
+
info = LastResponse(response.status_code, response.headers, b"")
|
|
393
|
+
try:
|
|
394
|
+
chunks = bytearray()
|
|
395
|
+
for chunk in response.iter_bytes():
|
|
396
|
+
self._check_size(
|
|
397
|
+
len(chunks) + len(chunk), response.status_code, response.headers
|
|
398
|
+
)
|
|
399
|
+
chunks.extend(chunk)
|
|
400
|
+
info = LastResponse(response.status_code, response.headers, bytes(chunks))
|
|
401
|
+
finally:
|
|
402
|
+
response.close()
|
|
403
|
+
except httpx.DecodingError:
|
|
404
|
+
self._notify(prepared, attempt, start, info, None)
|
|
405
|
+
raise errors.UnexpectedResponseError(
|
|
406
|
+
"The API returned invalid compressed content.", last_response=info
|
|
407
|
+
) from None
|
|
408
|
+
except httpx.TransportError:
|
|
409
|
+
delay = _retry_delay(attempt, info) if attempt < prepared.retries else None
|
|
410
|
+
self._notify(prepared, attempt, start, info, delay)
|
|
411
|
+
if delay is None:
|
|
412
|
+
raise errors.ConnectionError(
|
|
413
|
+
"The API connection failed or timed out.", last_response=info
|
|
414
|
+
) from None
|
|
415
|
+
time.sleep(delay)
|
|
416
|
+
continue
|
|
417
|
+
except errors.UnexpectedResponseError as exc:
|
|
418
|
+
self._notify(prepared, attempt, start, exc.last_response, None)
|
|
419
|
+
raise
|
|
420
|
+
delay = (
|
|
421
|
+
_retry_delay(attempt, info)
|
|
422
|
+
if attempt < prepared.retries and info.status_code in _RETRY_STATUSES
|
|
423
|
+
else None
|
|
424
|
+
)
|
|
425
|
+
self._notify(prepared, attempt, start, info, delay)
|
|
426
|
+
if delay is not None:
|
|
427
|
+
time.sleep(delay)
|
|
428
|
+
continue
|
|
429
|
+
return self._interpret(prepared, info, asynchronous=False)
|
|
430
|
+
raise AssertionError("Unreachable retry state.")
|
|
431
|
+
|
|
432
|
+
def close(self) -> None:
|
|
433
|
+
self._http.close()
|
|
434
|
+
|
|
435
|
+
def __enter__(self: _SyncClientT) -> _SyncClientT: # noqa: PYI019
|
|
436
|
+
self._http.__enter__()
|
|
437
|
+
return self
|
|
438
|
+
|
|
439
|
+
def __exit__(
|
|
440
|
+
self,
|
|
441
|
+
exc_type: type[BaseException] | None,
|
|
442
|
+
exc_value: BaseException | None,
|
|
443
|
+
traceback: TracebackType | None,
|
|
444
|
+
) -> None:
|
|
445
|
+
self._http.__exit__(exc_type, exc_value, traceback)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class AsyncRaimsClient(_ClientBase):
|
|
449
|
+
def __init__(
|
|
450
|
+
self,
|
|
451
|
+
api_key: str,
|
|
452
|
+
*,
|
|
453
|
+
api_base: str = "https://api.raims.com",
|
|
454
|
+
timeout: float = 80.0,
|
|
455
|
+
connect_timeout: float = 10.0,
|
|
456
|
+
max_network_retries: int = 0,
|
|
457
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
458
|
+
request_observer: RequestObserver | None = None,
|
|
459
|
+
) -> None:
|
|
460
|
+
super().__init__(
|
|
461
|
+
api_key,
|
|
462
|
+
api_base=api_base,
|
|
463
|
+
timeout=timeout,
|
|
464
|
+
connect_timeout=connect_timeout,
|
|
465
|
+
max_network_retries=max_network_retries,
|
|
466
|
+
request_observer=request_observer,
|
|
467
|
+
)
|
|
468
|
+
self._http = httpx.AsyncClient(transport=transport, trust_env=False, follow_redirects=False)
|
|
469
|
+
self.accounts = services.AsyncAccountsService(self)
|
|
470
|
+
self.balances = services.AsyncBalancesService(self)
|
|
471
|
+
self.customers = services.AsyncCustomersService(self)
|
|
472
|
+
self.charges = services.AsyncChargesService(self)
|
|
473
|
+
self.payment_links = services.AsyncPaymentLinksService(self)
|
|
474
|
+
self.refunds = services.AsyncRefundsService(self)
|
|
475
|
+
self.disputes = services.AsyncDisputesService(self)
|
|
476
|
+
self.statements = services.AsyncStatementsService(self)
|
|
477
|
+
self.withdrawals = services.AsyncWithdrawalsService(self)
|
|
478
|
+
self.withdrawal_destinations = services.AsyncWithdrawalDestinationsService(self)
|
|
479
|
+
self.webhooks = services.AsyncWebhooksService(self)
|
|
480
|
+
self.webhook_event_types = services.AsyncWebhookEventTypesService(self)
|
|
481
|
+
self.simulations = AsyncSimulations(
|
|
482
|
+
services.AsyncSimulationChargesService(self),
|
|
483
|
+
services.AsyncSimulationRefundsService(self),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
async def _request(
|
|
487
|
+
self,
|
|
488
|
+
operation_id: str,
|
|
489
|
+
*,
|
|
490
|
+
path_params: dict[str, str],
|
|
491
|
+
params: Mapping[str, Any] | None,
|
|
492
|
+
options: RequestOptions | None,
|
|
493
|
+
) -> Any:
|
|
494
|
+
if self._http.is_closed:
|
|
495
|
+
raise RuntimeError("The API client is closed.")
|
|
496
|
+
prepared = self._prepare(operation_id, path_params, params, options)
|
|
497
|
+
for attempt in range(prepared.retries + 1):
|
|
498
|
+
start = time.perf_counter()
|
|
499
|
+
info = None
|
|
500
|
+
try:
|
|
501
|
+
response = await self._http.send(
|
|
502
|
+
self._http_request(prepared), stream=True, follow_redirects=False
|
|
503
|
+
)
|
|
504
|
+
info = LastResponse(response.status_code, response.headers, b"")
|
|
505
|
+
try:
|
|
506
|
+
chunks = bytearray()
|
|
507
|
+
async for chunk in response.aiter_bytes():
|
|
508
|
+
self._check_size(
|
|
509
|
+
len(chunks) + len(chunk), response.status_code, response.headers
|
|
510
|
+
)
|
|
511
|
+
chunks.extend(chunk)
|
|
512
|
+
info = LastResponse(response.status_code, response.headers, bytes(chunks))
|
|
513
|
+
finally:
|
|
514
|
+
await response.aclose()
|
|
515
|
+
except httpx.DecodingError:
|
|
516
|
+
self._notify(prepared, attempt, start, info, None)
|
|
517
|
+
raise errors.UnexpectedResponseError(
|
|
518
|
+
"The API returned invalid compressed content.", last_response=info
|
|
519
|
+
) from None
|
|
520
|
+
except httpx.TransportError:
|
|
521
|
+
delay = _retry_delay(attempt, info) if attempt < prepared.retries else None
|
|
522
|
+
self._notify(prepared, attempt, start, info, delay)
|
|
523
|
+
if delay is None:
|
|
524
|
+
raise errors.ConnectionError(
|
|
525
|
+
"The API connection failed or timed out.", last_response=info
|
|
526
|
+
) from None
|
|
527
|
+
await asyncio.sleep(delay)
|
|
528
|
+
continue
|
|
529
|
+
except errors.UnexpectedResponseError as exc:
|
|
530
|
+
self._notify(prepared, attempt, start, exc.last_response, None)
|
|
531
|
+
raise
|
|
532
|
+
delay = (
|
|
533
|
+
_retry_delay(attempt, info)
|
|
534
|
+
if attempt < prepared.retries and info.status_code in _RETRY_STATUSES
|
|
535
|
+
else None
|
|
536
|
+
)
|
|
537
|
+
self._notify(prepared, attempt, start, info, delay)
|
|
538
|
+
if delay is not None:
|
|
539
|
+
await asyncio.sleep(delay)
|
|
540
|
+
continue
|
|
541
|
+
return self._interpret(prepared, info, asynchronous=True)
|
|
542
|
+
raise AssertionError("Unreachable retry state.")
|
|
543
|
+
|
|
544
|
+
async def close(self) -> None:
|
|
545
|
+
await self._http.aclose()
|
|
546
|
+
|
|
547
|
+
async def aclose(self) -> None:
|
|
548
|
+
await self.close()
|
|
549
|
+
|
|
550
|
+
async def __aenter__(self: _AsyncClientT) -> _AsyncClientT: # noqa: PYI019
|
|
551
|
+
await self._http.__aenter__()
|
|
552
|
+
return self
|
|
553
|
+
|
|
554
|
+
async def __aexit__(
|
|
555
|
+
self,
|
|
556
|
+
exc_type: type[BaseException] | None,
|
|
557
|
+
exc_value: BaseException | None,
|
|
558
|
+
traceback: TracebackType | None,
|
|
559
|
+
) -> None:
|
|
560
|
+
await self._http.__aexit__(exc_type, exc_value, traceback)
|
raims/_errors.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._response import LastResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RaimsError(Exception):
|
|
9
|
+
"""Base for failures reported by the SDK."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
last_response: LastResponse | None = None,
|
|
16
|
+
api_error: dict[str, Any] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.last_response = last_response
|
|
20
|
+
self.api_error = api_error
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def status_code(self) -> int | None:
|
|
24
|
+
return self.last_response.status_code if self.last_response else None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def request_id(self) -> str | None:
|
|
28
|
+
return self.last_response.request_id if self.last_response else None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def code(self) -> str | None:
|
|
32
|
+
value = self.api_error.get("code") if self.api_error else None
|
|
33
|
+
return value if isinstance(value, str) else None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def retry_after(self) -> str | None:
|
|
37
|
+
return self.last_response.retry_after if self.last_response else None
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return f"{type(self).__name__}(status_code={self.status_code!r})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ApiError(RaimsError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthenticationError(ApiError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PermissionError(ApiError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NotFoundError(ApiError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConflictError(ApiError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RateLimitError(ApiError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class InvalidRequestError(ApiError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConnectionError(RaimsError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class UnexpectedResponseError(RaimsError):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SignatureVerificationError(RaimsError):
|
|
80
|
+
pass
|