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 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()