airwallex-sdk 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.
- airwallex/__init__.py +74 -0
- airwallex/api/__init__.py +37 -0
- airwallex/api/account.py +107 -0
- airwallex/api/account_detail.py +469 -0
- airwallex/api/base.py +488 -0
- airwallex/api/beneficiary.py +156 -0
- airwallex/api/financial_transaction.py +123 -0
- airwallex/api/invoice.py +257 -0
- airwallex/api/issuing_authorization.py +313 -0
- airwallex/api/issuing_card.py +411 -0
- airwallex/api/issuing_cardholder.py +234 -0
- airwallex/api/issuing_config.py +80 -0
- airwallex/api/issuing_digital_wallet_token.py +249 -0
- airwallex/api/issuing_transaction.py +231 -0
- airwallex/api/issuing_transaction_dispute.py +339 -0
- airwallex/api/payment.py +148 -0
- airwallex/client.py +396 -0
- airwallex/exceptions.py +222 -0
- airwallex/models/__init__.py +69 -0
- airwallex/models/account.py +51 -0
- airwallex/models/account_detail.py +259 -0
- airwallex/models/base.py +121 -0
- airwallex/models/beneficiary.py +70 -0
- airwallex/models/financial_transaction.py +30 -0
- airwallex/models/fx.py +58 -0
- airwallex/models/invoice.py +102 -0
- airwallex/models/issuing_authorization.py +41 -0
- airwallex/models/issuing_card.py +135 -0
- airwallex/models/issuing_cardholder.py +52 -0
- airwallex/models/issuing_common.py +83 -0
- airwallex/models/issuing_config.py +62 -0
- airwallex/models/issuing_digital_wallet_token.py +38 -0
- airwallex/models/issuing_transaction.py +42 -0
- airwallex/models/issuing_transaction_dispute.py +59 -0
- airwallex/models/payment.py +81 -0
- airwallex/utils.py +107 -0
- airwallex_sdk-0.1.0.dist-info/METADATA +202 -0
- airwallex_sdk-0.1.0.dist-info/RECORD +39 -0
- airwallex_sdk-0.1.0.dist-info/WHEEL +4 -0
airwallex/client.py
ADDED
@@ -0,0 +1,396 @@
|
|
1
|
+
"""
|
2
|
+
Client for interacting with the Airwallex API.
|
3
|
+
"""
|
4
|
+
import asyncio
|
5
|
+
import logging
|
6
|
+
import time
|
7
|
+
import httpx
|
8
|
+
import json
|
9
|
+
from datetime import datetime, timedelta, timezone, date
|
10
|
+
from typing import Any, Dict, List, Optional, Union, Type, TypeVar, cast
|
11
|
+
from importlib import import_module
|
12
|
+
|
13
|
+
from .utils import snake_to_pascal_case
|
14
|
+
from .exceptions import create_exception_from_response, AuthenticationError
|
15
|
+
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
|
18
|
+
DEFAULT_BASE_URL = 'https://api.airwallex.com/'
|
19
|
+
DEFAULT_AUTH_URL = 'https://api.airwallex.com/api/v1/authentication/login'
|
20
|
+
|
21
|
+
T = TypeVar("T")
|
22
|
+
|
23
|
+
|
24
|
+
class AirwallexClient:
|
25
|
+
"""
|
26
|
+
Client for interacting with the Airwallex API.
|
27
|
+
|
28
|
+
This client handles authentication, rate limiting, and provides
|
29
|
+
access to all API endpoints through dynamic attribute access.
|
30
|
+
"""
|
31
|
+
def __init__(
|
32
|
+
self,
|
33
|
+
*,
|
34
|
+
client_id: str,
|
35
|
+
api_key: str,
|
36
|
+
base_url: str = DEFAULT_BASE_URL,
|
37
|
+
auth_url: str = DEFAULT_AUTH_URL,
|
38
|
+
request_timeout: int = 60,
|
39
|
+
on_behalf_of: Optional[str] = None
|
40
|
+
):
|
41
|
+
if not client_id or not api_key:
|
42
|
+
raise ValueError("Client ID and API key are required")
|
43
|
+
|
44
|
+
self.client_id = client_id
|
45
|
+
self.api_key = api_key
|
46
|
+
self.base_url = base_url
|
47
|
+
self.auth_url = auth_url
|
48
|
+
self.request_timeout = request_timeout
|
49
|
+
self.on_behalf_of = on_behalf_of
|
50
|
+
|
51
|
+
# Authentication state
|
52
|
+
self._token: Optional[str] = None
|
53
|
+
self._token_expiry: Optional[datetime] = None
|
54
|
+
|
55
|
+
# Create persistent httpx client
|
56
|
+
self._client = httpx.Client(
|
57
|
+
base_url=self.base_url,
|
58
|
+
timeout=self.request_timeout,
|
59
|
+
)
|
60
|
+
|
61
|
+
# Cache for API instances
|
62
|
+
self._api_instances: Dict[str, Any] = {}
|
63
|
+
|
64
|
+
@property
|
65
|
+
def headers(self) -> Dict[str, str]:
|
66
|
+
"""Default headers to use for all requests."""
|
67
|
+
headers = {
|
68
|
+
"Content-Type": "application/json",
|
69
|
+
"Accept": "application/json",
|
70
|
+
}
|
71
|
+
|
72
|
+
# Add authentication token if available
|
73
|
+
if self._token:
|
74
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
75
|
+
|
76
|
+
# Add on-behalf-of header if specified
|
77
|
+
if self.on_behalf_of:
|
78
|
+
headers["x-on-behalf-of"] = self.on_behalf_of
|
79
|
+
|
80
|
+
return headers
|
81
|
+
|
82
|
+
@staticmethod
|
83
|
+
def _prepare_params(params: Dict[str, Any]) -> Dict[str, str]:
|
84
|
+
"""Convert parameters to string format for URL encoding.
|
85
|
+
|
86
|
+
datetime objects are formatted as ISO8601 strings.
|
87
|
+
Lists are joined by commas.
|
88
|
+
All other types are converted to strings.
|
89
|
+
|
90
|
+
Args:
|
91
|
+
params (Dict[str, Any]): _description_
|
92
|
+
|
93
|
+
Returns:
|
94
|
+
Dict[str, str]: _description_
|
95
|
+
"""
|
96
|
+
prepared_params = {}
|
97
|
+
for key, value in params.items():
|
98
|
+
if isinstance(value, (date, datetime)):
|
99
|
+
prepared_params[key] = value.isoformat()
|
100
|
+
elif isinstance(value, list):
|
101
|
+
prepared_params[key] = ",".join(map(str, value))
|
102
|
+
else:
|
103
|
+
prepared_params[key] = str(value)
|
104
|
+
return prepared_params
|
105
|
+
|
106
|
+
def _prepare_request(self, **kwargs) -> Dict[str, Any]:
|
107
|
+
"""Merge default headers and allow caller overrides."""
|
108
|
+
headers = kwargs.pop('headers', {})
|
109
|
+
params = kwargs.pop('params', {})
|
110
|
+
kwargs['headers'] = {**self.headers, **headers}
|
111
|
+
kwargs['params'] = self._prepare_params(params)
|
112
|
+
return kwargs
|
113
|
+
|
114
|
+
def authenticate(self) -> None:
|
115
|
+
"""
|
116
|
+
Authenticate with the Airwallex API and get an access token.
|
117
|
+
|
118
|
+
Airwallex auth requires sending the API key and client ID in headers
|
119
|
+
and returns a token valid for 30 minutes.
|
120
|
+
"""
|
121
|
+
# Return early if we already have a valid token
|
122
|
+
if self._token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
|
123
|
+
return
|
124
|
+
|
125
|
+
# Use a separate client for authentication to avoid base_url issues
|
126
|
+
auth_client = httpx.Client(timeout=self.request_timeout)
|
127
|
+
try:
|
128
|
+
# Airwallex requires x-client-id and x-api-key in the headers, not in the body
|
129
|
+
response = auth_client.post(
|
130
|
+
self.auth_url,
|
131
|
+
headers={
|
132
|
+
"Content-Type": "application/json",
|
133
|
+
"x-client-id": self.client_id,
|
134
|
+
"x-api-key": self.api_key
|
135
|
+
}
|
136
|
+
)
|
137
|
+
|
138
|
+
if response.status_code != 201: # Airwallex returns 201 for successful auth
|
139
|
+
raise AuthenticationError(
|
140
|
+
status_code=response.status_code,
|
141
|
+
response=response,
|
142
|
+
method="POST",
|
143
|
+
url=self.auth_url,
|
144
|
+
kwargs={"headers": {"x-client-id": self.client_id, "x-api-key": "**redacted**"}},
|
145
|
+
message="Authentication failed"
|
146
|
+
)
|
147
|
+
|
148
|
+
auth_data = response.json()
|
149
|
+
self._token = auth_data.get("token")
|
150
|
+
|
151
|
+
# Set token expiry based on expires_at if provided, or default to 30 minutes
|
152
|
+
if "expires_at" in auth_data:
|
153
|
+
# Parse ISO8601 format date
|
154
|
+
self._token_expiry = datetime.fromisoformat(auth_data["expires_at"].replace("Z", "+00:00"))
|
155
|
+
else:
|
156
|
+
# Default to 30 minutes if no expires_at provided
|
157
|
+
self._token_expiry = datetime.now(timezone.utc) + timedelta(minutes=30)
|
158
|
+
|
159
|
+
logger.debug("Successfully authenticated with Airwallex API")
|
160
|
+
|
161
|
+
finally:
|
162
|
+
auth_client.close()
|
163
|
+
|
164
|
+
def _request(self, method: str, url: str, **kwargs) -> Optional[httpx.Response]:
|
165
|
+
"""
|
166
|
+
Make a synchronous HTTP request with automatic authentication.
|
167
|
+
|
168
|
+
Args:
|
169
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
170
|
+
url: API endpoint URL (relative to base_url)
|
171
|
+
**kwargs: Additional arguments to pass to httpx.request()
|
172
|
+
|
173
|
+
Returns:
|
174
|
+
httpx.Response: The HTTP response
|
175
|
+
|
176
|
+
Raises:
|
177
|
+
AirwallexAPIError: For API errors
|
178
|
+
"""
|
179
|
+
# Ensure we're authenticated before making a request
|
180
|
+
self.authenticate()
|
181
|
+
|
182
|
+
retries = 5
|
183
|
+
kwargs = self._prepare_request(**kwargs)
|
184
|
+
|
185
|
+
while retries > 0:
|
186
|
+
response = self._client.request(method, url, **kwargs)
|
187
|
+
|
188
|
+
# Handle successful responses
|
189
|
+
if 200 <= response.status_code < 300:
|
190
|
+
return response
|
191
|
+
|
192
|
+
# Handle authentication errors
|
193
|
+
if response.status_code == 401:
|
194
|
+
# Token might be expired, force refresh and retry
|
195
|
+
self._token = None
|
196
|
+
self._token_expiry = None
|
197
|
+
self.authenticate()
|
198
|
+
kwargs['headers'].update({"Authorization": f"Bearer {self._token}"})
|
199
|
+
retries -= 1
|
200
|
+
continue
|
201
|
+
|
202
|
+
# Handle rate limiting
|
203
|
+
if response.status_code == 429:
|
204
|
+
retry_after = response.headers.get('Retry-After')
|
205
|
+
if retry_after and retry_after.isdigit():
|
206
|
+
wait_time = int(retry_after)
|
207
|
+
logger.info(f"Rate limited, sleeping for {wait_time} seconds")
|
208
|
+
time.sleep(wait_time)
|
209
|
+
continue
|
210
|
+
else:
|
211
|
+
# Default backoff: 1 second
|
212
|
+
time.sleep(1)
|
213
|
+
continue
|
214
|
+
|
215
|
+
# Retry on server errors (HTTP 5xx)
|
216
|
+
if response.status_code >= 500 and retries > 0:
|
217
|
+
retries -= 1
|
218
|
+
logger.warning(f"Server error ({response.status_code}), retrying {retries} more time(s)...")
|
219
|
+
time.sleep(1)
|
220
|
+
continue
|
221
|
+
|
222
|
+
# Create and raise the appropriate exception based on the response
|
223
|
+
raise create_exception_from_response(
|
224
|
+
response=response,
|
225
|
+
method=method,
|
226
|
+
url=url,
|
227
|
+
kwargs=kwargs
|
228
|
+
)
|
229
|
+
|
230
|
+
def __getattr__(self, item: str) -> Any:
|
231
|
+
"""
|
232
|
+
Dynamically load an API wrapper from the `api` subpackage.
|
233
|
+
For example, accessing `client.account` will load the Account API wrapper.
|
234
|
+
"""
|
235
|
+
# Check cache first
|
236
|
+
if item in self._api_instances:
|
237
|
+
return self._api_instances[item]
|
238
|
+
|
239
|
+
try:
|
240
|
+
base_package = self.__class__.__module__.split(".")[0]
|
241
|
+
module = import_module(f"{base_package}.api.{item.lower()}")
|
242
|
+
# Expect the API class to have the same name but capitalized.
|
243
|
+
api_class = getattr(module, snake_to_pascal_case(item))
|
244
|
+
api_instance = api_class(client=self)
|
245
|
+
|
246
|
+
# Cache the instance
|
247
|
+
self._api_instances[item] = api_instance
|
248
|
+
return api_instance
|
249
|
+
except (ModuleNotFoundError, AttributeError) as e:
|
250
|
+
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}'") from e
|
251
|
+
|
252
|
+
def close(self) -> None:
|
253
|
+
"""Close the HTTP client."""
|
254
|
+
self._client.close()
|
255
|
+
|
256
|
+
def __enter__(self) -> "AirwallexClient":
|
257
|
+
return self
|
258
|
+
|
259
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
260
|
+
self.close()
|
261
|
+
|
262
|
+
|
263
|
+
class AirwallexAsyncClient(AirwallexClient):
|
264
|
+
"""
|
265
|
+
Asynchronous client for interacting with the Airwallex API.
|
266
|
+
"""
|
267
|
+
def __init__(self, **kwargs):
|
268
|
+
super().__init__(**kwargs)
|
269
|
+
|
270
|
+
# Replace the HTTP client with an async one
|
271
|
+
self._client = httpx.AsyncClient(
|
272
|
+
base_url=self.base_url,
|
273
|
+
timeout=self.request_timeout,
|
274
|
+
)
|
275
|
+
|
276
|
+
async def authenticate(self) -> None:
|
277
|
+
"""
|
278
|
+
Authenticate with the Airwallex API and get an access token.
|
279
|
+
|
280
|
+
Airwallex auth requires sending the API key and client ID in headers
|
281
|
+
and returns a token valid for 30 minutes.
|
282
|
+
"""
|
283
|
+
# Return early if we already have a valid token
|
284
|
+
if self._token and self._token_expiry and datetime.now() < self._token_expiry:
|
285
|
+
return
|
286
|
+
|
287
|
+
# Use a separate client for authentication to avoid base_url issues
|
288
|
+
async with httpx.AsyncClient(timeout=self.request_timeout) as auth_client:
|
289
|
+
# Airwallex requires x-client-id and x-api-key in the headers, not in the body
|
290
|
+
response = await auth_client.post(
|
291
|
+
self.auth_url,
|
292
|
+
headers={
|
293
|
+
"Content-Type": "application/json",
|
294
|
+
"x-client-id": self.client_id,
|
295
|
+
"x-api-key": self.api_key
|
296
|
+
}
|
297
|
+
)
|
298
|
+
|
299
|
+
if response.status_code != 201: # Airwallex returns 201 for successful auth
|
300
|
+
raise AuthenticationError(
|
301
|
+
status_code=response.status_code,
|
302
|
+
response=response,
|
303
|
+
method="POST",
|
304
|
+
url=self.auth_url,
|
305
|
+
kwargs={"headers": {"x-client-id": self.client_id, "x-api-key": "**redacted**"}},
|
306
|
+
message="Authentication failed"
|
307
|
+
)
|
308
|
+
|
309
|
+
auth_data = response.json()
|
310
|
+
self._token = auth_data.get("token")
|
311
|
+
|
312
|
+
# Set token expiry based on expires_at if provided, or default to 30 minutes
|
313
|
+
if "expires_at" in auth_data:
|
314
|
+
# Parse ISO8601 format date
|
315
|
+
self._token_expiry = datetime.fromisoformat(auth_data["expires_at"].replace("Z", "+00:00"))
|
316
|
+
else:
|
317
|
+
# Default to 30 minutes if no expires_at provided
|
318
|
+
self._token_expiry = datetime.now() + timedelta(minutes=30)
|
319
|
+
|
320
|
+
logger.debug("Successfully authenticated with Airwallex API")
|
321
|
+
|
322
|
+
async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
323
|
+
"""
|
324
|
+
Make an asynchronous HTTP request with automatic authentication.
|
325
|
+
|
326
|
+
Args:
|
327
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
328
|
+
url: API endpoint URL (relative to base_url)
|
329
|
+
**kwargs: Additional arguments to pass to httpx.request()
|
330
|
+
|
331
|
+
Returns:
|
332
|
+
httpx.Response: The HTTP response
|
333
|
+
|
334
|
+
Raises:
|
335
|
+
AirwallexAPIError: For API errors
|
336
|
+
"""
|
337
|
+
# Ensure we're authenticated before making a request
|
338
|
+
await self.authenticate()
|
339
|
+
|
340
|
+
retries = 5
|
341
|
+
kwargs = self._prepare_request(**kwargs)
|
342
|
+
|
343
|
+
while retries > 0:
|
344
|
+
response = await self._client.request(method, url, **kwargs)
|
345
|
+
|
346
|
+
# Handle successful responses
|
347
|
+
if 200 <= response.status_code < 300:
|
348
|
+
return response
|
349
|
+
|
350
|
+
# Handle authentication errors
|
351
|
+
if response.status_code == 401:
|
352
|
+
# Token might be expired, force refresh and retry
|
353
|
+
self._token = None
|
354
|
+
self._token_expiry = None
|
355
|
+
await self.authenticate()
|
356
|
+
kwargs['headers'].update({"Authorization": f"Bearer {self._token}"})
|
357
|
+
retries -= 1
|
358
|
+
continue
|
359
|
+
|
360
|
+
# Handle rate limiting
|
361
|
+
if response.status_code == 429:
|
362
|
+
retry_after = response.headers.get('Retry-After')
|
363
|
+
if retry_after and retry_after.isdigit():
|
364
|
+
wait_time = int(retry_after)
|
365
|
+
logger.info(f"Rate limited, sleeping for {wait_time} seconds")
|
366
|
+
await asyncio.sleep(wait_time)
|
367
|
+
continue
|
368
|
+
else:
|
369
|
+
# Default backoff: 1 second
|
370
|
+
await asyncio.sleep(1)
|
371
|
+
continue
|
372
|
+
|
373
|
+
# Retry on server errors (HTTP 5xx)
|
374
|
+
if response.status_code >= 500 and retries > 0:
|
375
|
+
retries -= 1
|
376
|
+
logger.warning(f"Server error ({response.status_code}), retrying {retries} more time(s)...")
|
377
|
+
await asyncio.sleep(1)
|
378
|
+
continue
|
379
|
+
|
380
|
+
# Create and raise the appropriate exception based on the response
|
381
|
+
raise create_exception_from_response(
|
382
|
+
response=response,
|
383
|
+
method=method,
|
384
|
+
url=url,
|
385
|
+
kwargs=kwargs
|
386
|
+
)
|
387
|
+
|
388
|
+
async def close(self) -> None:
|
389
|
+
"""Close the async HTTP client."""
|
390
|
+
await self._client.aclose()
|
391
|
+
|
392
|
+
async def __aenter__(self) -> "AirwallexAsyncClient":
|
393
|
+
return self
|
394
|
+
|
395
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
396
|
+
await self.close()
|
airwallex/exceptions.py
ADDED
@@ -0,0 +1,222 @@
|
|
1
|
+
"""
|
2
|
+
Exceptions for the Airwallex API client.
|
3
|
+
"""
|
4
|
+
from typing import Any, Dict, Optional, Type, ClassVar, Mapping
|
5
|
+
import httpx
|
6
|
+
|
7
|
+
|
8
|
+
class AirwallexAPIError(Exception):
|
9
|
+
"""Base exception for Airwallex API errors."""
|
10
|
+
|
11
|
+
def __init__(
|
12
|
+
self,
|
13
|
+
*,
|
14
|
+
status_code: int,
|
15
|
+
response: httpx.Response,
|
16
|
+
method: str,
|
17
|
+
url: str,
|
18
|
+
kwargs: Dict[str, Any],
|
19
|
+
message: Optional[str] = None
|
20
|
+
):
|
21
|
+
self.status_code = status_code
|
22
|
+
self.response = response
|
23
|
+
self.method = method
|
24
|
+
self.url = url
|
25
|
+
self.kwargs = kwargs
|
26
|
+
|
27
|
+
# Try to parse error details from the response
|
28
|
+
try:
|
29
|
+
error_data = response.json()
|
30
|
+
self.error_code = error_data.get("code", "unknown")
|
31
|
+
self.error_message = error_data.get("message", "Unknown error")
|
32
|
+
self.error_source = error_data.get("source", None)
|
33
|
+
self.request_id = error_data.get("request_id", None)
|
34
|
+
except Exception:
|
35
|
+
self.error_code = "unknown"
|
36
|
+
self.error_message = message or f"HTTP {status_code} error"
|
37
|
+
self.error_source = None
|
38
|
+
self.request_id = None
|
39
|
+
|
40
|
+
super().__init__(self.__str__())
|
41
|
+
|
42
|
+
def __str__(self) -> str:
|
43
|
+
source_info = f" (source: {self.error_source})" if self.error_source else ""
|
44
|
+
return (
|
45
|
+
f"Airwallex API Error (HTTP {self.status_code}): [{self.error_code}] {self.error_message}{source_info} "
|
46
|
+
f"for {self.method} {self.url}"
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
class AuthenticationError(AirwallexAPIError):
|
51
|
+
"""Raised when there's an authentication issue."""
|
52
|
+
pass
|
53
|
+
|
54
|
+
|
55
|
+
class RateLimitError(AirwallexAPIError):
|
56
|
+
"""Raised when the API rate limit has been exceeded."""
|
57
|
+
pass
|
58
|
+
|
59
|
+
|
60
|
+
class ResourceNotFoundError(AirwallexAPIError):
|
61
|
+
"""Raised when a requested resource is not found."""
|
62
|
+
pass
|
63
|
+
|
64
|
+
|
65
|
+
class ValidationError(AirwallexAPIError):
|
66
|
+
"""Raised when the request data is invalid."""
|
67
|
+
pass
|
68
|
+
|
69
|
+
|
70
|
+
class ServerError(AirwallexAPIError):
|
71
|
+
"""Raised when the server returns a 5xx error."""
|
72
|
+
pass
|
73
|
+
|
74
|
+
|
75
|
+
class ResourceExistsError(ValidationError):
|
76
|
+
"""Raised when trying to create a resource that already exists."""
|
77
|
+
pass
|
78
|
+
|
79
|
+
|
80
|
+
class AmountLimitError(ValidationError):
|
81
|
+
"""Raised when a transaction amount exceeds or falls below the allowed limits."""
|
82
|
+
pass
|
83
|
+
|
84
|
+
|
85
|
+
class EditForbiddenError(ValidationError):
|
86
|
+
"""Raised when trying to edit a resource that can't be modified."""
|
87
|
+
pass
|
88
|
+
|
89
|
+
|
90
|
+
class CurrencyError(ValidationError):
|
91
|
+
"""Raised for currency-related errors like invalid pairs or unsupported currencies."""
|
92
|
+
pass
|
93
|
+
|
94
|
+
|
95
|
+
class DateError(ValidationError):
|
96
|
+
"""Raised for date-related validation errors."""
|
97
|
+
pass
|
98
|
+
|
99
|
+
|
100
|
+
class TransferMethodError(ValidationError):
|
101
|
+
"""Raised when the transfer method is not supported."""
|
102
|
+
pass
|
103
|
+
|
104
|
+
|
105
|
+
class ConversionError(AirwallexAPIError):
|
106
|
+
"""Raised for conversion-related errors."""
|
107
|
+
pass
|
108
|
+
|
109
|
+
|
110
|
+
class ServiceUnavailableError(ServerError):
|
111
|
+
"""Raised when a service is temporarily unavailable."""
|
112
|
+
pass
|
113
|
+
|
114
|
+
|
115
|
+
# Mapping of error codes to exception classes
|
116
|
+
ERROR_CODE_MAP: Dict[str, Type[AirwallexAPIError]] = {
|
117
|
+
# Authentication errors
|
118
|
+
"credentials_expired": AuthenticationError,
|
119
|
+
"credentials_invalid": AuthenticationError,
|
120
|
+
|
121
|
+
# Rate limiting
|
122
|
+
"too_many_requests": RateLimitError,
|
123
|
+
|
124
|
+
# Resource exists
|
125
|
+
"already_exists": ResourceExistsError,
|
126
|
+
|
127
|
+
# Amount limit errors
|
128
|
+
"amount_above_limit": AmountLimitError,
|
129
|
+
"amount_below_limit": AmountLimitError,
|
130
|
+
"amount_above_transfer_method_limit": AmountLimitError,
|
131
|
+
|
132
|
+
# Edit forbidden
|
133
|
+
"can_not_be_edited": EditForbiddenError,
|
134
|
+
|
135
|
+
# Conversion errors
|
136
|
+
"conversion_create_failed": ConversionError,
|
137
|
+
|
138
|
+
# Validation errors
|
139
|
+
"field_required": ValidationError,
|
140
|
+
"invalid_argument": ValidationError,
|
141
|
+
"term_agreement_is_required": ValidationError,
|
142
|
+
|
143
|
+
# Currency errors
|
144
|
+
"invalid_currency_pair": CurrencyError,
|
145
|
+
"unsupported_currency": CurrencyError,
|
146
|
+
|
147
|
+
# Date errors
|
148
|
+
"invalid_transfer_date": DateError,
|
149
|
+
"invalid_conversion_date": DateError,
|
150
|
+
|
151
|
+
# Transfer method errors
|
152
|
+
"unsupported_country_code": TransferMethodError,
|
153
|
+
"unsupported_transfer_method": TransferMethodError,
|
154
|
+
|
155
|
+
# Service unavailable
|
156
|
+
"service_unavailable": ServiceUnavailableError,
|
157
|
+
}
|
158
|
+
|
159
|
+
|
160
|
+
def create_exception_from_response(
|
161
|
+
*,
|
162
|
+
response: httpx.Response,
|
163
|
+
method: str,
|
164
|
+
url: str,
|
165
|
+
kwargs: Dict[str, Any],
|
166
|
+
message: Optional[str] = None
|
167
|
+
) -> AirwallexAPIError:
|
168
|
+
"""
|
169
|
+
Create the appropriate exception based on the API response.
|
170
|
+
|
171
|
+
This function first checks for specific error codes in the response body.
|
172
|
+
If no specific error code is found or it's not recognized, it falls back
|
173
|
+
to using the HTTP status code to determine the exception type.
|
174
|
+
|
175
|
+
Args:
|
176
|
+
response: The HTTP response
|
177
|
+
method: HTTP method used for the request
|
178
|
+
url: URL of the request
|
179
|
+
kwargs: Additional keyword arguments passed to the request
|
180
|
+
message: Optional custom error message
|
181
|
+
|
182
|
+
Returns:
|
183
|
+
An instance of the appropriate AirwallexAPIError subclass
|
184
|
+
"""
|
185
|
+
status_code = response.status_code
|
186
|
+
|
187
|
+
try:
|
188
|
+
error_data = response.json()
|
189
|
+
error_code = error_data.get("code")
|
190
|
+
|
191
|
+
if error_code and error_code in ERROR_CODE_MAP:
|
192
|
+
exception_class = ERROR_CODE_MAP[error_code]
|
193
|
+
else:
|
194
|
+
# Fall back to status code-based exception
|
195
|
+
exception_class = exception_for_status(status_code)
|
196
|
+
except Exception:
|
197
|
+
# If we can't parse the response JSON, fall back to status code
|
198
|
+
exception_class = exception_for_status(status_code)
|
199
|
+
|
200
|
+
return exception_class(
|
201
|
+
status_code=status_code,
|
202
|
+
response=response,
|
203
|
+
method=method,
|
204
|
+
url=url,
|
205
|
+
kwargs=kwargs,
|
206
|
+
message=message
|
207
|
+
)
|
208
|
+
|
209
|
+
|
210
|
+
def exception_for_status(status_code: int) -> Type[AirwallexAPIError]:
|
211
|
+
"""Return the appropriate exception class for a given HTTP status code."""
|
212
|
+
if status_code == 401:
|
213
|
+
return AuthenticationError
|
214
|
+
elif status_code == 429:
|
215
|
+
return RateLimitError
|
216
|
+
elif status_code == 404:
|
217
|
+
return ResourceNotFoundError
|
218
|
+
elif 400 <= status_code < 500:
|
219
|
+
return ValidationError
|
220
|
+
elif 500 <= status_code < 600:
|
221
|
+
return ServerError
|
222
|
+
return AirwallexAPIError # Default to the base exception for other status codes
|
@@ -0,0 +1,69 @@
|
|
1
|
+
"""
|
2
|
+
Pydantic models for the Airwallex API.
|
3
|
+
"""
|
4
|
+
from .base import AirwallexModel
|
5
|
+
from .account import Account as AccountModel
|
6
|
+
from .payment import Payment as PaymentModel
|
7
|
+
from .beneficiary import Beneficiary as BeneficiaryModel
|
8
|
+
from .invoice import Invoice as InvoiceModel, InvoiceItem
|
9
|
+
from .financial_transaction import FinancialTransaction as FinancialTransactionModel
|
10
|
+
from .fx import FXConversion, FXQuote
|
11
|
+
from .account_detail import (
|
12
|
+
AccountDetailModel, AccountCreateRequest, AccountUpdateRequest,
|
13
|
+
Amendment, AmendmentCreateRequest, WalletInfo, TermsAndConditionsRequest
|
14
|
+
)
|
15
|
+
|
16
|
+
# Issuing API Models
|
17
|
+
from .issuing_common import (
|
18
|
+
Address,
|
19
|
+
Name,
|
20
|
+
Merchant,
|
21
|
+
RiskDetails,
|
22
|
+
DeviceInformation,
|
23
|
+
TransactionUsage,
|
24
|
+
DeliveryDetails,
|
25
|
+
HasMoreResponse
|
26
|
+
)
|
27
|
+
from .issuing_authorization import Authorization as IssuingAuthorizationModel
|
28
|
+
from .issuing_cardholder import Cardholder as IssuingCardholderModel
|
29
|
+
from .issuing_card import Card as IssuingCardModel, CardDetails
|
30
|
+
from .issuing_digital_wallet_token import DigitalWalletToken as IssuingDigitalWalletTokenModel
|
31
|
+
from .issuing_transaction_dispute import TransactionDispute as IssuingTransactionDisputeModel
|
32
|
+
from .issuing_transaction import Transaction as IssuingTransactionModel
|
33
|
+
from .issuing_config import IssuingConfig as IssuingConfigModel
|
34
|
+
|
35
|
+
__all__ = [
|
36
|
+
"AirwallexModel",
|
37
|
+
"AccountModel",
|
38
|
+
"PaymentModel",
|
39
|
+
"BeneficiaryModel",
|
40
|
+
"InvoiceModel",
|
41
|
+
"InvoiceItem",
|
42
|
+
"FinancialTransactionModel",
|
43
|
+
"FXConversion",
|
44
|
+
"FXQuote",
|
45
|
+
"AccountDetailModel",
|
46
|
+
"AccountCreateRequest",
|
47
|
+
"AccountUpdateRequest",
|
48
|
+
"Amendment",
|
49
|
+
"AmendmentCreateRequest",
|
50
|
+
"WalletInfo",
|
51
|
+
"TermsAndConditionsRequest",
|
52
|
+
# Issuing API
|
53
|
+
"Address",
|
54
|
+
"Name",
|
55
|
+
"Merchant",
|
56
|
+
"RiskDetails",
|
57
|
+
"DeviceInformation",
|
58
|
+
"TransactionUsage",
|
59
|
+
"DeliveryDetails",
|
60
|
+
"HasMoreResponse",
|
61
|
+
"IssuingAuthorizationModel",
|
62
|
+
"IssuingCardholderModel",
|
63
|
+
"IssuingCardModel",
|
64
|
+
"CardDetails",
|
65
|
+
"IssuingDigitalWalletTokenModel",
|
66
|
+
"IssuingTransactionDisputeModel",
|
67
|
+
"IssuingTransactionModel",
|
68
|
+
"IssuingConfigModel",
|
69
|
+
]
|