epay 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.
- epay/__init__.py +146 -0
- epay/_config.py +106 -0
- epay/_specs.py +197 -0
- epay/_transport.py +228 -0
- epay/_types.py +194 -0
- epay/_version.py +3 -0
- epay/async_client.py +275 -0
- epay/client.py +362 -0
- epay/errors.py +224 -0
- epay/helpers.py +167 -0
- epay/integrations/__init__.py +19 -0
- epay/integrations/asgi.py +66 -0
- epay/integrations/django.py +83 -0
- epay/integrations/flask.py +87 -0
- epay/pagination.py +142 -0
- epay/py.typed +0 -0
- epay/webhooks.py +163 -0
- epay-0.1.0.dist-info/METADATA +473 -0
- epay-0.1.0.dist-info/RECORD +21 -0
- epay-0.1.0.dist-info/WHEEL +4 -0
- epay-0.1.0.dist-info/licenses/LICENSE +21 -0
epay/__init__.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""ePay Business API client for Python.
|
|
2
|
+
|
|
3
|
+
Accept payments from every major Ethiopian mobile wallet and bank with one
|
|
4
|
+
integration::
|
|
5
|
+
|
|
6
|
+
from epay import Epay
|
|
7
|
+
|
|
8
|
+
epay = Epay() # reads EPAY_SECRET_KEY
|
|
9
|
+
|
|
10
|
+
session = epay.payments.initialize(
|
|
11
|
+
amount="250.00",
|
|
12
|
+
currency_code="ETB",
|
|
13
|
+
customer_phone="+251911234567",
|
|
14
|
+
merchant_reference="order_123",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
redirect(session["checkoutUrl"])
|
|
18
|
+
|
|
19
|
+
An async client is available as :class:`AsyncEpay`, and framework webhook
|
|
20
|
+
helpers live in :mod:`epay.integrations`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from ._config import (
|
|
26
|
+
DEFAULT_BASE_URL,
|
|
27
|
+
DEFAULT_MAX_RETRIES,
|
|
28
|
+
DEFAULT_TIMEOUT,
|
|
29
|
+
mode_from_api_key,
|
|
30
|
+
)
|
|
31
|
+
from ._types import (
|
|
32
|
+
CANCELLABLE_STATUSES,
|
|
33
|
+
TRANSACTION_STATUSES,
|
|
34
|
+
WEBHOOK_EVENT_TYPES,
|
|
35
|
+
EpayMode,
|
|
36
|
+
InitializedPayment,
|
|
37
|
+
PaymentProvider,
|
|
38
|
+
PaymentProviderItem,
|
|
39
|
+
TimelineEvent,
|
|
40
|
+
Transaction,
|
|
41
|
+
TransactionCustomer,
|
|
42
|
+
TransactionListResponse,
|
|
43
|
+
TransactionStatus,
|
|
44
|
+
TransactionTimeline,
|
|
45
|
+
VerifiedPayment,
|
|
46
|
+
WebhookEvent,
|
|
47
|
+
WebhookEventType,
|
|
48
|
+
)
|
|
49
|
+
from ._version import __version__
|
|
50
|
+
from .async_client import AsyncEpay
|
|
51
|
+
from .client import Epay
|
|
52
|
+
from .errors import (
|
|
53
|
+
EpayAPIError,
|
|
54
|
+
EpayAuthenticationError,
|
|
55
|
+
EpayBadRequestError,
|
|
56
|
+
EpayConfigError,
|
|
57
|
+
EpayConflictError,
|
|
58
|
+
EpayConnectionError,
|
|
59
|
+
EpayError,
|
|
60
|
+
EpayNotFoundError,
|
|
61
|
+
EpayPermissionDeniedError,
|
|
62
|
+
EpayRateLimitError,
|
|
63
|
+
EpayServerError,
|
|
64
|
+
EpayTimeoutError,
|
|
65
|
+
EpayUnprocessableEntityError,
|
|
66
|
+
EpayValidationError,
|
|
67
|
+
EpayWebhookSignatureError,
|
|
68
|
+
)
|
|
69
|
+
from .helpers import (
|
|
70
|
+
MAX_AMOUNT,
|
|
71
|
+
MAX_LIST_RANGE_DAYS,
|
|
72
|
+
format_amount,
|
|
73
|
+
normalize_currency,
|
|
74
|
+
normalize_phone,
|
|
75
|
+
)
|
|
76
|
+
from .pagination import PAGE_SIZE, AsyncTransactionPage, TransactionPage
|
|
77
|
+
from .webhooks import (
|
|
78
|
+
SIGNATURE_HEADER,
|
|
79
|
+
Webhooks,
|
|
80
|
+
construct_webhook_event,
|
|
81
|
+
is_known_webhook_event,
|
|
82
|
+
sign_webhook_payload,
|
|
83
|
+
verify_webhook_signature,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"__version__",
|
|
88
|
+
# Clients
|
|
89
|
+
"Epay",
|
|
90
|
+
"AsyncEpay",
|
|
91
|
+
# Configuration
|
|
92
|
+
"DEFAULT_BASE_URL",
|
|
93
|
+
"DEFAULT_MAX_RETRIES",
|
|
94
|
+
"DEFAULT_TIMEOUT",
|
|
95
|
+
"mode_from_api_key",
|
|
96
|
+
# Errors
|
|
97
|
+
"EpayError",
|
|
98
|
+
"EpayAPIError",
|
|
99
|
+
"EpayAuthenticationError",
|
|
100
|
+
"EpayBadRequestError",
|
|
101
|
+
"EpayConfigError",
|
|
102
|
+
"EpayConflictError",
|
|
103
|
+
"EpayConnectionError",
|
|
104
|
+
"EpayNotFoundError",
|
|
105
|
+
"EpayPermissionDeniedError",
|
|
106
|
+
"EpayRateLimitError",
|
|
107
|
+
"EpayServerError",
|
|
108
|
+
"EpayTimeoutError",
|
|
109
|
+
"EpayUnprocessableEntityError",
|
|
110
|
+
"EpayValidationError",
|
|
111
|
+
"EpayWebhookSignatureError",
|
|
112
|
+
# Helpers
|
|
113
|
+
"MAX_AMOUNT",
|
|
114
|
+
"MAX_LIST_RANGE_DAYS",
|
|
115
|
+
"format_amount",
|
|
116
|
+
"normalize_currency",
|
|
117
|
+
"normalize_phone",
|
|
118
|
+
# Pagination
|
|
119
|
+
"PAGE_SIZE",
|
|
120
|
+
"TransactionPage",
|
|
121
|
+
"AsyncTransactionPage",
|
|
122
|
+
# Webhooks
|
|
123
|
+
"SIGNATURE_HEADER",
|
|
124
|
+
"Webhooks",
|
|
125
|
+
"construct_webhook_event",
|
|
126
|
+
"is_known_webhook_event",
|
|
127
|
+
"sign_webhook_payload",
|
|
128
|
+
"verify_webhook_signature",
|
|
129
|
+
# Types
|
|
130
|
+
"CANCELLABLE_STATUSES",
|
|
131
|
+
"TRANSACTION_STATUSES",
|
|
132
|
+
"WEBHOOK_EVENT_TYPES",
|
|
133
|
+
"EpayMode",
|
|
134
|
+
"InitializedPayment",
|
|
135
|
+
"PaymentProvider",
|
|
136
|
+
"PaymentProviderItem",
|
|
137
|
+
"TimelineEvent",
|
|
138
|
+
"Transaction",
|
|
139
|
+
"TransactionCustomer",
|
|
140
|
+
"TransactionListResponse",
|
|
141
|
+
"TransactionStatus",
|
|
142
|
+
"TransactionTimeline",
|
|
143
|
+
"VerifiedPayment",
|
|
144
|
+
"WebhookEvent",
|
|
145
|
+
"WebhookEventType",
|
|
146
|
+
]
|
epay/_config.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Client configuration: option resolution and API-key inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
from ._types import EpayMode
|
|
10
|
+
from .errors import EpayConfigError
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DEFAULT_BASE_URL",
|
|
14
|
+
"DEFAULT_TIMEOUT",
|
|
15
|
+
"DEFAULT_MAX_RETRIES",
|
|
16
|
+
"Config",
|
|
17
|
+
"mode_from_api_key",
|
|
18
|
+
"resolve_config",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
#: Default API root.
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.e-pay.et/v1"
|
|
23
|
+
|
|
24
|
+
#: Default per-attempt timeout, in seconds.
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
|
|
27
|
+
#: Default number of retries after the initial attempt.
|
|
28
|
+
DEFAULT_MAX_RETRIES = 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Config:
|
|
33
|
+
"""Fully resolved client configuration."""
|
|
34
|
+
|
|
35
|
+
api_key: str
|
|
36
|
+
base_url: str
|
|
37
|
+
timeout: float
|
|
38
|
+
max_retries: int
|
|
39
|
+
webhook_secret: Optional[str] = None
|
|
40
|
+
default_headers: Dict[str, str] = field(default_factory=dict)
|
|
41
|
+
mode: Optional[EpayMode] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def mode_from_api_key(api_key: str) -> Optional[EpayMode]:
|
|
45
|
+
"""Derives the traffic mode from a key prefix.
|
|
46
|
+
|
|
47
|
+
Returns ``None`` for an ``sk_``-prefixed key with an unrecognised
|
|
48
|
+
environment segment, so a future key format does not break the client.
|
|
49
|
+
"""
|
|
50
|
+
if api_key.startswith("sk_live_"):
|
|
51
|
+
return "live"
|
|
52
|
+
if api_key.startswith("sk_test_"):
|
|
53
|
+
return "sandbox"
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_config(
|
|
58
|
+
api_key: Optional[str] = None,
|
|
59
|
+
*,
|
|
60
|
+
webhook_secret: Optional[str] = None,
|
|
61
|
+
base_url: Optional[str] = None,
|
|
62
|
+
timeout: Optional[float] = None,
|
|
63
|
+
max_retries: Optional[int] = None,
|
|
64
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
65
|
+
) -> Config:
|
|
66
|
+
"""Validates and fills in every option, reading environment fallbacks.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
EpayConfigError: if no API key is available, the key is clearly not a
|
|
70
|
+
secret key, or an option is out of range.
|
|
71
|
+
"""
|
|
72
|
+
key = (api_key or os.environ.get("EPAY_SECRET_KEY") or "").strip()
|
|
73
|
+
|
|
74
|
+
if not key:
|
|
75
|
+
raise EpayConfigError(
|
|
76
|
+
"Missing ePay API key. Pass api_key=... or set the EPAY_SECRET_KEY "
|
|
77
|
+
"environment variable."
|
|
78
|
+
)
|
|
79
|
+
if not key.startswith("sk_"):
|
|
80
|
+
raise EpayConfigError(
|
|
81
|
+
'Invalid ePay API key: expected a secret key beginning with "sk_live_" or '
|
|
82
|
+
'"sk_test_". Secret keys are found in the dashboard under '
|
|
83
|
+
"Developers -> API Keys."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
resolved_timeout = DEFAULT_TIMEOUT if timeout is None else float(timeout)
|
|
87
|
+
if resolved_timeout <= 0:
|
|
88
|
+
raise EpayConfigError(f"timeout must be a positive number of seconds, received {timeout!r}")
|
|
89
|
+
|
|
90
|
+
resolved_retries = DEFAULT_MAX_RETRIES if max_retries is None else int(max_retries)
|
|
91
|
+
if resolved_retries < 0:
|
|
92
|
+
raise EpayConfigError(
|
|
93
|
+
f"max_retries must be a non-negative integer, received {max_retries!r}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
root = (base_url or os.environ.get("EPAY_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
97
|
+
|
|
98
|
+
return Config(
|
|
99
|
+
api_key=key,
|
|
100
|
+
base_url=root,
|
|
101
|
+
timeout=resolved_timeout,
|
|
102
|
+
max_retries=resolved_retries,
|
|
103
|
+
webhook_secret=webhook_secret or os.environ.get("EPAY_WEBHOOK_SECRET") or None,
|
|
104
|
+
default_headers=dict(default_headers or {}),
|
|
105
|
+
mode=mode_from_api_key(key),
|
|
106
|
+
)
|
epay/_specs.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Request builders shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Each function validates its inputs and returns a :class:`RequestSpec`. Keeping
|
|
4
|
+
validation here means the sync and async surfaces enforce identical rules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from typing import Any, Dict, Optional, Union
|
|
12
|
+
|
|
13
|
+
from ._transport import RequestSpec
|
|
14
|
+
from ._types import TRANSACTION_STATUSES
|
|
15
|
+
from .errors import EpayValidationError
|
|
16
|
+
from .helpers import (
|
|
17
|
+
AmountInput,
|
|
18
|
+
format_amount,
|
|
19
|
+
format_date_param,
|
|
20
|
+
normalize_currency,
|
|
21
|
+
normalize_phone,
|
|
22
|
+
require_reference,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"initialize_spec",
|
|
27
|
+
"verify_spec",
|
|
28
|
+
"cancel_spec",
|
|
29
|
+
"list_transactions_spec",
|
|
30
|
+
"retrieve_spec",
|
|
31
|
+
"timeline_spec",
|
|
32
|
+
"providers_list_spec",
|
|
33
|
+
"providers_get_all_spec",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_OPTIONAL_INITIALIZE_FIELDS = (
|
|
37
|
+
"merchantReference",
|
|
38
|
+
"email",
|
|
39
|
+
"firstName",
|
|
40
|
+
"lastName",
|
|
41
|
+
"returnUrl",
|
|
42
|
+
"callbackUrl",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def initialize_spec(
|
|
47
|
+
*,
|
|
48
|
+
amount: AmountInput,
|
|
49
|
+
currency_code: str,
|
|
50
|
+
customer_phone: str,
|
|
51
|
+
merchant_reference: Optional[str] = None,
|
|
52
|
+
email: Optional[str] = None,
|
|
53
|
+
first_name: Optional[str] = None,
|
|
54
|
+
last_name: Optional[str] = None,
|
|
55
|
+
return_url: Optional[str] = None,
|
|
56
|
+
callback_url: Optional[str] = None,
|
|
57
|
+
idempotency_key: Optional[str] = None,
|
|
58
|
+
) -> RequestSpec:
|
|
59
|
+
"""Builds the ``POST /transactions/initialize`` request.
|
|
60
|
+
|
|
61
|
+
When ``idempotency_key`` is omitted a fresh one is generated per call, which
|
|
62
|
+
makes the SDK's internal retries safe but does not deduplicate across
|
|
63
|
+
separate calls.
|
|
64
|
+
"""
|
|
65
|
+
key = idempotency_key or f"epay_{uuid.uuid4()}"
|
|
66
|
+
if len(key) > 255:
|
|
67
|
+
raise EpayValidationError(
|
|
68
|
+
f"idempotency_key must be at most 255 characters, received {len(key)}"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
body: Dict[str, Any] = {
|
|
72
|
+
"amount": format_amount(amount),
|
|
73
|
+
"currencyCode": normalize_currency(currency_code),
|
|
74
|
+
"customerPhone": normalize_phone(customer_phone),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Only forward optional fields the caller actually set, so the API applies
|
|
78
|
+
# its own defaults rather than receiving explicit nulls.
|
|
79
|
+
optional = {
|
|
80
|
+
"merchantReference": merchant_reference,
|
|
81
|
+
"email": email,
|
|
82
|
+
"firstName": first_name,
|
|
83
|
+
"lastName": last_name,
|
|
84
|
+
"returnUrl": return_url,
|
|
85
|
+
"callbackUrl": callback_url,
|
|
86
|
+
}
|
|
87
|
+
for field_name in _OPTIONAL_INITIALIZE_FIELDS:
|
|
88
|
+
value = optional[field_name]
|
|
89
|
+
if value is not None:
|
|
90
|
+
body[field_name] = value
|
|
91
|
+
|
|
92
|
+
return RequestSpec(
|
|
93
|
+
method="POST",
|
|
94
|
+
path="/transactions/initialize",
|
|
95
|
+
json=body,
|
|
96
|
+
headers={"x-idempotency-key": key},
|
|
97
|
+
# Safe to retry: every attempt carries the same idempotency key.
|
|
98
|
+
retryable=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def verify_spec(reference: str) -> RequestSpec:
|
|
103
|
+
"""Builds the ``GET /transactions/:reference/verify`` request."""
|
|
104
|
+
from urllib.parse import quote
|
|
105
|
+
|
|
106
|
+
return RequestSpec(
|
|
107
|
+
method="GET",
|
|
108
|
+
path="/transactions/{}/verify".format(quote(require_reference(reference), safe="")),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cancel_spec(reference: str) -> RequestSpec:
|
|
113
|
+
"""Builds the ``POST /transactions/:reference/cancel`` request."""
|
|
114
|
+
from urllib.parse import quote
|
|
115
|
+
|
|
116
|
+
return RequestSpec(
|
|
117
|
+
method="POST",
|
|
118
|
+
path="/transactions/{}/cancel".format(quote(require_reference(reference), safe="")),
|
|
119
|
+
# Cancelling twice is rejected with 400 rather than duplicated, but the
|
|
120
|
+
# endpoint takes no idempotency key, so a retried attempt could act on a
|
|
121
|
+
# request the server already processed.
|
|
122
|
+
retryable=False,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def retrieve_spec(reference: str) -> RequestSpec:
|
|
127
|
+
"""Builds the ``GET /transactions/:reference`` request."""
|
|
128
|
+
from urllib.parse import quote
|
|
129
|
+
|
|
130
|
+
return RequestSpec(
|
|
131
|
+
method="GET",
|
|
132
|
+
path="/transactions/{}".format(quote(require_reference(reference), safe="")),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def timeline_spec(reference: str) -> RequestSpec:
|
|
137
|
+
"""Builds the ``GET /transactions/:reference/timeline`` request."""
|
|
138
|
+
from urllib.parse import quote
|
|
139
|
+
|
|
140
|
+
return RequestSpec(
|
|
141
|
+
method="GET",
|
|
142
|
+
path="/transactions/{}/timeline".format(quote(require_reference(reference), safe="")),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def list_transactions_spec(
|
|
147
|
+
*,
|
|
148
|
+
from_date: Optional[Union[str, date, datetime]] = None,
|
|
149
|
+
to_date: Optional[Union[str, date, datetime]] = None,
|
|
150
|
+
currency: Optional[str] = None,
|
|
151
|
+
status: Optional[str] = None,
|
|
152
|
+
cursor: Optional[str] = None,
|
|
153
|
+
) -> RequestSpec:
|
|
154
|
+
"""Builds the ``GET /transactions`` request.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
EpayValidationError: if the range is inverted or the status is unknown.
|
|
158
|
+
"""
|
|
159
|
+
params: Dict[str, Any] = {}
|
|
160
|
+
|
|
161
|
+
if from_date is not None:
|
|
162
|
+
params["from"] = format_date_param(from_date, "from_date")
|
|
163
|
+
if to_date is not None:
|
|
164
|
+
params["to"] = format_date_param(to_date, "to_date")
|
|
165
|
+
if "from" in params and "to" in params and params["from"] > params["to"]:
|
|
166
|
+
raise EpayValidationError(
|
|
167
|
+
"from_date ({}) must not be later than to_date ({})".format(
|
|
168
|
+
params["from"], params["to"]
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if currency is not None:
|
|
173
|
+
params["currency"] = normalize_currency(currency, "currency")
|
|
174
|
+
|
|
175
|
+
if status is not None:
|
|
176
|
+
if status not in TRANSACTION_STATUSES:
|
|
177
|
+
raise EpayValidationError(
|
|
178
|
+
"status must be one of {}, received {!r}".format(
|
|
179
|
+
", ".join(TRANSACTION_STATUSES), status
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
params["status"] = status
|
|
183
|
+
|
|
184
|
+
if cursor is not None:
|
|
185
|
+
params["cursor"] = cursor
|
|
186
|
+
|
|
187
|
+
return RequestSpec(method="GET", path="/transactions", params=params)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def providers_list_spec() -> RequestSpec:
|
|
191
|
+
"""Builds the ``GET /payment-providers/list`` request."""
|
|
192
|
+
return RequestSpec(method="GET", path="/payment-providers/list")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def providers_get_all_spec() -> RequestSpec:
|
|
196
|
+
"""Builds the ``GET /payment-providers`` request."""
|
|
197
|
+
return RequestSpec(method="GET", path="/payment-providers")
|
epay/_transport.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""HTTP transport: request specs, retry policy, and error mapping.
|
|
2
|
+
|
|
3
|
+
The retry policy and error mapping live here as module-level functions so the
|
|
4
|
+
sync and async clients share one implementation and cannot drift apart.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json as jsonlib
|
|
10
|
+
import random
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._config import Config
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import (
|
|
19
|
+
EpayConnectionError,
|
|
20
|
+
EpayTimeoutError,
|
|
21
|
+
error_from_response,
|
|
22
|
+
parse_retry_after,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = ["RequestSpec", "SyncTransport", "AsyncTransport"]
|
|
26
|
+
|
|
27
|
+
#: Status codes worth a second attempt, alongside anything 5xx.
|
|
28
|
+
_RETRYABLE_STATUSES = frozenset({408, 409, 429})
|
|
29
|
+
|
|
30
|
+
#: Base delay for exponential backoff, in seconds.
|
|
31
|
+
_BACKOFF_BASE = 0.5
|
|
32
|
+
|
|
33
|
+
#: Ceiling for a single backoff delay, in seconds.
|
|
34
|
+
_BACKOFF_MAX = 8.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class RequestSpec:
|
|
39
|
+
"""A single API call, independent of sync or async execution."""
|
|
40
|
+
|
|
41
|
+
method: str
|
|
42
|
+
path: str
|
|
43
|
+
params: Optional[Dict[str, Any]] = None
|
|
44
|
+
json: Optional[Dict[str, Any]] = None
|
|
45
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
46
|
+
#: Whether a failed attempt may be retried. Defaults to true for GET.
|
|
47
|
+
retryable: Optional[bool] = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def label(self) -> str:
|
|
51
|
+
"""Method and path, for error messages."""
|
|
52
|
+
return f"{self.method} {self.path}"
|
|
53
|
+
|
|
54
|
+
def is_retryable(self) -> bool:
|
|
55
|
+
if self.retryable is not None:
|
|
56
|
+
return self.retryable
|
|
57
|
+
return self.method == "GET"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_headers(config: Config, spec: RequestSpec) -> Dict[str, str]:
|
|
61
|
+
headers = {
|
|
62
|
+
"Accept": "application/json",
|
|
63
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
64
|
+
"User-Agent": f"epay-python/{__version__}",
|
|
65
|
+
}
|
|
66
|
+
headers.update(config.default_headers)
|
|
67
|
+
headers.update(spec.headers)
|
|
68
|
+
if spec.json is not None:
|
|
69
|
+
headers["Content-Type"] = "application/json"
|
|
70
|
+
return headers
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _url(config: Config, spec: RequestSpec) -> str:
|
|
74
|
+
path = spec.path if spec.path.startswith("/") else "/" + spec.path
|
|
75
|
+
return config.base_url + path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _clean_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
79
|
+
"""Drops keys whose value is ``None``, so they are absent from the query."""
|
|
80
|
+
if not params:
|
|
81
|
+
return None
|
|
82
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _should_retry_status(status: int) -> bool:
|
|
86
|
+
return status >= 500 or status in _RETRYABLE_STATUSES
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _backoff_delay(attempt: int, retry_after: Optional[float]) -> float:
|
|
90
|
+
"""Exponential backoff with jitter, honouring ``Retry-After``.
|
|
91
|
+
|
|
92
|
+
Jitter keeps concurrent clients from retrying in lockstep.
|
|
93
|
+
"""
|
|
94
|
+
if retry_after is not None:
|
|
95
|
+
return float(min(retry_after, _BACKOFF_MAX))
|
|
96
|
+
ceiling = min(_BACKOFF_BASE * (2.0**attempt), _BACKOFF_MAX)
|
|
97
|
+
return ceiling * (0.5 + random.random() * 0.5)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
101
|
+
"""Parses a response body as JSON, falling back to raw text.
|
|
102
|
+
|
|
103
|
+
``204 No Content`` (returned by cancel) and empty bodies become ``None``.
|
|
104
|
+
"""
|
|
105
|
+
if response.status_code == 204 or not response.content:
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
return response.json()
|
|
109
|
+
except (ValueError, jsonlib.JSONDecodeError):
|
|
110
|
+
return response.text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _handle(response: httpx.Response, spec: RequestSpec) -> Any:
|
|
114
|
+
"""Returns the parsed body, or raises the mapped error."""
|
|
115
|
+
if response.is_success:
|
|
116
|
+
return _parse_body(response)
|
|
117
|
+
|
|
118
|
+
raise error_from_response(
|
|
119
|
+
status=response.status_code,
|
|
120
|
+
body=_parse_body(response),
|
|
121
|
+
headers={key.lower(): value for key, value in response.headers.items()},
|
|
122
|
+
request=spec.label,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _translate_transport_error(error: Exception, url: str, timeout: float) -> Exception:
|
|
127
|
+
"""Maps an httpx transport failure onto the SDK hierarchy."""
|
|
128
|
+
if isinstance(error, httpx.TimeoutException):
|
|
129
|
+
return EpayTimeoutError(f"Request to {url} timed out after {timeout}s")
|
|
130
|
+
return EpayConnectionError(f"Could not reach the ePay API at {url}: {error}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SyncTransport:
|
|
134
|
+
"""Issues API calls over a synchronous ``httpx.Client``."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, config: Config, http_client: Optional[httpx.Client] = None) -> None:
|
|
137
|
+
self._config = config
|
|
138
|
+
self._owns_client = http_client is None
|
|
139
|
+
self._client = http_client or httpx.Client(timeout=config.timeout)
|
|
140
|
+
|
|
141
|
+
def request(self, spec: RequestSpec) -> Any:
|
|
142
|
+
"""Sends a request, retrying transient failures, and returns the body."""
|
|
143
|
+
import time
|
|
144
|
+
|
|
145
|
+
url = _url(self._config, spec)
|
|
146
|
+
attempts = self._config.max_retries + 1 if spec.is_retryable() else 1
|
|
147
|
+
last_error: Optional[Exception] = None
|
|
148
|
+
|
|
149
|
+
for attempt in range(attempts):
|
|
150
|
+
is_last = attempt == attempts - 1
|
|
151
|
+
try:
|
|
152
|
+
response = self._client.request(
|
|
153
|
+
spec.method,
|
|
154
|
+
url,
|
|
155
|
+
params=_clean_params(spec.params),
|
|
156
|
+
json=spec.json,
|
|
157
|
+
headers=_build_headers(self._config, spec),
|
|
158
|
+
timeout=self._config.timeout,
|
|
159
|
+
)
|
|
160
|
+
except httpx.HTTPError as error:
|
|
161
|
+
last_error = _translate_transport_error(error, url, self._config.timeout)
|
|
162
|
+
if is_last:
|
|
163
|
+
raise last_error from error
|
|
164
|
+
time.sleep(_backoff_delay(attempt, None))
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if response.is_success or is_last or not _should_retry_status(response.status_code):
|
|
168
|
+
return _handle(response, spec)
|
|
169
|
+
|
|
170
|
+
time.sleep(
|
|
171
|
+
_backoff_delay(attempt, parse_retry_after(response.headers.get("retry-after")))
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
raise last_error or EpayConnectionError(f"{spec.label} failed after {attempts} attempts")
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
"""Closes the underlying connection pool, if this transport owns it."""
|
|
178
|
+
if self._owns_client:
|
|
179
|
+
self._client.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class AsyncTransport:
|
|
183
|
+
"""Issues API calls over an asynchronous ``httpx.AsyncClient``."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, config: Config, http_client: Optional[httpx.AsyncClient] = None) -> None:
|
|
186
|
+
self._config = config
|
|
187
|
+
self._owns_client = http_client is None
|
|
188
|
+
self._client = http_client or httpx.AsyncClient(timeout=config.timeout)
|
|
189
|
+
|
|
190
|
+
async def request(self, spec: RequestSpec) -> Any:
|
|
191
|
+
"""Sends a request, retrying transient failures, and returns the body."""
|
|
192
|
+
import asyncio
|
|
193
|
+
|
|
194
|
+
url = _url(self._config, spec)
|
|
195
|
+
attempts = self._config.max_retries + 1 if spec.is_retryable() else 1
|
|
196
|
+
last_error: Optional[Exception] = None
|
|
197
|
+
|
|
198
|
+
for attempt in range(attempts):
|
|
199
|
+
is_last = attempt == attempts - 1
|
|
200
|
+
try:
|
|
201
|
+
response = await self._client.request(
|
|
202
|
+
spec.method,
|
|
203
|
+
url,
|
|
204
|
+
params=_clean_params(spec.params),
|
|
205
|
+
json=spec.json,
|
|
206
|
+
headers=_build_headers(self._config, spec),
|
|
207
|
+
timeout=self._config.timeout,
|
|
208
|
+
)
|
|
209
|
+
except httpx.HTTPError as error:
|
|
210
|
+
last_error = _translate_transport_error(error, url, self._config.timeout)
|
|
211
|
+
if is_last:
|
|
212
|
+
raise last_error from error
|
|
213
|
+
await asyncio.sleep(_backoff_delay(attempt, None))
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if response.is_success or is_last or not _should_retry_status(response.status_code):
|
|
217
|
+
return _handle(response, spec)
|
|
218
|
+
|
|
219
|
+
await asyncio.sleep(
|
|
220
|
+
_backoff_delay(attempt, parse_retry_after(response.headers.get("retry-after")))
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
raise last_error or EpayConnectionError(f"{spec.label} failed after {attempts} attempts")
|
|
224
|
+
|
|
225
|
+
async def aclose(self) -> None:
|
|
226
|
+
"""Closes the underlying connection pool, if this transport owns it."""
|
|
227
|
+
if self._owns_client:
|
|
228
|
+
await self._client.aclose()
|