payos 1.0.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.
- payos/__init__.py +42 -0
- payos/_async_client.py +563 -0
- payos/_client.py +558 -0
- payos/_core/__init__.py +22 -0
- payos/_core/exceptions.py +169 -0
- payos/_core/models.py +28 -0
- payos/_core/pagination.py +320 -0
- payos/_core/request_options.py +123 -0
- payos/_crypto/__init__.py +5 -0
- payos/_crypto/provider.py +209 -0
- payos/_version.py +2 -0
- payos/resources/__init__.py +12 -0
- payos/resources/_base.py +23 -0
- payos/resources/v1/__init__.py +11 -0
- payos/resources/v1/payouts/__init__.py +5 -0
- payos/resources/v1/payouts/batch/__init__.py +5 -0
- payos/resources/v1/payouts/batch/batch.py +74 -0
- payos/resources/v1/payouts/payouts.py +212 -0
- payos/resources/v1/payouts_account/__init__.py +5 -0
- payos/resources/v1/payouts_account/payouts_account.py +50 -0
- payos/resources/v2/__init__.py +5 -0
- payos/resources/v2/payment_requests/__init__.py +5 -0
- payos/resources/v2/payment_requests/invoices/__init__.py +5 -0
- payos/resources/v2/payment_requests/invoices/invoices.py +91 -0
- payos/resources/v2/payment_requests/payment_requests.py +149 -0
- payos/resources/webhooks/__init__.py +5 -0
- payos/resources/webhooks/webhooks.py +144 -0
- payos/types/__init__.py +36 -0
- payos/types/common.py +12 -0
- payos/types/v1/__init__.py +14 -0
- payos/types/v1/payouts/__init__.py +15 -0
- payos/types/v1/payouts/batch/__init__.py +5 -0
- payos/types/v1/payouts/batch/batch.py +33 -0
- payos/types/v1/payouts/payouts.py +85 -0
- payos/types/v1/payouts_account/__init__.py +1 -0
- payos/types/v1/payouts_account/payouts_account.py +13 -0
- payos/types/v2/__init__.py +15 -0
- payos/types/v2/payment_requests/__init__.py +17 -0
- payos/types/v2/payment_requests/invoices/__init__.py +6 -0
- payos/types/v2/payment_requests/invoices/invoices.py +33 -0
- payos/types/v2/payment_requests/payment_requests.py +100 -0
- payos/types/webhooks/__init__.py +6 -0
- payos/types/webhooks/webhook.py +50 -0
- payos/utils/__init__.py +22 -0
- payos/utils/casting.py +50 -0
- payos/utils/env.py +7 -0
- payos/utils/json_utils.py +57 -0
- payos/utils/logs.py +63 -0
- payos/utils/validation.py +7 -0
- payos-1.0.0.dist-info/METADATA +330 -0
- payos-1.0.0.dist-info/RECORD +52 -0
- payos-1.0.0.dist-info/WHEEL +4 -0
payos/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""payOS Python SDK.
|
|
2
|
+
|
|
3
|
+
A modern Python SDK for the payOS payment platform with sync/async support.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from . import types
|
|
7
|
+
from ._async_client import AsyncPayOS
|
|
8
|
+
from ._client import PayOS
|
|
9
|
+
from ._core.exceptions import (
|
|
10
|
+
APIError,
|
|
11
|
+
BadRequestError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
ConnectionTimeoutError,
|
|
14
|
+
ForbiddenError,
|
|
15
|
+
InternalServerError,
|
|
16
|
+
InvalidSignatureError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
PayOSError,
|
|
19
|
+
TooManyRequestsError,
|
|
20
|
+
UnauthorizedError,
|
|
21
|
+
WebhookError,
|
|
22
|
+
)
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"types",
|
|
27
|
+
"PayOS",
|
|
28
|
+
"AsyncPayOS",
|
|
29
|
+
"PayOSError",
|
|
30
|
+
"APIError",
|
|
31
|
+
"ConnectionError",
|
|
32
|
+
"ConnectionTimeoutError",
|
|
33
|
+
"BadRequestError",
|
|
34
|
+
"UnauthorizedError",
|
|
35
|
+
"ForbiddenError",
|
|
36
|
+
"NotFoundError",
|
|
37
|
+
"TooManyRequestsError",
|
|
38
|
+
"InternalServerError",
|
|
39
|
+
"InvalidSignatureError",
|
|
40
|
+
"WebhookError",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
payos/_async_client.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Asynchronous payOS client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import email.utils
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any, Optional, TypeVar, Union
|
|
12
|
+
from urllib.parse import urlencode, urljoin
|
|
13
|
+
|
|
14
|
+
from typing_extensions import Unpack
|
|
15
|
+
|
|
16
|
+
from ._core import (
|
|
17
|
+
FileDownloadResponse,
|
|
18
|
+
FinalRequestOptions,
|
|
19
|
+
)
|
|
20
|
+
from ._core.exceptions import (
|
|
21
|
+
APIError,
|
|
22
|
+
ConnectionError,
|
|
23
|
+
ConnectionTimeoutError,
|
|
24
|
+
InvalidSignatureError,
|
|
25
|
+
PayOSError,
|
|
26
|
+
)
|
|
27
|
+
from ._core.request_options import RequestOptions
|
|
28
|
+
from ._crypto import CryptoProvider
|
|
29
|
+
from ._version import __version__
|
|
30
|
+
from .utils import (
|
|
31
|
+
cast_to as cast_response_to,
|
|
32
|
+
get_env_var,
|
|
33
|
+
request_to_dict,
|
|
34
|
+
response_to_dict,
|
|
35
|
+
validate_positive_number,
|
|
36
|
+
)
|
|
37
|
+
from .utils.logs import SensitiveHeadersFilter
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import httpx
|
|
41
|
+
except ImportError:
|
|
42
|
+
raise ImportError("The payOS SDK requires httpx. Install it with: pip install httpx") from None
|
|
43
|
+
|
|
44
|
+
from .resources.v1.payouts import AsyncPayouts
|
|
45
|
+
from .resources.v1.payouts_account import AsyncPayoutsAccount
|
|
46
|
+
from .resources.v2.payment_requests import AsyncPaymentRequests
|
|
47
|
+
from .resources.webhooks import AsyncWebhooks
|
|
48
|
+
|
|
49
|
+
T = TypeVar("T")
|
|
50
|
+
ResponseT = TypeVar("ResponseT")
|
|
51
|
+
log: logging.Logger = logging.getLogger(__name__)
|
|
52
|
+
log.addFilter(SensitiveHeadersFilter())
|
|
53
|
+
|
|
54
|
+
DEFAULT_BASE_URL = "https://api-merchant.payos.vn"
|
|
55
|
+
DEFAULT_TIMEOUT = 60.0
|
|
56
|
+
DEFAULT_MAX_RETRIES = 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AsyncPayOS:
|
|
60
|
+
"""Asynchronous payOS API client."""
|
|
61
|
+
|
|
62
|
+
client_id: str
|
|
63
|
+
api_key: str
|
|
64
|
+
checksum_key: str
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
client_id: Optional[str] = None,
|
|
69
|
+
api_key: Optional[str] = None,
|
|
70
|
+
checksum_key: Optional[str] = None,
|
|
71
|
+
partner_code: Optional[str] = None,
|
|
72
|
+
*,
|
|
73
|
+
base_url: Optional[str] = None,
|
|
74
|
+
timeout: Optional[float] = None,
|
|
75
|
+
max_retries: Optional[int] = None,
|
|
76
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Initialize async payOS client.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
client_id: payOS client ID. Defaults to PAYOS_CLIENT_ID env var.
|
|
82
|
+
api_key: payOS API key. Defaults to PAYOS_API_KEY env var.
|
|
83
|
+
checksum_key: payOS checksum key. Defaults to PAYOS_CHECKSUM_KEY env var.
|
|
84
|
+
partner_code: Optional partner code. Defaults to PAYOS_PARTNER_CODE env var.
|
|
85
|
+
base_url: API base URL. Defaults to PAYOS_BASE_URL env var or official URL.
|
|
86
|
+
timeout: Request timeout in seconds. Defaults to 60.
|
|
87
|
+
max_retries: Maximum number of retries. Defaults to 2.
|
|
88
|
+
http_client: Custom httpx.AsyncClient instance.
|
|
89
|
+
"""
|
|
90
|
+
# Required credentials
|
|
91
|
+
if client_id is None:
|
|
92
|
+
client_id = get_env_var("PAYOS_CLIENT_ID")
|
|
93
|
+
if not client_id:
|
|
94
|
+
raise PayOSError(
|
|
95
|
+
"The PAYOS_CLIENT_ID environment variable is missing or empty; "
|
|
96
|
+
"either provide it, or instantiate the payOS client with a client_id option."
|
|
97
|
+
)
|
|
98
|
+
self.client_id = client_id
|
|
99
|
+
|
|
100
|
+
if api_key is None:
|
|
101
|
+
api_key = get_env_var("PAYOS_API_KEY")
|
|
102
|
+
if not api_key:
|
|
103
|
+
raise PayOSError(
|
|
104
|
+
"The PAYOS_API_KEY environment variable is missing or empty; "
|
|
105
|
+
"either provide it, or instantiate the payOS client with an api_key option."
|
|
106
|
+
)
|
|
107
|
+
self.api_key = api_key
|
|
108
|
+
|
|
109
|
+
if checksum_key is None:
|
|
110
|
+
checksum_key = get_env_var("PAYOS_CHECKSUM_KEY")
|
|
111
|
+
if not checksum_key:
|
|
112
|
+
raise PayOSError(
|
|
113
|
+
"The PAYOS_CHECKSUM_KEY environment variable is missing or empty; "
|
|
114
|
+
"either provide it, or instantiate the payOS client with a checksum_key option."
|
|
115
|
+
)
|
|
116
|
+
self.checksum_key = checksum_key
|
|
117
|
+
|
|
118
|
+
# Optional configuration
|
|
119
|
+
self.partner_code = partner_code or get_env_var("PAYOS_PARTNER_CODE")
|
|
120
|
+
self.base_url = base_url or get_env_var("PAYOS_BASE_URL") or DEFAULT_BASE_URL
|
|
121
|
+
self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
|
|
122
|
+
self.max_retries = max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
|
|
123
|
+
|
|
124
|
+
# Validate timeout
|
|
125
|
+
if self.timeout is not None:
|
|
126
|
+
validate_positive_number("timeout", self.timeout)
|
|
127
|
+
|
|
128
|
+
# Set up crypto provider
|
|
129
|
+
self.crypto = CryptoProvider()
|
|
130
|
+
|
|
131
|
+
# Set up HTTP client
|
|
132
|
+
self._http_client = http_client or httpx.AsyncClient()
|
|
133
|
+
self._own_http_client = http_client is None
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def user_agent(self) -> str:
|
|
137
|
+
return f"{self.__class__.__name__}/Python {__version__}"
|
|
138
|
+
|
|
139
|
+
async def aclose(self) -> None:
|
|
140
|
+
"""Close the HTTP client if we own it."""
|
|
141
|
+
if self._own_http_client and self._http_client is not None:
|
|
142
|
+
await self._http_client.aclose()
|
|
143
|
+
|
|
144
|
+
async def __aenter__(self) -> "AsyncPayOS":
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
async def __aexit__(
|
|
148
|
+
self,
|
|
149
|
+
exc_type: Optional[type[BaseException]],
|
|
150
|
+
exc_val: Optional[BaseException],
|
|
151
|
+
exc_tb: Optional[TracebackType],
|
|
152
|
+
) -> None:
|
|
153
|
+
await self.aclose()
|
|
154
|
+
|
|
155
|
+
def _build_headers(self, additional_headers: Optional[dict[str, str]] = None) -> dict[str, str]:
|
|
156
|
+
"""Build headers for API requests."""
|
|
157
|
+
headers = {
|
|
158
|
+
"x-client-id": self.client_id,
|
|
159
|
+
"x-api-key": self.api_key,
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
"User-Agent": self.user_agent,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if self.partner_code:
|
|
165
|
+
headers["x-partner-code"] = self.partner_code
|
|
166
|
+
|
|
167
|
+
if additional_headers:
|
|
168
|
+
headers.update(additional_headers)
|
|
169
|
+
|
|
170
|
+
return headers
|
|
171
|
+
|
|
172
|
+
def _build_url(
|
|
173
|
+
self, path: str, query: Optional[dict[str, Any]] = None, url: Optional[str] = None
|
|
174
|
+
) -> str:
|
|
175
|
+
"""Build full URL from path and query parameters."""
|
|
176
|
+
url = urljoin(self.base_url, path.lstrip("/"))
|
|
177
|
+
|
|
178
|
+
if query:
|
|
179
|
+
# Filter out None values and convert to strings
|
|
180
|
+
filtered_query = {}
|
|
181
|
+
for key, value in query.items():
|
|
182
|
+
if value is not None:
|
|
183
|
+
if isinstance(value, (dict, list)):
|
|
184
|
+
filtered_query[key] = json.dumps(value)
|
|
185
|
+
else:
|
|
186
|
+
filtered_query[key] = str(value)
|
|
187
|
+
|
|
188
|
+
if filtered_query:
|
|
189
|
+
url += "?" + urlencode(filtered_query)
|
|
190
|
+
|
|
191
|
+
return url
|
|
192
|
+
|
|
193
|
+
def _build_body(self, body: Any) -> Optional[Union[str, bytes]]:
|
|
194
|
+
"""Build request body."""
|
|
195
|
+
if body is None:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
if isinstance(body, (str, bytes)):
|
|
199
|
+
return body
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
from ._core.models import PayOSBaseModel
|
|
203
|
+
|
|
204
|
+
if isinstance(body, PayOSBaseModel):
|
|
205
|
+
if hasattr(body, "model_dump_camel_case"):
|
|
206
|
+
body = body.model_dump_camel_case()
|
|
207
|
+
else:
|
|
208
|
+
body = body.model_dump(by_alias=True)
|
|
209
|
+
except ImportError:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
if isinstance(body, dict):
|
|
213
|
+
return json.dumps(body)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
return json.dumps(body)
|
|
217
|
+
except TypeError:
|
|
218
|
+
return str(body)
|
|
219
|
+
|
|
220
|
+
def _build_request(self, options: FinalRequestOptions) -> httpx.Request:
|
|
221
|
+
url = self._build_url(options.path or "", options.query, options.url)
|
|
222
|
+
body = options.body
|
|
223
|
+
if body is not None:
|
|
224
|
+
body = self._handle_signature(options, body)
|
|
225
|
+
headers = self._build_headers(options.headers)
|
|
226
|
+
request_body = self._build_body(body)
|
|
227
|
+
|
|
228
|
+
return self._http_client.build_request(
|
|
229
|
+
url=url,
|
|
230
|
+
method=options.method,
|
|
231
|
+
headers=headers,
|
|
232
|
+
content=request_body,
|
|
233
|
+
timeout=self.timeout if not options.timeout else options.timeout,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _should_retry(self, response: httpx.Response, retry_count: int) -> bool:
|
|
237
|
+
"""Determine if request should be retried."""
|
|
238
|
+
if retry_count >= self.max_retries:
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
if response.status_code in [408, 429] or response.status_code >= 500:
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def _calculate_retry_delay(self, retry_count: int, response_headers: httpx.Headers) -> float:
|
|
247
|
+
"""Calculate delay before retry."""
|
|
248
|
+
|
|
249
|
+
# Check for Retry-After header
|
|
250
|
+
# About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
|
251
|
+
timeout_ms: Union[float, None] = None
|
|
252
|
+
retry_after = response_headers.get("retry-after")
|
|
253
|
+
rate_limit_reset = response_headers.get("x-ratelimit-reset")
|
|
254
|
+
if retry_after:
|
|
255
|
+
try:
|
|
256
|
+
timeout_ms = float(retry_after)
|
|
257
|
+
except ValueError:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
retry_date_tuple = email.utils.parsedate_tz(retry_after)
|
|
261
|
+
if retry_date_tuple is not None:
|
|
262
|
+
retry_date = email.utils.mktime_tz(retry_date_tuple)
|
|
263
|
+
timeout_ms = float(retry_date) - time.time()
|
|
264
|
+
|
|
265
|
+
if rate_limit_reset:
|
|
266
|
+
timeout_ms = float(rate_limit_reset) - time.time()
|
|
267
|
+
|
|
268
|
+
if timeout_ms is not None and 0 < timeout_ms <= 60:
|
|
269
|
+
return timeout_ms
|
|
270
|
+
|
|
271
|
+
# Exponential backoff with jitter
|
|
272
|
+
base_delay = 0.5
|
|
273
|
+
max_delay = 10.0
|
|
274
|
+
delay: float = min(base_delay * (2**retry_count), max_delay)
|
|
275
|
+
|
|
276
|
+
# Add jitter (random factor between 0.75 and 1.0)
|
|
277
|
+
|
|
278
|
+
jitter = 0.75 + random.random() * 0.25
|
|
279
|
+
|
|
280
|
+
return delay * jitter
|
|
281
|
+
|
|
282
|
+
def _handle_signature(self, options: FinalRequestOptions, body: Any) -> Any:
|
|
283
|
+
"""Handle request signature generation."""
|
|
284
|
+
if not options.signature_request or not body:
|
|
285
|
+
return body
|
|
286
|
+
|
|
287
|
+
original_body = body
|
|
288
|
+
try:
|
|
289
|
+
from ._core.models import PayOSBaseModel
|
|
290
|
+
|
|
291
|
+
if isinstance(body, PayOSBaseModel):
|
|
292
|
+
if hasattr(body, "model_dump_camel_case"):
|
|
293
|
+
body_dict = body.model_dump_camel_case()
|
|
294
|
+
else:
|
|
295
|
+
body_dict = body.model_dump(by_alias=True)
|
|
296
|
+
elif isinstance(body, str):
|
|
297
|
+
try:
|
|
298
|
+
body_dict = json.loads(body)
|
|
299
|
+
except json.JSONDecodeError:
|
|
300
|
+
raise InvalidSignatureError(
|
|
301
|
+
"Invalid JSON body for signature generation"
|
|
302
|
+
) from None
|
|
303
|
+
else:
|
|
304
|
+
body_dict = body
|
|
305
|
+
except ImportError:
|
|
306
|
+
if isinstance(body, str):
|
|
307
|
+
try:
|
|
308
|
+
body_dict = json.loads(body)
|
|
309
|
+
except json.JSONDecodeError:
|
|
310
|
+
raise InvalidSignatureError(
|
|
311
|
+
"Invalid JSON body for signature generation"
|
|
312
|
+
) from None
|
|
313
|
+
else:
|
|
314
|
+
body_dict = body
|
|
315
|
+
|
|
316
|
+
if not isinstance(body_dict, dict):
|
|
317
|
+
raise InvalidSignatureError("Body must be a dictionary for signature generation")
|
|
318
|
+
|
|
319
|
+
signature = None
|
|
320
|
+
if options.signature_request == "create-payment-link":
|
|
321
|
+
signature = self.crypto.create_signature_of_payment_request(
|
|
322
|
+
body_dict,
|
|
323
|
+
self.checksum_key,
|
|
324
|
+
)
|
|
325
|
+
elif options.signature_request == "body":
|
|
326
|
+
signature = self.crypto.create_signature_from_object(body_dict, self.checksum_key)
|
|
327
|
+
elif options.signature_request == "header":
|
|
328
|
+
signature = self.crypto.create_signature(self.checksum_key, body_dict)
|
|
329
|
+
|
|
330
|
+
if not signature:
|
|
331
|
+
raise InvalidSignatureError("Failed to generate signature")
|
|
332
|
+
|
|
333
|
+
if options.signature_request in ["create-payment-link", "body"]:
|
|
334
|
+
body_dict["signature"] = signature
|
|
335
|
+
return body_dict
|
|
336
|
+
else: # header
|
|
337
|
+
options.headers = options.headers or {}
|
|
338
|
+
options.headers["x-signature"] = signature
|
|
339
|
+
return original_body
|
|
340
|
+
|
|
341
|
+
def _verify_response_signature(
|
|
342
|
+
self, options: FinalRequestOptions, response_data: Any, response_signature: Optional[str]
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Verify response signature."""
|
|
345
|
+
if not options.signature_response or not response_data:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
if not response_signature:
|
|
349
|
+
raise InvalidSignatureError("Response signature missing")
|
|
350
|
+
|
|
351
|
+
if options.signature_response == "body":
|
|
352
|
+
expected_signature = self.crypto.create_signature_from_object(
|
|
353
|
+
response_data,
|
|
354
|
+
self.checksum_key,
|
|
355
|
+
)
|
|
356
|
+
elif options.signature_response == "header":
|
|
357
|
+
expected_signature = self.crypto.create_signature(self.checksum_key, response_data)
|
|
358
|
+
else:
|
|
359
|
+
raise InvalidSignatureError("Invalid signature response type")
|
|
360
|
+
|
|
361
|
+
if not expected_signature or response_signature != expected_signature:
|
|
362
|
+
raise InvalidSignatureError("Response signature verification failed")
|
|
363
|
+
|
|
364
|
+
async def request(
|
|
365
|
+
self,
|
|
366
|
+
options: FinalRequestOptions,
|
|
367
|
+
*,
|
|
368
|
+
retry_count: int = 0,
|
|
369
|
+
cast_to: type[ResponseT],
|
|
370
|
+
) -> ResponseT:
|
|
371
|
+
"""Make HTTP request with retry logic."""
|
|
372
|
+
max_retries = options.max_retries if options.max_retries is not None else self.max_retries
|
|
373
|
+
request = self._build_request(options=options)
|
|
374
|
+
log.debug("Request options: %s", request_to_dict(request))
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
response = await self._http_client.send(request=request)
|
|
378
|
+
|
|
379
|
+
if not response.is_success:
|
|
380
|
+
should_retry = self._should_retry(response, retry_count)
|
|
381
|
+
|
|
382
|
+
error_data = None
|
|
383
|
+
try:
|
|
384
|
+
error_json = response.json()
|
|
385
|
+
error_data = error_json
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
if should_retry:
|
|
390
|
+
delay = self._calculate_retry_delay(retry_count, response.headers)
|
|
391
|
+
log.info("Retrying request to %s in %f seconds", request.url, delay)
|
|
392
|
+
await asyncio.sleep(delay)
|
|
393
|
+
return await self.request(
|
|
394
|
+
options,
|
|
395
|
+
cast_to=cast_to,
|
|
396
|
+
retry_count=retry_count + 1,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
raise APIError.from_response(response, error_data=error_data) from None
|
|
400
|
+
|
|
401
|
+
log.debug("HTTP Response: %s", response_to_dict(response=response))
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
response_json = response.json()
|
|
405
|
+
except Exception:
|
|
406
|
+
raise APIError(
|
|
407
|
+
f"Invalid JSON response: {response.text[:200]}",
|
|
408
|
+
status_code=response.status_code,
|
|
409
|
+
response=response,
|
|
410
|
+
) from None
|
|
411
|
+
|
|
412
|
+
code = response_json.get("code")
|
|
413
|
+
desc = response_json.get("desc")
|
|
414
|
+
data = response_json.get("data")
|
|
415
|
+
signature = response_json.get("signature")
|
|
416
|
+
|
|
417
|
+
if code != "00":
|
|
418
|
+
raise APIError(
|
|
419
|
+
desc or "API error",
|
|
420
|
+
error_code=code,
|
|
421
|
+
error_desc=desc,
|
|
422
|
+
status_code=response.status_code,
|
|
423
|
+
response=response,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if options.signature_response == "body":
|
|
427
|
+
self._verify_response_signature(options, data, signature)
|
|
428
|
+
elif options.signature_response == "header":
|
|
429
|
+
header_signature = response.headers.get("x-signature")
|
|
430
|
+
self._verify_response_signature(options, data, header_signature)
|
|
431
|
+
|
|
432
|
+
return cast_response_to(cast_to, data)
|
|
433
|
+
|
|
434
|
+
except httpx.TimeoutException:
|
|
435
|
+
log.debug("Encountered httpx.TimeoutException", exc_info=True)
|
|
436
|
+
if retry_count < max_retries:
|
|
437
|
+
delay = self._calculate_retry_delay(retry_count, httpx.Headers())
|
|
438
|
+
log.info("Retrying request to %s in %f seconds", request.url, delay)
|
|
439
|
+
await asyncio.sleep(delay)
|
|
440
|
+
return await self.request(
|
|
441
|
+
options,
|
|
442
|
+
cast_to=cast_to,
|
|
443
|
+
retry_count=retry_count + 1,
|
|
444
|
+
)
|
|
445
|
+
raise ConnectionTimeoutError("Request timed out") from None
|
|
446
|
+
|
|
447
|
+
except httpx.ConnectError:
|
|
448
|
+
log.debug("Encountered Exception", exc_info=True)
|
|
449
|
+
if retry_count < max_retries:
|
|
450
|
+
delay = self._calculate_retry_delay(retry_count, httpx.Headers())
|
|
451
|
+
log.info("Retrying request to %s in %f seconds", request.url, delay)
|
|
452
|
+
await asyncio.sleep(delay)
|
|
453
|
+
return await self.request(
|
|
454
|
+
options,
|
|
455
|
+
cast_to=cast_to,
|
|
456
|
+
retry_count=retry_count + 1,
|
|
457
|
+
)
|
|
458
|
+
raise ConnectionError("Failed to connect") from None
|
|
459
|
+
|
|
460
|
+
except Exception:
|
|
461
|
+
raise
|
|
462
|
+
|
|
463
|
+
async def get(
|
|
464
|
+
self, path: str, *, cast_to: type[ResponseT], **kwargs: Unpack[RequestOptions]
|
|
465
|
+
) -> ResponseT:
|
|
466
|
+
"""Make GET request."""
|
|
467
|
+
options = FinalRequestOptions(method="GET", path=path, **kwargs)
|
|
468
|
+
return await self.request(options, cast_to=cast_to)
|
|
469
|
+
|
|
470
|
+
async def post(
|
|
471
|
+
self, path: str, *, cast_to: type[ResponseT], **kwargs: Unpack[RequestOptions]
|
|
472
|
+
) -> ResponseT:
|
|
473
|
+
"""Make POST request."""
|
|
474
|
+
options = FinalRequestOptions(method="POST", path=path, **kwargs)
|
|
475
|
+
return await self.request(options, cast_to=cast_to)
|
|
476
|
+
|
|
477
|
+
async def put(
|
|
478
|
+
self, path: str, *, cast_to: type[ResponseT], **kwargs: Unpack[RequestOptions]
|
|
479
|
+
) -> ResponseT:
|
|
480
|
+
"""Make PUT request."""
|
|
481
|
+
options = FinalRequestOptions(method="PUT", path=path, **kwargs)
|
|
482
|
+
return await self.request(options, cast_to=cast_to)
|
|
483
|
+
|
|
484
|
+
async def patch(
|
|
485
|
+
self, path: str, *, cast_to: type[ResponseT], **kwargs: Unpack[RequestOptions]
|
|
486
|
+
) -> ResponseT:
|
|
487
|
+
"""Make PATCH request."""
|
|
488
|
+
options = FinalRequestOptions(method="PATCH", path=path, **kwargs)
|
|
489
|
+
return await self.request(options, cast_to=cast_to)
|
|
490
|
+
|
|
491
|
+
async def delete(
|
|
492
|
+
self, path: str, *, cast_to: type[ResponseT], **kwargs: Unpack[RequestOptions]
|
|
493
|
+
) -> ResponseT:
|
|
494
|
+
"""Make DELETE request."""
|
|
495
|
+
options = FinalRequestOptions(method="DELETE", path=path, **kwargs)
|
|
496
|
+
return await self.request(options, cast_to=cast_to)
|
|
497
|
+
|
|
498
|
+
async def download(self, path: str, **kwargs: Unpack[RequestOptions]) -> FileDownloadResponse:
|
|
499
|
+
"""Download a file from the API."""
|
|
500
|
+
from ._core import FileDownloadResponse
|
|
501
|
+
|
|
502
|
+
options = FinalRequestOptions(method="GET", path=path, **kwargs)
|
|
503
|
+
request = self._build_request(options)
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
response = await self._http_client.send(request=request)
|
|
507
|
+
if not response.is_success:
|
|
508
|
+
error_data = None
|
|
509
|
+
try:
|
|
510
|
+
error_json = response.json()
|
|
511
|
+
error_data = error_json
|
|
512
|
+
except Exception:
|
|
513
|
+
pass
|
|
514
|
+
raise APIError.from_response(response, error_data=error_data)
|
|
515
|
+
|
|
516
|
+
filename = None
|
|
517
|
+
content_disposition = response.headers.get("content-disposition")
|
|
518
|
+
if content_disposition:
|
|
519
|
+
import re
|
|
520
|
+
|
|
521
|
+
match = re.search(r'filename="([^"]+)"', content_disposition)
|
|
522
|
+
if match:
|
|
523
|
+
filename = match.group(1)
|
|
524
|
+
|
|
525
|
+
if not filename:
|
|
526
|
+
from urllib.parse import urlparse
|
|
527
|
+
|
|
528
|
+
parsed_url = urlparse(str(request.url))
|
|
529
|
+
filename = parsed_url.path.split("/")[-1] if parsed_url.path else None
|
|
530
|
+
|
|
531
|
+
content_size = len(response.content)
|
|
532
|
+
return FileDownloadResponse(
|
|
533
|
+
data=response.content,
|
|
534
|
+
filename=filename,
|
|
535
|
+
content_type=response.headers.get("content-type"),
|
|
536
|
+
size=content_size,
|
|
537
|
+
headers=dict(response.headers),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
except httpx.TimeoutException:
|
|
541
|
+
raise ConnectionTimeoutError("Download request timed out") from None
|
|
542
|
+
|
|
543
|
+
except httpx.ConnectError:
|
|
544
|
+
raise ConnectionError("Failed to connect for download") from None
|
|
545
|
+
|
|
546
|
+
except Exception:
|
|
547
|
+
raise
|
|
548
|
+
|
|
549
|
+
@cached_property
|
|
550
|
+
def payment_requests(self) -> AsyncPaymentRequests:
|
|
551
|
+
return AsyncPaymentRequests(self)
|
|
552
|
+
|
|
553
|
+
@cached_property
|
|
554
|
+
def webhooks(self) -> AsyncWebhooks:
|
|
555
|
+
return AsyncWebhooks(self)
|
|
556
|
+
|
|
557
|
+
@cached_property
|
|
558
|
+
def payouts(self) -> AsyncPayouts:
|
|
559
|
+
return AsyncPayouts(self)
|
|
560
|
+
|
|
561
|
+
@cached_property
|
|
562
|
+
def payouts_account(self) -> AsyncPayoutsAccount:
|
|
563
|
+
return AsyncPayoutsAccount(self)
|