onepay 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.
- onepay/__init__.py +88 -0
- onepay/_auth.py +59 -0
- onepay/_config.py +123 -0
- onepay/_http.py +326 -0
- onepay/_version.py +1 -0
- onepay/client.py +182 -0
- onepay/exceptions.py +138 -0
- onepay/models/__init__.py +128 -0
- onepay/models/card.py +67 -0
- onepay/models/checkout.py +69 -0
- onepay/models/customer.py +90 -0
- onepay/models/enums.py +46 -0
- onepay/models/item.py +92 -0
- onepay/models/payment_link.py +80 -0
- onepay/models/payout.py +45 -0
- onepay/models/refund.py +58 -0
- onepay/models/transaction.py +37 -0
- onepay/py.typed +1 -0
- onepay/resources/__init__.py +43 -0
- onepay/resources/cards.py +201 -0
- onepay/resources/checkout.py +146 -0
- onepay/resources/customers.py +229 -0
- onepay/resources/items.py +229 -0
- onepay/resources/payment_links.py +262 -0
- onepay/resources/payouts.py +174 -0
- onepay/resources/refunds.py +109 -0
- onepay/resources/subscriptions.py +154 -0
- onepay/resources/transactions.py +74 -0
- onepay/webhook.py +80 -0
- onepay-0.1.0.dist-info/METADATA +310 -0
- onepay-0.1.0.dist-info/RECORD +33 -0
- onepay-0.1.0.dist-info/WHEEL +4 -0
- onepay-0.1.0.dist-info/licenses/LICENSE +21 -0
onepay/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""OnePay Python SDK — Official SDK for the OnePay payment gateway.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from onepay import OnePay
|
|
6
|
+
|
|
7
|
+
client = OnePay(
|
|
8
|
+
app_id="YOUR_APP_ID",
|
|
9
|
+
hash_salt="YOUR_HASH_SALT",
|
|
10
|
+
app_token="YOUR_APP_TOKEN",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
result = client.checkout.create(
|
|
14
|
+
amount=1000.00,
|
|
15
|
+
currency="LKR",
|
|
16
|
+
reference="ORDER-123",
|
|
17
|
+
customer_first_name="Amila",
|
|
18
|
+
customer_last_name="Perera",
|
|
19
|
+
customer_phone_number="+94771234567",
|
|
20
|
+
customer_email="amila@store.lk",
|
|
21
|
+
transaction_redirect_url="https://store.lk/thank-you",
|
|
22
|
+
)
|
|
23
|
+
print(result.redirect_url)
|
|
24
|
+
|
|
25
|
+
For async usage::
|
|
26
|
+
|
|
27
|
+
from onepay import AsyncOnePay
|
|
28
|
+
|
|
29
|
+
client = AsyncOnePay(...)
|
|
30
|
+
result = await client.checkout.create(...)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from onepay._auth import generate_hash
|
|
36
|
+
from onepay._version import __version__
|
|
37
|
+
from onepay.client import AsyncOnePay, OnePay
|
|
38
|
+
|
|
39
|
+
# Exceptions
|
|
40
|
+
from onepay.exceptions import (
|
|
41
|
+
APIError,
|
|
42
|
+
AuthenticationError,
|
|
43
|
+
InvalidAmountError,
|
|
44
|
+
InvalidAppIdError,
|
|
45
|
+
InvalidCurrencyError,
|
|
46
|
+
InvalidRequestError,
|
|
47
|
+
NetworkError,
|
|
48
|
+
OnePayError,
|
|
49
|
+
RateLimitError,
|
|
50
|
+
RefundNotAllowedError,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Enums
|
|
54
|
+
from onepay.models.enums import (
|
|
55
|
+
Currency,
|
|
56
|
+
PaymentStatus,
|
|
57
|
+
RefundReason,
|
|
58
|
+
SubscriptionInterval,
|
|
59
|
+
)
|
|
60
|
+
from onepay.webhook import Webhook, WebhookEvent
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
# Version
|
|
64
|
+
"__version__",
|
|
65
|
+
# Clients
|
|
66
|
+
"OnePay",
|
|
67
|
+
"AsyncOnePay",
|
|
68
|
+
# Utilities
|
|
69
|
+
"generate_hash",
|
|
70
|
+
"Webhook",
|
|
71
|
+
"WebhookEvent",
|
|
72
|
+
# Exceptions
|
|
73
|
+
"OnePayError",
|
|
74
|
+
"APIError",
|
|
75
|
+
"AuthenticationError",
|
|
76
|
+
"InvalidRequestError",
|
|
77
|
+
"InvalidAppIdError",
|
|
78
|
+
"InvalidAmountError",
|
|
79
|
+
"InvalidCurrencyError",
|
|
80
|
+
"RefundNotAllowedError",
|
|
81
|
+
"RateLimitError",
|
|
82
|
+
"NetworkError",
|
|
83
|
+
# Enums
|
|
84
|
+
"Currency",
|
|
85
|
+
"PaymentStatus",
|
|
86
|
+
"RefundReason",
|
|
87
|
+
"SubscriptionInterval",
|
|
88
|
+
]
|
onepay/_auth.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Authentication utilities for the OnePay SDK.
|
|
2
|
+
|
|
3
|
+
Provides SHA-256 hash generation for payment requests and auth header
|
|
4
|
+
construction for different API endpoint groups.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from typing import Dict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_hash(app_id: str, currency: str, amount: str, hash_salt: str) -> str:
|
|
14
|
+
"""Generate SHA-256 hash for OnePay payment requests.
|
|
15
|
+
|
|
16
|
+
The hash is computed as ``SHA256(app_id + currency + amount + hash_salt)``
|
|
17
|
+
with all values concatenated as plain strings with no separators.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
app_id: Your OnePay application identifier.
|
|
21
|
+
currency: Three-letter ISO currency code (e.g., ``"LKR"``).
|
|
22
|
+
amount: The transaction amount as a string (e.g., ``"100.00"``).
|
|
23
|
+
hash_salt: Your OnePay hash salt (secret key).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Lowercase hexadecimal SHA-256 hash string.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
>>> generate_hash("APP123", "LKR", "100.00", "SALT456")
|
|
30
|
+
'a1b2c3...'
|
|
31
|
+
"""
|
|
32
|
+
payload = f"{app_id}{currency}{amount}{hash_salt}"
|
|
33
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_auth_header(token: str) -> Dict[str, str]:
|
|
37
|
+
"""Build the Authorization header dictionary.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
token: The bearer/API token value.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Dictionary with ``Authorization`` and ``Content-Type`` headers.
|
|
44
|
+
"""
|
|
45
|
+
return {
|
|
46
|
+
"Authorization": token,
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_json_header() -> Dict[str, str]:
|
|
52
|
+
"""Build a basic JSON Content-Type header (no auth).
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dictionary with ``Content-Type`` header only.
|
|
56
|
+
"""
|
|
57
|
+
return {
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
}
|
onepay/_config.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Configuration management for the OnePay SDK.
|
|
2
|
+
|
|
3
|
+
Loads credentials from constructor arguments or environment variables.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
_DEFAULT_BASE_URL = "https://api.onepay.lk"
|
|
12
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
13
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OnePayConfig:
|
|
18
|
+
"""Holds all configuration for an OnePay client instance.
|
|
19
|
+
|
|
20
|
+
Values can be provided explicitly or fall back to environment variables:
|
|
21
|
+
- ``ONEPAY_APP_ID``
|
|
22
|
+
- ``ONEPAY_HASH_SALT``
|
|
23
|
+
- ``ONEPAY_APP_TOKEN``
|
|
24
|
+
- ``ONEPAY_API_KEY``
|
|
25
|
+
- ``ONEPAY_ACCESS_TOKEN``
|
|
26
|
+
- ``ONEPAY_BASE_URL``
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
app_id: str = ""
|
|
30
|
+
hash_salt: str = ""
|
|
31
|
+
app_token: str = ""
|
|
32
|
+
api_key: str = ""
|
|
33
|
+
access_token: str = ""
|
|
34
|
+
base_url: str = _DEFAULT_BASE_URL
|
|
35
|
+
timeout: float = _DEFAULT_TIMEOUT
|
|
36
|
+
max_retries: int = _DEFAULT_MAX_RETRIES
|
|
37
|
+
debug: bool = False
|
|
38
|
+
|
|
39
|
+
# Internal — resolved after __post_init__
|
|
40
|
+
_resolved: bool = field(default=False, repr=False, init=False)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
"""Resolve unset fields from environment variables."""
|
|
44
|
+
if not self._resolved:
|
|
45
|
+
self.app_id = self.app_id or os.environ.get("ONEPAY_APP_ID", "")
|
|
46
|
+
self.hash_salt = self.hash_salt or os.environ.get("ONEPAY_HASH_SALT", "")
|
|
47
|
+
self.app_token = self.app_token or os.environ.get("ONEPAY_APP_TOKEN", "")
|
|
48
|
+
self.api_key = self.api_key or os.environ.get("ONEPAY_API_KEY", "")
|
|
49
|
+
self.access_token = self.access_token or os.environ.get("ONEPAY_ACCESS_TOKEN", "")
|
|
50
|
+
self.base_url = self.base_url or os.environ.get(
|
|
51
|
+
"ONEPAY_BASE_URL", _DEFAULT_BASE_URL
|
|
52
|
+
)
|
|
53
|
+
# Strip trailing slashes from base URL
|
|
54
|
+
self.base_url = self.base_url.rstrip("/")
|
|
55
|
+
self._resolved = True
|
|
56
|
+
|
|
57
|
+
def get_app_token_or_raise(self) -> str:
|
|
58
|
+
"""Return the app token, raising if not configured."""
|
|
59
|
+
if not self.app_token:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"app_token is required for this operation. "
|
|
62
|
+
"Pass it to the OnePay constructor or set the ONEPAY_APP_TOKEN environment variable."
|
|
63
|
+
)
|
|
64
|
+
return self.app_token
|
|
65
|
+
|
|
66
|
+
def get_api_key_or_raise(self) -> str:
|
|
67
|
+
"""Return the API key, raising if not configured."""
|
|
68
|
+
if not self.api_key:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"api_key is required for this operation. "
|
|
71
|
+
"Pass it to the OnePay constructor or set the ONEPAY_API_KEY environment variable."
|
|
72
|
+
)
|
|
73
|
+
return self.api_key
|
|
74
|
+
|
|
75
|
+
def get_access_token_or_raise(self) -> str:
|
|
76
|
+
"""Return the access token, raising if not configured."""
|
|
77
|
+
if not self.access_token:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"access_token is required for this operation. "
|
|
80
|
+
"Pass it to the OnePay constructor or set the ONEPAY_ACCESS_TOKEN environment variable."
|
|
81
|
+
)
|
|
82
|
+
return self.access_token
|
|
83
|
+
|
|
84
|
+
def get_hash_salt_or_raise(self) -> str:
|
|
85
|
+
"""Return the hash salt, raising if not configured."""
|
|
86
|
+
if not self.hash_salt:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"hash_salt is required for this operation. "
|
|
89
|
+
"Pass it to the OnePay constructor or set the ONEPAY_HASH_SALT environment variable."
|
|
90
|
+
)
|
|
91
|
+
return self.hash_salt
|
|
92
|
+
|
|
93
|
+
def get_app_id_or_raise(self) -> str:
|
|
94
|
+
"""Return the app ID, raising if not configured."""
|
|
95
|
+
if not self.app_id:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"app_id is required for this operation. "
|
|
98
|
+
"Pass it to the OnePay constructor or set the ONEPAY_APP_ID environment variable."
|
|
99
|
+
)
|
|
100
|
+
return self.app_id
|
|
101
|
+
|
|
102
|
+
def redacted_repr(self) -> str:
|
|
103
|
+
"""Return a string representation with secrets redacted."""
|
|
104
|
+
def _redact(val: str) -> str:
|
|
105
|
+
if not val:
|
|
106
|
+
return "(not set)"
|
|
107
|
+
if len(val) <= 8:
|
|
108
|
+
return "***"
|
|
109
|
+
return val[:4] + "***" + val[-4:]
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
f"OnePayConfig("
|
|
113
|
+
f"app_id={_redact(self.app_id)!r}, "
|
|
114
|
+
f"hash_salt={_redact(self.hash_salt)!r}, "
|
|
115
|
+
f"app_token={_redact(self.app_token)!r}, "
|
|
116
|
+
f"api_key={_redact(self.api_key)!r}, "
|
|
117
|
+
f"access_token={_redact(self.access_token)!r}, "
|
|
118
|
+
f"base_url={self.base_url!r}, "
|
|
119
|
+
f"timeout={self.timeout}, "
|
|
120
|
+
f"max_retries={self.max_retries}, "
|
|
121
|
+
f"debug={self.debug}"
|
|
122
|
+
f")"
|
|
123
|
+
)
|
onepay/_http.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""HTTP client layer for the OnePay SDK.
|
|
2
|
+
|
|
3
|
+
Provides both synchronous and asynchronous HTTP clients with automatic
|
|
4
|
+
retry logic, error parsing, and structured logging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, Optional, TypeVar
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from onepay._config import OnePayConfig
|
|
17
|
+
from onepay._version import __version__
|
|
18
|
+
from onepay.exceptions import (
|
|
19
|
+
APIError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
InvalidRequestError,
|
|
22
|
+
NetworkError,
|
|
23
|
+
OnePayError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("onepay")
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
_USER_AGENT = f"onepay-python/{__version__}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_error_response(
|
|
35
|
+
status_code: int,
|
|
36
|
+
response_data: Dict[str, Any],
|
|
37
|
+
) -> APIError:
|
|
38
|
+
"""Parse an HTTP error response into the appropriate exception type."""
|
|
39
|
+
message = response_data.get("message", "") or response_data.get("error", "")
|
|
40
|
+
if not message:
|
|
41
|
+
message = str(response_data)
|
|
42
|
+
|
|
43
|
+
# Detect specific 400 error types from the message
|
|
44
|
+
msg_lower = message.lower() if isinstance(message, str) else ""
|
|
45
|
+
|
|
46
|
+
if status_code == 401:
|
|
47
|
+
return AuthenticationError(message=message, raw_response=response_data)
|
|
48
|
+
|
|
49
|
+
if status_code == 429:
|
|
50
|
+
return RateLimitError(message=message, raw_response=response_data)
|
|
51
|
+
|
|
52
|
+
if status_code == 400:
|
|
53
|
+
if "invalid app" in msg_lower:
|
|
54
|
+
from onepay.exceptions import InvalidAppIdError
|
|
55
|
+
|
|
56
|
+
return InvalidAppIdError(message=message, raw_response=response_data)
|
|
57
|
+
if "invalid amount" in msg_lower:
|
|
58
|
+
from onepay.exceptions import InvalidAmountError
|
|
59
|
+
|
|
60
|
+
return InvalidAmountError(message=message, raw_response=response_data)
|
|
61
|
+
if "currency" in msg_lower and ("not available" in msg_lower or "invalid" in msg_lower):
|
|
62
|
+
from onepay.exceptions import InvalidCurrencyError
|
|
63
|
+
|
|
64
|
+
return InvalidCurrencyError(message=message, raw_response=response_data)
|
|
65
|
+
if "refund" in msg_lower:
|
|
66
|
+
from onepay.exceptions import RefundNotAllowedError
|
|
67
|
+
|
|
68
|
+
return RefundNotAllowedError(message=message, raw_response=response_data)
|
|
69
|
+
return InvalidRequestError(message=message, raw_response=response_data)
|
|
70
|
+
|
|
71
|
+
return APIError(message=message, status_code=status_code, raw_response=response_data)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _should_retry(status_code: int) -> bool:
|
|
75
|
+
"""Determine if a request should be retried based on status code."""
|
|
76
|
+
return status_code == 429 or status_code >= 500
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _backoff_delay(attempt: int, base: float = 0.5, max_delay: float = 30.0) -> float:
|
|
80
|
+
"""Calculate exponential backoff delay with jitter."""
|
|
81
|
+
delay = min(base * (2 ** attempt), max_delay)
|
|
82
|
+
jitter = random.uniform(0, delay * 0.5) # noqa: S311
|
|
83
|
+
return float(delay + jitter)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SyncHttpClient:
|
|
87
|
+
"""Synchronous HTTP client backed by ``httpx.Client``."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, config: OnePayConfig) -> None:
|
|
90
|
+
self._config = config
|
|
91
|
+
self._client = httpx.Client(
|
|
92
|
+
base_url=config.base_url,
|
|
93
|
+
timeout=httpx.Timeout(config.timeout),
|
|
94
|
+
headers={"User-Agent": _USER_AGENT},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path: str,
|
|
101
|
+
*,
|
|
102
|
+
json: Optional[Dict[str, Any]] = None,
|
|
103
|
+
params: Optional[Dict[str, Any]] = None,
|
|
104
|
+
headers: Optional[Dict[str, str]] = None,
|
|
105
|
+
) -> Dict[str, Any]:
|
|
106
|
+
"""Execute an HTTP request with retry logic.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
method: HTTP method (GET, POST, PUT, DELETE).
|
|
110
|
+
path: API endpoint path (e.g., ``/v3/checkout/link/``).
|
|
111
|
+
json: JSON body for the request.
|
|
112
|
+
params: Query parameters.
|
|
113
|
+
headers: Additional headers.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Parsed JSON response as a dictionary.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
APIError: On API error responses.
|
|
120
|
+
NetworkError: On connection or timeout failures.
|
|
121
|
+
"""
|
|
122
|
+
last_error: Optional[Exception] = None
|
|
123
|
+
|
|
124
|
+
for attempt in range(self._config.max_retries + 1):
|
|
125
|
+
try:
|
|
126
|
+
if self._config.debug:
|
|
127
|
+
logger.debug(
|
|
128
|
+
"OnePay API request: %s %s (attempt %d/%d)",
|
|
129
|
+
method,
|
|
130
|
+
path,
|
|
131
|
+
attempt + 1,
|
|
132
|
+
self._config.max_retries + 1,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
response = self._client.request(
|
|
136
|
+
method=method,
|
|
137
|
+
url=path,
|
|
138
|
+
json=json,
|
|
139
|
+
params=params,
|
|
140
|
+
headers=headers,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if self._config.debug:
|
|
144
|
+
logger.debug(
|
|
145
|
+
"OnePay API response: %d %s",
|
|
146
|
+
response.status_code,
|
|
147
|
+
path,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Parse response
|
|
151
|
+
try:
|
|
152
|
+
response_data = response.json()
|
|
153
|
+
except Exception:
|
|
154
|
+
response_data = {"message": response.text}
|
|
155
|
+
|
|
156
|
+
# Success
|
|
157
|
+
if response.is_success:
|
|
158
|
+
return response_data # type: ignore[no-any-return]
|
|
159
|
+
|
|
160
|
+
# Check if we should retry
|
|
161
|
+
if _should_retry(response.status_code) and attempt < self._config.max_retries:
|
|
162
|
+
delay = _backoff_delay(attempt)
|
|
163
|
+
if self._config.debug:
|
|
164
|
+
logger.debug(
|
|
165
|
+
"Retrying in %.2fs after HTTP %d",
|
|
166
|
+
delay,
|
|
167
|
+
response.status_code,
|
|
168
|
+
)
|
|
169
|
+
time.sleep(delay)
|
|
170
|
+
last_error = _parse_error_response(response.status_code, response_data)
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# Non-retryable error
|
|
174
|
+
raise _parse_error_response(response.status_code, response_data)
|
|
175
|
+
|
|
176
|
+
except OnePayError:
|
|
177
|
+
raise
|
|
178
|
+
except httpx.TimeoutException as exc:
|
|
179
|
+
last_error = NetworkError(
|
|
180
|
+
message=f"Request timed out: {method} {path}",
|
|
181
|
+
original_error=exc,
|
|
182
|
+
)
|
|
183
|
+
if attempt < self._config.max_retries:
|
|
184
|
+
delay = _backoff_delay(attempt)
|
|
185
|
+
time.sleep(delay)
|
|
186
|
+
continue
|
|
187
|
+
raise last_error from exc
|
|
188
|
+
except httpx.HTTPError as exc:
|
|
189
|
+
last_error = NetworkError(
|
|
190
|
+
message=f"Network error: {exc}",
|
|
191
|
+
original_error=exc,
|
|
192
|
+
)
|
|
193
|
+
if attempt < self._config.max_retries:
|
|
194
|
+
delay = _backoff_delay(attempt)
|
|
195
|
+
time.sleep(delay)
|
|
196
|
+
continue
|
|
197
|
+
raise last_error from exc
|
|
198
|
+
|
|
199
|
+
# Should not reach here, but just in case
|
|
200
|
+
if last_error:
|
|
201
|
+
raise last_error
|
|
202
|
+
raise NetworkError(message="Request failed after all retries.")
|
|
203
|
+
|
|
204
|
+
def close(self) -> None:
|
|
205
|
+
"""Close the underlying HTTP client."""
|
|
206
|
+
self._client.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class AsyncHttpClient:
|
|
210
|
+
"""Asynchronous HTTP client backed by ``httpx.AsyncClient``."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, config: OnePayConfig) -> None:
|
|
213
|
+
self._config = config
|
|
214
|
+
self._client = httpx.AsyncClient(
|
|
215
|
+
base_url=config.base_url,
|
|
216
|
+
timeout=httpx.Timeout(config.timeout),
|
|
217
|
+
headers={"User-Agent": _USER_AGENT},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
async def request(
|
|
221
|
+
self,
|
|
222
|
+
method: str,
|
|
223
|
+
path: str,
|
|
224
|
+
*,
|
|
225
|
+
json: Optional[Dict[str, Any]] = None,
|
|
226
|
+
params: Optional[Dict[str, Any]] = None,
|
|
227
|
+
headers: Optional[Dict[str, str]] = None,
|
|
228
|
+
) -> Dict[str, Any]:
|
|
229
|
+
"""Execute an async HTTP request with retry logic.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
method: HTTP method (GET, POST, PUT, DELETE).
|
|
233
|
+
path: API endpoint path (e.g., ``/v3/checkout/link/``).
|
|
234
|
+
json: JSON body for the request.
|
|
235
|
+
params: Query parameters.
|
|
236
|
+
headers: Additional headers.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Parsed JSON response as a dictionary.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
APIError: On API error responses.
|
|
243
|
+
NetworkError: On connection or timeout failures.
|
|
244
|
+
"""
|
|
245
|
+
import asyncio
|
|
246
|
+
|
|
247
|
+
last_error: Optional[Exception] = None
|
|
248
|
+
|
|
249
|
+
for attempt in range(self._config.max_retries + 1):
|
|
250
|
+
try:
|
|
251
|
+
if self._config.debug:
|
|
252
|
+
logger.debug(
|
|
253
|
+
"OnePay API request: %s %s (attempt %d/%d)",
|
|
254
|
+
method,
|
|
255
|
+
path,
|
|
256
|
+
attempt + 1,
|
|
257
|
+
self._config.max_retries + 1,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
response = await self._client.request(
|
|
261
|
+
method=method,
|
|
262
|
+
url=path,
|
|
263
|
+
json=json,
|
|
264
|
+
params=params,
|
|
265
|
+
headers=headers,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if self._config.debug:
|
|
269
|
+
logger.debug(
|
|
270
|
+
"OnePay API response: %d %s",
|
|
271
|
+
response.status_code,
|
|
272
|
+
path,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
response_data = response.json()
|
|
277
|
+
except Exception:
|
|
278
|
+
response_data = {"message": response.text}
|
|
279
|
+
|
|
280
|
+
if response.is_success:
|
|
281
|
+
return response_data # type: ignore[no-any-return]
|
|
282
|
+
|
|
283
|
+
if _should_retry(response.status_code) and attempt < self._config.max_retries:
|
|
284
|
+
delay = _backoff_delay(attempt)
|
|
285
|
+
if self._config.debug:
|
|
286
|
+
logger.debug(
|
|
287
|
+
"Retrying in %.2fs after HTTP %d",
|
|
288
|
+
delay,
|
|
289
|
+
response.status_code,
|
|
290
|
+
)
|
|
291
|
+
await asyncio.sleep(delay)
|
|
292
|
+
last_error = _parse_error_response(response.status_code, response_data)
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
raise _parse_error_response(response.status_code, response_data)
|
|
296
|
+
|
|
297
|
+
except OnePayError:
|
|
298
|
+
raise
|
|
299
|
+
except httpx.TimeoutException as exc:
|
|
300
|
+
last_error = NetworkError(
|
|
301
|
+
message=f"Request timed out: {method} {path}",
|
|
302
|
+
original_error=exc,
|
|
303
|
+
)
|
|
304
|
+
if attempt < self._config.max_retries:
|
|
305
|
+
delay = _backoff_delay(attempt)
|
|
306
|
+
await asyncio.sleep(delay)
|
|
307
|
+
continue
|
|
308
|
+
raise last_error from exc
|
|
309
|
+
except httpx.HTTPError as exc:
|
|
310
|
+
last_error = NetworkError(
|
|
311
|
+
message=f"Network error: {exc}",
|
|
312
|
+
original_error=exc,
|
|
313
|
+
)
|
|
314
|
+
if attempt < self._config.max_retries:
|
|
315
|
+
delay = _backoff_delay(attempt)
|
|
316
|
+
await asyncio.sleep(delay)
|
|
317
|
+
continue
|
|
318
|
+
raise last_error from exc
|
|
319
|
+
|
|
320
|
+
if last_error:
|
|
321
|
+
raise last_error
|
|
322
|
+
raise NetworkError(message="Request failed after all retries.")
|
|
323
|
+
|
|
324
|
+
async def close(self) -> None:
|
|
325
|
+
"""Close the underlying async HTTP client."""
|
|
326
|
+
await self._client.aclose()
|
onepay/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|