opensettle 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.
- opensettle/__init__.py +54 -0
- opensettle/_errors.py +255 -0
- opensettle/_http.py +204 -0
- opensettle/_http_async.py +194 -0
- opensettle/_transport.py +242 -0
- opensettle/_version.py +7 -0
- opensettle/_webhooks.py +195 -0
- opensettle/client.py +230 -0
- opensettle/py.typed +0 -0
- opensettle/resources/__init__.py +3 -0
- opensettle/resources/checkouts.py +48 -0
- opensettle/resources/customers.py +88 -0
- opensettle/resources/invoices.py +118 -0
- opensettle/resources/payments.py +100 -0
- opensettle/resources/products.py +120 -0
- opensettle/resources/subscriptions.py +179 -0
- opensettle/resources/webhook_endpoints.py +183 -0
- opensettle/types.py +331 -0
- opensettle-0.1.0.dist-info/METADATA +206 -0
- opensettle-0.1.0.dist-info/RECORD +22 -0
- opensettle-0.1.0.dist-info/WHEEL +4 -0
- opensettle-0.1.0.dist-info/licenses/LICENSE +21 -0
opensettle/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Official Python SDK for the OpenSettle API.
|
|
2
|
+
|
|
3
|
+
Public surface is intentionally narrow: the high-level clients
|
|
4
|
+
(:class:`OpenSettle`, :class:`AsyncOpenSettle`), the typed error
|
|
5
|
+
hierarchy, and the webhook verifier. Everything else is private and may
|
|
6
|
+
change without a major-version bump.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._errors import (
|
|
12
|
+
APIError,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConflictError,
|
|
15
|
+
ErrorCode,
|
|
16
|
+
ForbiddenError,
|
|
17
|
+
InvalidRequestError,
|
|
18
|
+
InvalidStateTransitionError,
|
|
19
|
+
NetworkError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
OpenSettleError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
SettlementError,
|
|
24
|
+
StepUpRequiredError,
|
|
25
|
+
)
|
|
26
|
+
from ._version import SDK_VERSION
|
|
27
|
+
from ._webhooks import (
|
|
28
|
+
VerifiedWebhook,
|
|
29
|
+
WebhookVerificationError,
|
|
30
|
+
verify_webhook,
|
|
31
|
+
)
|
|
32
|
+
from .client import AsyncOpenSettle, OpenSettle
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"SDK_VERSION",
|
|
36
|
+
"APIError",
|
|
37
|
+
"AsyncOpenSettle",
|
|
38
|
+
"AuthenticationError",
|
|
39
|
+
"ConflictError",
|
|
40
|
+
"ErrorCode",
|
|
41
|
+
"ForbiddenError",
|
|
42
|
+
"InvalidRequestError",
|
|
43
|
+
"InvalidStateTransitionError",
|
|
44
|
+
"NetworkError",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"OpenSettle",
|
|
47
|
+
"OpenSettleError",
|
|
48
|
+
"RateLimitError",
|
|
49
|
+
"SettlementError",
|
|
50
|
+
"StepUpRequiredError",
|
|
51
|
+
"VerifiedWebhook",
|
|
52
|
+
"WebhookVerificationError",
|
|
53
|
+
"verify_webhook",
|
|
54
|
+
]
|
opensettle/_errors.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Typed error hierarchy for the OpenSettle SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors the API's stable ``error.code`` taxonomy from
|
|
4
|
+
``@opensettle/shared/errors`` and the Node SDK's ``src/errors.ts``
|
|
5
|
+
character-for-character — same 12 codes, same subclasses, same fields.
|
|
6
|
+
|
|
7
|
+
Catchers can either:
|
|
8
|
+
- check ``isinstance(err, OpenSettleError)`` for the broad case, then
|
|
9
|
+
branch on ``err.code`` to handle specifics, or
|
|
10
|
+
- catch the specific subclass (``InvalidRequestError``, ...) when the
|
|
11
|
+
handler only cares about that family.
|
|
12
|
+
|
|
13
|
+
The base class always carries ``request_id`` so users can quote it in
|
|
14
|
+
support — this is the same ``request_id`` the API echoes back in every
|
|
15
|
+
error envelope.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
try: # pragma: no cover - exercised on Python 3.9/3.10
|
|
23
|
+
from typing import Literal
|
|
24
|
+
except ImportError: # pragma: no cover
|
|
25
|
+
from typing_extensions import Literal
|
|
26
|
+
|
|
27
|
+
ErrorCode = Literal[
|
|
28
|
+
"invalid_request",
|
|
29
|
+
"invalid_state_transition",
|
|
30
|
+
"unauthorized",
|
|
31
|
+
"forbidden",
|
|
32
|
+
"not_found",
|
|
33
|
+
"conflict",
|
|
34
|
+
"rate_limited",
|
|
35
|
+
"internal_error",
|
|
36
|
+
"chain_reverted",
|
|
37
|
+
"insufficient_confirmations",
|
|
38
|
+
"signing_required",
|
|
39
|
+
"aal_required",
|
|
40
|
+
"network_error",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OpenSettleError(Exception):
|
|
45
|
+
"""Base class for every typed error raised by the SDK."""
|
|
46
|
+
|
|
47
|
+
code: ErrorCode
|
|
48
|
+
status: int
|
|
49
|
+
request_id: Optional[str]
|
|
50
|
+
param: Optional[str]
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
message: str,
|
|
55
|
+
*,
|
|
56
|
+
code: ErrorCode,
|
|
57
|
+
status: int,
|
|
58
|
+
request_id: Optional[str] = None,
|
|
59
|
+
param: Optional[str] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.code = code
|
|
63
|
+
self.status = status
|
|
64
|
+
self.request_id = request_id
|
|
65
|
+
self.param = param
|
|
66
|
+
|
|
67
|
+
def __repr__(self) -> str:
|
|
68
|
+
return (
|
|
69
|
+
f"{type(self).__name__}(code={self.code!r}, status={self.status!r}, "
|
|
70
|
+
f"request_id={self.request_id!r}, param={self.param!r}, "
|
|
71
|
+
f"message={self.args[0]!r})"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InvalidRequestError(OpenSettleError):
|
|
76
|
+
"""The request payload failed validation."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class InvalidStateTransitionError(OpenSettleError):
|
|
80
|
+
"""The resource is in a state that does not allow this operation."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AuthenticationError(OpenSettleError):
|
|
84
|
+
"""The API key was missing, malformed, revoked, or unrecognised."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ForbiddenError(OpenSettleError):
|
|
88
|
+
"""The API key authenticated but lacks permission for this route."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NotFoundError(OpenSettleError):
|
|
92
|
+
"""The addressed resource does not exist (or is not visible to this key)."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ConflictError(OpenSettleError):
|
|
96
|
+
"""The request collides with the current resource state (dup, race, FK)."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RateLimitError(OpenSettleError):
|
|
100
|
+
"""The caller exceeded a rate-limit bucket.
|
|
101
|
+
|
|
102
|
+
``retry_after`` is the seconds-to-wait the API advertised in its
|
|
103
|
+
``Retry-After`` header (numeric or HTTP-date), or ``None`` if not
|
|
104
|
+
advertised.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
retry_after: Optional[float]
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
message: str,
|
|
112
|
+
*,
|
|
113
|
+
status: int,
|
|
114
|
+
request_id: Optional[str] = None,
|
|
115
|
+
param: Optional[str] = None,
|
|
116
|
+
retry_after: Optional[float] = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
super().__init__(
|
|
119
|
+
message,
|
|
120
|
+
code="rate_limited",
|
|
121
|
+
status=status,
|
|
122
|
+
request_id=request_id,
|
|
123
|
+
param=param,
|
|
124
|
+
)
|
|
125
|
+
self.retry_after = retry_after
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class SettlementError(OpenSettleError):
|
|
129
|
+
"""A chain-side or signing failure: revert, insufficient confs, missing sig."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class StepUpRequiredError(OpenSettleError):
|
|
133
|
+
"""The route requires step-up auth (AAL=2). Re-authenticate and retry."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class APIError(OpenSettleError):
|
|
137
|
+
"""Catch-all for unknown error codes and ``internal_error`` 5xx responses."""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class NetworkError(OpenSettleError):
|
|
141
|
+
"""Transport-level failure: timeout, DNS, connection refused, etc."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, message: str) -> None:
|
|
144
|
+
super().__init__(
|
|
145
|
+
message,
|
|
146
|
+
code="network_error",
|
|
147
|
+
status=0,
|
|
148
|
+
request_id=None,
|
|
149
|
+
param=None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def from_envelope(
|
|
154
|
+
envelope: Any,
|
|
155
|
+
status: int,
|
|
156
|
+
retry_after: Optional[float],
|
|
157
|
+
) -> OpenSettleError:
|
|
158
|
+
"""Map a parsed API error envelope + HTTP status into the right subclass.
|
|
159
|
+
|
|
160
|
+
Falls back to :class:`APIError` on unknown codes so a new server-side
|
|
161
|
+
code never crashes older clients.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
error = envelope.get("error") if isinstance(envelope, dict) else None
|
|
165
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
166
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
167
|
+
request_id_raw = error.get("request_id") if isinstance(error, dict) else None
|
|
168
|
+
param_raw = error.get("param") if isinstance(error, dict) else None
|
|
169
|
+
|
|
170
|
+
if not isinstance(message, str) or not message:
|
|
171
|
+
message = f"Request failed with status {status}"
|
|
172
|
+
|
|
173
|
+
req_id: Optional[str] = request_id_raw if isinstance(request_id_raw, str) else None
|
|
174
|
+
param: Optional[str] = param_raw if isinstance(param_raw, str) else None
|
|
175
|
+
|
|
176
|
+
if code == "invalid_request":
|
|
177
|
+
return InvalidRequestError(
|
|
178
|
+
message, code="invalid_request", status=status, request_id=req_id, param=param
|
|
179
|
+
)
|
|
180
|
+
if code == "invalid_state_transition":
|
|
181
|
+
return InvalidStateTransitionError(
|
|
182
|
+
message,
|
|
183
|
+
code="invalid_state_transition",
|
|
184
|
+
status=status,
|
|
185
|
+
request_id=req_id,
|
|
186
|
+
param=param,
|
|
187
|
+
)
|
|
188
|
+
if code == "unauthorized":
|
|
189
|
+
return AuthenticationError(
|
|
190
|
+
message, code="unauthorized", status=status, request_id=req_id, param=param
|
|
191
|
+
)
|
|
192
|
+
if code == "forbidden":
|
|
193
|
+
return ForbiddenError(
|
|
194
|
+
message, code="forbidden", status=status, request_id=req_id, param=param
|
|
195
|
+
)
|
|
196
|
+
if code == "not_found":
|
|
197
|
+
return NotFoundError(
|
|
198
|
+
message, code="not_found", status=status, request_id=req_id, param=param
|
|
199
|
+
)
|
|
200
|
+
if code == "conflict":
|
|
201
|
+
return ConflictError(
|
|
202
|
+
message, code="conflict", status=status, request_id=req_id, param=param
|
|
203
|
+
)
|
|
204
|
+
if code == "rate_limited":
|
|
205
|
+
return RateLimitError(
|
|
206
|
+
message,
|
|
207
|
+
status=status,
|
|
208
|
+
request_id=req_id,
|
|
209
|
+
param=param,
|
|
210
|
+
retry_after=retry_after,
|
|
211
|
+
)
|
|
212
|
+
if code == "chain_reverted":
|
|
213
|
+
return SettlementError(
|
|
214
|
+
message, code="chain_reverted", status=status, request_id=req_id, param=param
|
|
215
|
+
)
|
|
216
|
+
if code == "insufficient_confirmations":
|
|
217
|
+
return SettlementError(
|
|
218
|
+
message,
|
|
219
|
+
code="insufficient_confirmations",
|
|
220
|
+
status=status,
|
|
221
|
+
request_id=req_id,
|
|
222
|
+
param=param,
|
|
223
|
+
)
|
|
224
|
+
if code == "signing_required":
|
|
225
|
+
return SettlementError(
|
|
226
|
+
message,
|
|
227
|
+
code="signing_required",
|
|
228
|
+
status=status,
|
|
229
|
+
request_id=req_id,
|
|
230
|
+
param=param,
|
|
231
|
+
)
|
|
232
|
+
if code == "aal_required":
|
|
233
|
+
return StepUpRequiredError(
|
|
234
|
+
message, code="aal_required", status=status, request_id=req_id, param=param
|
|
235
|
+
)
|
|
236
|
+
# internal_error or anything we don't recognise → forward-compat APIError.
|
|
237
|
+
return APIError(message, code="internal_error", status=status, request_id=req_id, param=param)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
__all__ = [
|
|
241
|
+
"APIError",
|
|
242
|
+
"AuthenticationError",
|
|
243
|
+
"ConflictError",
|
|
244
|
+
"ErrorCode",
|
|
245
|
+
"ForbiddenError",
|
|
246
|
+
"InvalidRequestError",
|
|
247
|
+
"InvalidStateTransitionError",
|
|
248
|
+
"NetworkError",
|
|
249
|
+
"NotFoundError",
|
|
250
|
+
"OpenSettleError",
|
|
251
|
+
"RateLimitError",
|
|
252
|
+
"SettlementError",
|
|
253
|
+
"StepUpRequiredError",
|
|
254
|
+
"from_envelope",
|
|
255
|
+
]
|
opensettle/_http.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Synchronous HTTP client built on ``httpx.Client``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, Optional, Union
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
from ._errors import NetworkError, OpenSettleError
|
|
13
|
+
from ._transport import (
|
|
14
|
+
DEFAULT_MAX_NETWORK_RETRIES,
|
|
15
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
16
|
+
assert_api_key_environment,
|
|
17
|
+
build_headers,
|
|
18
|
+
build_raw_url,
|
|
19
|
+
build_url,
|
|
20
|
+
encode_body,
|
|
21
|
+
is_retriable_error,
|
|
22
|
+
map_error,
|
|
23
|
+
normalize_base_url,
|
|
24
|
+
parse_response_body,
|
|
25
|
+
wait_seconds_for_error,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HttpClient:
|
|
30
|
+
"""Thin sync wrapper around ``httpx.Client``.
|
|
31
|
+
|
|
32
|
+
Owns the auth header, workspace URL prefix, idempotency-key
|
|
33
|
+
injection, retries with exponential backoff, and error mapping.
|
|
34
|
+
Resources call :meth:`request` (workspace-scoped) or
|
|
35
|
+
:meth:`request_raw` (everything else, rare).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
api_key: str
|
|
39
|
+
workspace_id: str
|
|
40
|
+
base_url: str
|
|
41
|
+
timeout: float
|
|
42
|
+
max_network_retries: int
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
api_key: str,
|
|
48
|
+
workspace_id: str,
|
|
49
|
+
base_url: Optional[str] = None,
|
|
50
|
+
test_mode: Optional[bool] = None,
|
|
51
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
52
|
+
max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES,
|
|
53
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
54
|
+
_client: Optional[httpx.Client] = None,
|
|
55
|
+
_sleep: Any = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if not api_key:
|
|
58
|
+
raise ValueError("OpenSettle: api_key is required")
|
|
59
|
+
if not workspace_id:
|
|
60
|
+
raise ValueError("OpenSettle: workspace_id is required")
|
|
61
|
+
assert_api_key_environment(api_key, test_mode)
|
|
62
|
+
|
|
63
|
+
self.api_key = api_key
|
|
64
|
+
self.workspace_id = workspace_id
|
|
65
|
+
self.base_url = normalize_base_url(base_url)
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
self.max_network_retries = max_network_retries
|
|
68
|
+
if _client is not None:
|
|
69
|
+
self._client = _client
|
|
70
|
+
self._owns_client = False
|
|
71
|
+
else:
|
|
72
|
+
self._client = httpx.Client(timeout=timeout, transport=transport)
|
|
73
|
+
self._owns_client = True
|
|
74
|
+
self._sleep = _sleep if _sleep is not None else time.sleep
|
|
75
|
+
|
|
76
|
+
# Lifecycle ----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
if self._owns_client:
|
|
80
|
+
self._client.close()
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> Self:
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *_exc: object) -> None:
|
|
86
|
+
self.close()
|
|
87
|
+
|
|
88
|
+
# URL helpers --------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
|
|
91
|
+
return build_url(self.base_url, self.workspace_id, path, query)
|
|
92
|
+
|
|
93
|
+
def raw_url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
|
|
94
|
+
return build_raw_url(self.base_url, path, query)
|
|
95
|
+
|
|
96
|
+
# Request entry points ----------------------------------------------
|
|
97
|
+
|
|
98
|
+
def request(
|
|
99
|
+
self,
|
|
100
|
+
path: str,
|
|
101
|
+
*,
|
|
102
|
+
method: str = "GET",
|
|
103
|
+
body: Any = None,
|
|
104
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
105
|
+
idempotency_key: Union[None, str, bool] = None,
|
|
106
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
107
|
+
timeout: Optional[float] = None,
|
|
108
|
+
max_network_retries: Optional[int] = None,
|
|
109
|
+
) -> Any:
|
|
110
|
+
return self._request_at(
|
|
111
|
+
self.url(path, query),
|
|
112
|
+
method=method,
|
|
113
|
+
body=body,
|
|
114
|
+
idempotency_key=idempotency_key,
|
|
115
|
+
headers=headers,
|
|
116
|
+
timeout=timeout,
|
|
117
|
+
max_network_retries=max_network_retries,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def request_raw(
|
|
121
|
+
self,
|
|
122
|
+
path: str,
|
|
123
|
+
*,
|
|
124
|
+
method: str = "GET",
|
|
125
|
+
body: Any = None,
|
|
126
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
127
|
+
idempotency_key: Union[None, str, bool] = None,
|
|
128
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
129
|
+
timeout: Optional[float] = None,
|
|
130
|
+
max_network_retries: Optional[int] = None,
|
|
131
|
+
) -> Any:
|
|
132
|
+
return self._request_at(
|
|
133
|
+
self.raw_url(path, query),
|
|
134
|
+
method=method,
|
|
135
|
+
body=body,
|
|
136
|
+
idempotency_key=idempotency_key,
|
|
137
|
+
headers=headers,
|
|
138
|
+
timeout=timeout,
|
|
139
|
+
max_network_retries=max_network_retries,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Internal -----------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _request_at(
|
|
145
|
+
self,
|
|
146
|
+
url: str,
|
|
147
|
+
*,
|
|
148
|
+
method: str,
|
|
149
|
+
body: Any,
|
|
150
|
+
idempotency_key: Union[None, str, bool],
|
|
151
|
+
headers: Optional[Mapping[str, str]],
|
|
152
|
+
timeout: Optional[float],
|
|
153
|
+
max_network_retries: Optional[int],
|
|
154
|
+
) -> Any:
|
|
155
|
+
body_text = encode_body(body)
|
|
156
|
+
final_headers = build_headers(
|
|
157
|
+
api_key=self.api_key,
|
|
158
|
+
extra=headers,
|
|
159
|
+
has_body=body_text is not None,
|
|
160
|
+
idempotency_key=idempotency_key,
|
|
161
|
+
)
|
|
162
|
+
request_timeout = timeout if timeout is not None else self.timeout
|
|
163
|
+
budget = (
|
|
164
|
+
max_network_retries if max_network_retries is not None else self.max_network_retries
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
last_error: Optional[OpenSettleError] = None
|
|
168
|
+
for attempt in range(budget + 1):
|
|
169
|
+
try:
|
|
170
|
+
resp = self._client.request(
|
|
171
|
+
method,
|
|
172
|
+
url,
|
|
173
|
+
content=body_text,
|
|
174
|
+
headers=final_headers,
|
|
175
|
+
timeout=request_timeout,
|
|
176
|
+
)
|
|
177
|
+
except httpx.HTTPError as exc:
|
|
178
|
+
err = NetworkError(f"Network error: {exc}")
|
|
179
|
+
if attempt < budget:
|
|
180
|
+
self._sleep(wait_seconds_for_error(err, attempt))
|
|
181
|
+
last_error = err
|
|
182
|
+
continue
|
|
183
|
+
raise err from exc
|
|
184
|
+
|
|
185
|
+
if resp.status_code < 400:
|
|
186
|
+
if resp.status_code == 204 or not resp.content:
|
|
187
|
+
return None
|
|
188
|
+
data, parse_err = parse_response_body(resp.text, resp.status_code)
|
|
189
|
+
if parse_err is not None:
|
|
190
|
+
raise parse_err
|
|
191
|
+
return data
|
|
192
|
+
|
|
193
|
+
api_err = map_error(resp.text, resp.status_code, resp.headers.get("retry-after"))
|
|
194
|
+
if is_retriable_error(api_err) and attempt < budget:
|
|
195
|
+
self._sleep(wait_seconds_for_error(api_err, attempt))
|
|
196
|
+
last_error = api_err
|
|
197
|
+
continue
|
|
198
|
+
raise api_err
|
|
199
|
+
|
|
200
|
+
# Unreachable in practice — the loop either returns or raises.
|
|
201
|
+
raise last_error or RuntimeError("OpenSettle: request loop exited unexpectedly")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = ["HttpClient"]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Asynchronous HTTP client built on ``httpx.AsyncClient``.
|
|
2
|
+
|
|
3
|
+
Mirror of :mod:`opensettle._http`. The two clients share the
|
|
4
|
+
transport core in :mod:`opensettle._transport` — they only differ in
|
|
5
|
+
``await`` and ``asyncio.sleep`` vs ``time.sleep``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any, Optional, Union
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from typing_extensions import Self
|
|
16
|
+
|
|
17
|
+
from ._errors import NetworkError, OpenSettleError
|
|
18
|
+
from ._transport import (
|
|
19
|
+
DEFAULT_MAX_NETWORK_RETRIES,
|
|
20
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
21
|
+
assert_api_key_environment,
|
|
22
|
+
build_headers,
|
|
23
|
+
build_raw_url,
|
|
24
|
+
build_url,
|
|
25
|
+
encode_body,
|
|
26
|
+
is_retriable_error,
|
|
27
|
+
map_error,
|
|
28
|
+
normalize_base_url,
|
|
29
|
+
parse_response_body,
|
|
30
|
+
wait_seconds_for_error,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncHttpClient:
|
|
35
|
+
"""Async sibling of :class:`opensettle._http.HttpClient`."""
|
|
36
|
+
|
|
37
|
+
api_key: str
|
|
38
|
+
workspace_id: str
|
|
39
|
+
base_url: str
|
|
40
|
+
timeout: float
|
|
41
|
+
max_network_retries: int
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
api_key: str,
|
|
47
|
+
workspace_id: str,
|
|
48
|
+
base_url: Optional[str] = None,
|
|
49
|
+
test_mode: Optional[bool] = None,
|
|
50
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
51
|
+
max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES,
|
|
52
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
53
|
+
_client: Optional[httpx.AsyncClient] = None,
|
|
54
|
+
_sleep: Any = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
if not api_key:
|
|
57
|
+
raise ValueError("OpenSettle: api_key is required")
|
|
58
|
+
if not workspace_id:
|
|
59
|
+
raise ValueError("OpenSettle: workspace_id is required")
|
|
60
|
+
assert_api_key_environment(api_key, test_mode)
|
|
61
|
+
|
|
62
|
+
self.api_key = api_key
|
|
63
|
+
self.workspace_id = workspace_id
|
|
64
|
+
self.base_url = normalize_base_url(base_url)
|
|
65
|
+
self.timeout = timeout
|
|
66
|
+
self.max_network_retries = max_network_retries
|
|
67
|
+
if _client is not None:
|
|
68
|
+
self._client = _client
|
|
69
|
+
self._owns_client = False
|
|
70
|
+
else:
|
|
71
|
+
self._client = httpx.AsyncClient(timeout=timeout, transport=transport)
|
|
72
|
+
self._owns_client = True
|
|
73
|
+
self._sleep = _sleep if _sleep is not None else asyncio.sleep
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
if self._owns_client:
|
|
77
|
+
await self._client.aclose()
|
|
78
|
+
|
|
79
|
+
async def __aenter__(self) -> Self:
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
83
|
+
await self.aclose()
|
|
84
|
+
|
|
85
|
+
def url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
|
|
86
|
+
return build_url(self.base_url, self.workspace_id, path, query)
|
|
87
|
+
|
|
88
|
+
def raw_url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
|
|
89
|
+
return build_raw_url(self.base_url, path, query)
|
|
90
|
+
|
|
91
|
+
async def request(
|
|
92
|
+
self,
|
|
93
|
+
path: str,
|
|
94
|
+
*,
|
|
95
|
+
method: str = "GET",
|
|
96
|
+
body: Any = None,
|
|
97
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
98
|
+
idempotency_key: Union[None, str, bool] = None,
|
|
99
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
100
|
+
timeout: Optional[float] = None,
|
|
101
|
+
max_network_retries: Optional[int] = None,
|
|
102
|
+
) -> Any:
|
|
103
|
+
return await self._request_at(
|
|
104
|
+
self.url(path, query),
|
|
105
|
+
method=method,
|
|
106
|
+
body=body,
|
|
107
|
+
idempotency_key=idempotency_key,
|
|
108
|
+
headers=headers,
|
|
109
|
+
timeout=timeout,
|
|
110
|
+
max_network_retries=max_network_retries,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def request_raw(
|
|
114
|
+
self,
|
|
115
|
+
path: str,
|
|
116
|
+
*,
|
|
117
|
+
method: str = "GET",
|
|
118
|
+
body: Any = None,
|
|
119
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
120
|
+
idempotency_key: Union[None, str, bool] = None,
|
|
121
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
122
|
+
timeout: Optional[float] = None,
|
|
123
|
+
max_network_retries: Optional[int] = None,
|
|
124
|
+
) -> Any:
|
|
125
|
+
return await self._request_at(
|
|
126
|
+
self.raw_url(path, query),
|
|
127
|
+
method=method,
|
|
128
|
+
body=body,
|
|
129
|
+
idempotency_key=idempotency_key,
|
|
130
|
+
headers=headers,
|
|
131
|
+
timeout=timeout,
|
|
132
|
+
max_network_retries=max_network_retries,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def _request_at(
|
|
136
|
+
self,
|
|
137
|
+
url: str,
|
|
138
|
+
*,
|
|
139
|
+
method: str,
|
|
140
|
+
body: Any,
|
|
141
|
+
idempotency_key: Union[None, str, bool],
|
|
142
|
+
headers: Optional[Mapping[str, str]],
|
|
143
|
+
timeout: Optional[float],
|
|
144
|
+
max_network_retries: Optional[int],
|
|
145
|
+
) -> Any:
|
|
146
|
+
body_text = encode_body(body)
|
|
147
|
+
final_headers = build_headers(
|
|
148
|
+
api_key=self.api_key,
|
|
149
|
+
extra=headers,
|
|
150
|
+
has_body=body_text is not None,
|
|
151
|
+
idempotency_key=idempotency_key,
|
|
152
|
+
)
|
|
153
|
+
request_timeout = timeout if timeout is not None else self.timeout
|
|
154
|
+
budget = (
|
|
155
|
+
max_network_retries if max_network_retries is not None else self.max_network_retries
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
last_error: Optional[OpenSettleError] = None
|
|
159
|
+
for attempt in range(budget + 1):
|
|
160
|
+
try:
|
|
161
|
+
resp = await self._client.request(
|
|
162
|
+
method,
|
|
163
|
+
url,
|
|
164
|
+
content=body_text,
|
|
165
|
+
headers=final_headers,
|
|
166
|
+
timeout=request_timeout,
|
|
167
|
+
)
|
|
168
|
+
except httpx.HTTPError as exc:
|
|
169
|
+
err = NetworkError(f"Network error: {exc}")
|
|
170
|
+
if attempt < budget:
|
|
171
|
+
await self._sleep(wait_seconds_for_error(err, attempt))
|
|
172
|
+
last_error = err
|
|
173
|
+
continue
|
|
174
|
+
raise err from exc
|
|
175
|
+
|
|
176
|
+
if resp.status_code < 400:
|
|
177
|
+
if resp.status_code == 204 or not resp.content:
|
|
178
|
+
return None
|
|
179
|
+
data, parse_err = parse_response_body(resp.text, resp.status_code)
|
|
180
|
+
if parse_err is not None:
|
|
181
|
+
raise parse_err
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
api_err = map_error(resp.text, resp.status_code, resp.headers.get("retry-after"))
|
|
185
|
+
if is_retriable_error(api_err) and attempt < budget:
|
|
186
|
+
await self._sleep(wait_seconds_for_error(api_err, attempt))
|
|
187
|
+
last_error = api_err
|
|
188
|
+
continue
|
|
189
|
+
raise api_err
|
|
190
|
+
|
|
191
|
+
raise last_error or RuntimeError("OpenSettle: request loop exited unexpectedly")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__ = ["AsyncHttpClient"]
|