onefirstflock-donations-embed 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- donations_embed/__init__.py +157 -0
- donations_embed/_http.py +318 -0
- donations_embed/_idempotency.py +53 -0
- donations_embed/_version.py +9 -0
- donations_embed/client.py +282 -0
- donations_embed/errors.py +180 -0
- donations_embed/models/__init__.py +93 -0
- donations_embed/models/donations.py +96 -0
- donations_embed/models/events.py +345 -0
- donations_embed/models/recurring.py +61 -0
- donations_embed/webhooks.py +189 -0
- onefirstflock_donations_embed-1.0.0.dist-info/METADATA +277 -0
- onefirstflock_donations_embed-1.0.0.dist-info/RECORD +15 -0
- onefirstflock_donations_embed-1.0.0.dist-info/WHEEL +4 -0
- onefirstflock_donations_embed-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Server-side Python SDK for the 1st Flock Donations Embed API.
|
|
2
|
+
|
|
3
|
+
This package provides:
|
|
4
|
+
|
|
5
|
+
- :class:`Client` — async client for the Customer SDK endpoints
|
|
6
|
+
(donations, recurring, webhook test).
|
|
7
|
+
- :func:`verify_webhook` / :func:`verify_webhook_async` — constant-time
|
|
8
|
+
HMAC-SHA256 signature verification for inbound webhook deliveries,
|
|
9
|
+
returning a parsed :data:`WebhookEvent`.
|
|
10
|
+
- :func:`idempotency_key` — convenience generator for ``Idempotency-Key``
|
|
11
|
+
header values.
|
|
12
|
+
- A full set of typed Pydantic models for every documented request,
|
|
13
|
+
response, and webhook payload.
|
|
14
|
+
|
|
15
|
+
Quickstart::
|
|
16
|
+
|
|
17
|
+
>>> from donations_embed import Client, verify_webhook
|
|
18
|
+
>>> async with Client(api_key="sk_test_…") as client:
|
|
19
|
+
... donation = await client.get_donation("8a1b9c4d-1234-4321-9abc-def012345678")
|
|
20
|
+
... refund = await client.refund_donation(donation.id, amount_cents=1000)
|
|
21
|
+
|
|
22
|
+
>>> # In an inbound webhook handler
|
|
23
|
+
>>> event = verify_webhook(
|
|
24
|
+
... body=raw_body_bytes,
|
|
25
|
+
... signature=request.headers["Flock-Signature"],
|
|
26
|
+
... secret=os.environ["FLOCK_WEBHOOK_SECRET"],
|
|
27
|
+
... )
|
|
28
|
+
|
|
29
|
+
See the README for FastAPI / Starlette / script examples and detailed
|
|
30
|
+
error-handling guidance.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from donations_embed._idempotency import idempotency_key
|
|
36
|
+
from donations_embed._version import __version__
|
|
37
|
+
from donations_embed.client import Client
|
|
38
|
+
from donations_embed.errors import (
|
|
39
|
+
ApiError,
|
|
40
|
+
AuthError,
|
|
41
|
+
BadGatewayError,
|
|
42
|
+
ConflictError,
|
|
43
|
+
DonationsEmbedError,
|
|
44
|
+
ForbiddenError,
|
|
45
|
+
GoneError,
|
|
46
|
+
NotFoundError,
|
|
47
|
+
RateLimitError,
|
|
48
|
+
ServiceUnavailableError,
|
|
49
|
+
ValidationError,
|
|
50
|
+
WebhookError,
|
|
51
|
+
WebhookFormatError,
|
|
52
|
+
WebhookSignatureError,
|
|
53
|
+
WebhookTimestampError,
|
|
54
|
+
)
|
|
55
|
+
from donations_embed.models import (
|
|
56
|
+
Donation,
|
|
57
|
+
DonationCompletedEvent,
|
|
58
|
+
DonationCreatedEvent,
|
|
59
|
+
DonationCreatedEventObject,
|
|
60
|
+
DonationEventDonor,
|
|
61
|
+
DonationEventFundAllocation,
|
|
62
|
+
DonationEventObject,
|
|
63
|
+
DonationFailedEvent,
|
|
64
|
+
DonationListResponse,
|
|
65
|
+
DonationRefundedEvent,
|
|
66
|
+
DonationRefundedEventObject,
|
|
67
|
+
DonationStatus,
|
|
68
|
+
EventPaymentMethod,
|
|
69
|
+
FundAllocation,
|
|
70
|
+
KeyFrozenEvent,
|
|
71
|
+
KeyFrozenEventObject,
|
|
72
|
+
KeyFrozenReason,
|
|
73
|
+
PaymentMethodType,
|
|
74
|
+
Recurring,
|
|
75
|
+
RecurringActivatedEvent,
|
|
76
|
+
RecurringCancelledEvent,
|
|
77
|
+
RecurringCancelledEventObject,
|
|
78
|
+
RecurringCancelResponse,
|
|
79
|
+
RecurringEventObject,
|
|
80
|
+
RecurringInterval,
|
|
81
|
+
RecurringListResponse,
|
|
82
|
+
RecurringPaymentFailedEvent,
|
|
83
|
+
RecurringPaymentSucceededEvent,
|
|
84
|
+
RecurringStatus,
|
|
85
|
+
RefundRequest,
|
|
86
|
+
RefundResponse,
|
|
87
|
+
WebhookEnvelope,
|
|
88
|
+
WebhookEvent,
|
|
89
|
+
WebhookEventType,
|
|
90
|
+
WebhookTestRequest,
|
|
91
|
+
WebhookTestResponse,
|
|
92
|
+
)
|
|
93
|
+
from donations_embed.webhooks import (
|
|
94
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
95
|
+
verify_webhook,
|
|
96
|
+
verify_webhook_async,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
101
|
+
"ApiError",
|
|
102
|
+
"AuthError",
|
|
103
|
+
"BadGatewayError",
|
|
104
|
+
"Client",
|
|
105
|
+
"ConflictError",
|
|
106
|
+
"Donation",
|
|
107
|
+
"DonationCompletedEvent",
|
|
108
|
+
"DonationCreatedEvent",
|
|
109
|
+
"DonationCreatedEventObject",
|
|
110
|
+
"DonationEventDonor",
|
|
111
|
+
"DonationEventFundAllocation",
|
|
112
|
+
"DonationEventObject",
|
|
113
|
+
"DonationFailedEvent",
|
|
114
|
+
"DonationListResponse",
|
|
115
|
+
"DonationRefundedEvent",
|
|
116
|
+
"DonationRefundedEventObject",
|
|
117
|
+
"DonationStatus",
|
|
118
|
+
"DonationsEmbedError",
|
|
119
|
+
"EventPaymentMethod",
|
|
120
|
+
"ForbiddenError",
|
|
121
|
+
"FundAllocation",
|
|
122
|
+
"GoneError",
|
|
123
|
+
"KeyFrozenEvent",
|
|
124
|
+
"KeyFrozenEventObject",
|
|
125
|
+
"KeyFrozenReason",
|
|
126
|
+
"NotFoundError",
|
|
127
|
+
"PaymentMethodType",
|
|
128
|
+
"RateLimitError",
|
|
129
|
+
"Recurring",
|
|
130
|
+
"RecurringActivatedEvent",
|
|
131
|
+
"RecurringCancelResponse",
|
|
132
|
+
"RecurringCancelledEvent",
|
|
133
|
+
"RecurringCancelledEventObject",
|
|
134
|
+
"RecurringEventObject",
|
|
135
|
+
"RecurringInterval",
|
|
136
|
+
"RecurringListResponse",
|
|
137
|
+
"RecurringPaymentFailedEvent",
|
|
138
|
+
"RecurringPaymentSucceededEvent",
|
|
139
|
+
"RecurringStatus",
|
|
140
|
+
"RefundRequest",
|
|
141
|
+
"RefundResponse",
|
|
142
|
+
"ServiceUnavailableError",
|
|
143
|
+
"ValidationError",
|
|
144
|
+
"WebhookEnvelope",
|
|
145
|
+
"WebhookError",
|
|
146
|
+
"WebhookEvent",
|
|
147
|
+
"WebhookEventType",
|
|
148
|
+
"WebhookFormatError",
|
|
149
|
+
"WebhookSignatureError",
|
|
150
|
+
"WebhookTestRequest",
|
|
151
|
+
"WebhookTestResponse",
|
|
152
|
+
"WebhookTimestampError",
|
|
153
|
+
"__version__",
|
|
154
|
+
"idempotency_key",
|
|
155
|
+
"verify_webhook",
|
|
156
|
+
"verify_webhook_async",
|
|
157
|
+
]
|
donations_embed/_http.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Internal HTTP transport — typed errors, retry, and idempotency forwarding.
|
|
2
|
+
|
|
3
|
+
The :class:`HttpTransport` wraps an ``httpx.AsyncClient`` and applies the
|
|
4
|
+
SDK's cross-cutting policies on every request:
|
|
5
|
+
|
|
6
|
+
- Adds the ``Authorization: Bearer <api_key>`` header.
|
|
7
|
+
- Adds a ``User-Agent`` header identifying the SDK + Python version so the
|
|
8
|
+
platform can attribute traffic and warn customers running EOL versions.
|
|
9
|
+
- Forwards an optional ``Idempotency-Key`` header.
|
|
10
|
+
- Maps non-2xx responses to typed exceptions
|
|
11
|
+
(:class:`ApiError` and subclasses).
|
|
12
|
+
- Retries 5xx and 429 responses with exponential backoff + jitter via
|
|
13
|
+
:mod:`tenacity`.
|
|
14
|
+
|
|
15
|
+
The transport is internal — public callers always interact through
|
|
16
|
+
:class:`donations_embed.client.Client`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import platform
|
|
24
|
+
import sys
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from tenacity import (
|
|
29
|
+
AsyncRetrying,
|
|
30
|
+
RetryError,
|
|
31
|
+
retry_if_exception_type,
|
|
32
|
+
stop_after_attempt,
|
|
33
|
+
wait_random_exponential,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from donations_embed._version import __version__
|
|
37
|
+
from donations_embed.errors import (
|
|
38
|
+
ApiError,
|
|
39
|
+
AuthError,
|
|
40
|
+
BadGatewayError,
|
|
41
|
+
ConflictError,
|
|
42
|
+
ForbiddenError,
|
|
43
|
+
GoneError,
|
|
44
|
+
NotFoundError,
|
|
45
|
+
RateLimitError,
|
|
46
|
+
ServiceUnavailableError,
|
|
47
|
+
ValidationError,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
DEFAULT_BASE_URL = "https://api.1stflock.com"
|
|
51
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
52
|
+
DEFAULT_MAX_RETRIES = 3
|
|
53
|
+
|
|
54
|
+
_RETRYABLE_STATUS_CODES = frozenset({500, 502, 503, 504})
|
|
55
|
+
_RETRYABLE_HTTPX_EXCEPTIONS = (
|
|
56
|
+
httpx.ConnectError,
|
|
57
|
+
httpx.ConnectTimeout,
|
|
58
|
+
httpx.ReadTimeout,
|
|
59
|
+
httpx.WriteTimeout,
|
|
60
|
+
httpx.RemoteProtocolError,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_user_agent() -> str:
|
|
65
|
+
"""Build the ``User-Agent`` string for outbound requests."""
|
|
66
|
+
py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
67
|
+
impl = platform.python_implementation().lower()
|
|
68
|
+
return f"onefirstflock-donations-embed/{__version__} python/{py} ({impl})"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _RetryableStatusError(Exception):
|
|
72
|
+
"""Internal sentinel — wraps a 5xx response so :mod:`tenacity` can retry it.
|
|
73
|
+
|
|
74
|
+
We deliberately do NOT make :class:`ApiError` itself retryable, because
|
|
75
|
+
we want callers to see a typed final error after exhaustion rather than
|
|
76
|
+
a tenacity-internal type.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
80
|
+
self.response = response
|
|
81
|
+
super().__init__(f"retryable {response.status_code} from {response.request.url}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class HttpTransport:
|
|
85
|
+
"""Async HTTP transport used by :class:`donations_embed.client.Client`.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
api_key: The Bearer token (an ``sk_live_*`` or ``sk_test_*``
|
|
89
|
+
secret key).
|
|
90
|
+
base_url: API base URL; trailing slashes are tolerated.
|
|
91
|
+
http_client: Optional pre-configured ``httpx.AsyncClient``. Pass
|
|
92
|
+
one in to share a connection pool with other code, customize
|
|
93
|
+
proxies, or inject a transport for testing. The transport will
|
|
94
|
+
**not** close a caller-supplied client.
|
|
95
|
+
max_retries: Maximum retry attempts for retryable failures
|
|
96
|
+
(5xx + transient network errors). The original attempt counts
|
|
97
|
+
as one — ``max_retries=3`` performs at most 4 requests.
|
|
98
|
+
timeout: Per-request timeout in seconds, applied to connect, read,
|
|
99
|
+
write, and pool acquisition.
|
|
100
|
+
logger: Optional :class:`logging.Logger` for low-cardinality
|
|
101
|
+
request/retry events. Set to ``None`` (default) to suppress
|
|
102
|
+
logging entirely.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
api_key: str,
|
|
108
|
+
*,
|
|
109
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
110
|
+
http_client: httpx.AsyncClient | None = None,
|
|
111
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
112
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
113
|
+
logger: logging.Logger | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._api_key = api_key
|
|
116
|
+
self._base_url = base_url.rstrip("/")
|
|
117
|
+
self._max_retries = max_retries
|
|
118
|
+
self._timeout = timeout
|
|
119
|
+
self._owns_client = http_client is None
|
|
120
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
121
|
+
self._logger = logger or logging.getLogger("donations_embed")
|
|
122
|
+
self._user_agent = _build_user_agent()
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ public
|
|
125
|
+
|
|
126
|
+
async def request(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
path: str,
|
|
130
|
+
*,
|
|
131
|
+
params: dict[str, Any] | None = None,
|
|
132
|
+
json_body: Any | None = None,
|
|
133
|
+
idempotency_key: str | None = None,
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Send a request and return the parsed JSON response body.
|
|
136
|
+
|
|
137
|
+
Returns ``None`` for 204 responses; raises a typed
|
|
138
|
+
:class:`ApiError` subclass on every non-2xx status code that
|
|
139
|
+
survives the retry policy.
|
|
140
|
+
"""
|
|
141
|
+
url = f"{self._base_url}{path}"
|
|
142
|
+
headers = self._build_headers(idempotency_key)
|
|
143
|
+
|
|
144
|
+
retrying = AsyncRetrying(
|
|
145
|
+
stop=stop_after_attempt(self._max_retries + 1),
|
|
146
|
+
wait=wait_random_exponential(multiplier=0.25, max=20),
|
|
147
|
+
retry=retry_if_exception_type(_RETRYABLE_STATUS_CODES_TYPE),
|
|
148
|
+
reraise=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
response: httpx.Response | None = None
|
|
152
|
+
try:
|
|
153
|
+
async for attempt in retrying:
|
|
154
|
+
with attempt:
|
|
155
|
+
response = await self._send_once(
|
|
156
|
+
method, url, headers=headers, params=params, json_body=json_body
|
|
157
|
+
)
|
|
158
|
+
if response.status_code in _RETRYABLE_STATUS_CODES:
|
|
159
|
+
# Retryable status — wrap in our sentinel and let
|
|
160
|
+
# tenacity decide. Don't drain the body until we're
|
|
161
|
+
# ready to surface the error, so the next attempt
|
|
162
|
+
# gets a clean response object.
|
|
163
|
+
raise _RetryableStatusError(response)
|
|
164
|
+
except RetryError as exc:
|
|
165
|
+
# Should not happen with reraise=True, but keep the branch for
|
|
166
|
+
# static analyzers that don't see through tenacity's internals.
|
|
167
|
+
last_result = exc.last_attempt.result()
|
|
168
|
+
assert isinstance(last_result, httpx.Response)
|
|
169
|
+
raise self._build_error_from_response(last_result) from exc
|
|
170
|
+
except _RetryableStatusError as exc:
|
|
171
|
+
raise self._build_error_from_response(exc.response) from exc
|
|
172
|
+
|
|
173
|
+
# ``response`` is guaranteed to be set: tenacity raised on each
|
|
174
|
+
# failed attempt; on success we fell through with the assignment.
|
|
175
|
+
assert response is not None
|
|
176
|
+
|
|
177
|
+
if response.status_code >= 400:
|
|
178
|
+
raise self._build_error_from_response(response)
|
|
179
|
+
if response.status_code == 204 or not response.content:
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
return response.json()
|
|
183
|
+
except json.JSONDecodeError as exc: # pragma: no cover — server contract violation
|
|
184
|
+
raise ApiError(
|
|
185
|
+
response.status_code,
|
|
186
|
+
f"response body did not parse as JSON: {exc}",
|
|
187
|
+
request_id=response.headers.get("x-request-id"),
|
|
188
|
+
) from exc
|
|
189
|
+
|
|
190
|
+
async def aclose(self) -> None:
|
|
191
|
+
"""Close the underlying ``httpx.AsyncClient`` if we own it."""
|
|
192
|
+
if self._owns_client:
|
|
193
|
+
await self._client.aclose()
|
|
194
|
+
|
|
195
|
+
# ----------------------------------------------------------------- private
|
|
196
|
+
|
|
197
|
+
def _build_headers(self, idempotency_key: str | None) -> dict[str, str]:
|
|
198
|
+
headers = {
|
|
199
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
200
|
+
"User-Agent": self._user_agent,
|
|
201
|
+
"Accept": "application/json",
|
|
202
|
+
}
|
|
203
|
+
if idempotency_key is not None:
|
|
204
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
205
|
+
return headers
|
|
206
|
+
|
|
207
|
+
async def _send_once(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
url: str,
|
|
211
|
+
*,
|
|
212
|
+
headers: dict[str, str],
|
|
213
|
+
params: dict[str, Any] | None,
|
|
214
|
+
json_body: Any | None,
|
|
215
|
+
) -> httpx.Response:
|
|
216
|
+
try:
|
|
217
|
+
return await self._client.request(
|
|
218
|
+
method,
|
|
219
|
+
url,
|
|
220
|
+
params=_drop_none_params(params),
|
|
221
|
+
json=json_body,
|
|
222
|
+
headers=headers,
|
|
223
|
+
timeout=self._timeout,
|
|
224
|
+
)
|
|
225
|
+
except _RETRYABLE_HTTPX_EXCEPTIONS as exc:
|
|
226
|
+
# tenacity sees the raised exception type and will retry — we
|
|
227
|
+
# log at DEBUG so production deployments don't drown in transient
|
|
228
|
+
# network noise.
|
|
229
|
+
self._logger.debug(
|
|
230
|
+
"donations-embed: transport error on %s %s: %s",
|
|
231
|
+
method,
|
|
232
|
+
url,
|
|
233
|
+
exc,
|
|
234
|
+
)
|
|
235
|
+
raise
|
|
236
|
+
|
|
237
|
+
def _build_error_from_response(self, response: httpx.Response) -> ApiError:
|
|
238
|
+
"""Map a 4xx / 5xx response onto the right :class:`ApiError` subclass."""
|
|
239
|
+
status = response.status_code
|
|
240
|
+
request_id = response.headers.get("x-request-id")
|
|
241
|
+
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
242
|
+
|
|
243
|
+
code: str | None = None
|
|
244
|
+
message: str = response.reason_phrase or f"HTTP {status}"
|
|
245
|
+
details: dict[str, Any] | None = None
|
|
246
|
+
try:
|
|
247
|
+
payload = response.json()
|
|
248
|
+
if isinstance(payload, dict):
|
|
249
|
+
raw_code = payload.get("code")
|
|
250
|
+
raw_message = payload.get("message")
|
|
251
|
+
raw_details = payload.get("details")
|
|
252
|
+
if isinstance(raw_code, str):
|
|
253
|
+
code = raw_code
|
|
254
|
+
if isinstance(raw_message, str):
|
|
255
|
+
message = raw_message
|
|
256
|
+
if isinstance(raw_details, dict):
|
|
257
|
+
details = raw_details
|
|
258
|
+
except (json.JSONDecodeError, ValueError): # pragma: no cover — non-JSON error body
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
common: dict[str, Any] = {
|
|
262
|
+
"code": code,
|
|
263
|
+
"details": details,
|
|
264
|
+
"request_id": request_id,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if status == 401:
|
|
268
|
+
return AuthError(status, message, **common)
|
|
269
|
+
if status == 403:
|
|
270
|
+
return ForbiddenError(status, message, **common)
|
|
271
|
+
if status == 404:
|
|
272
|
+
return NotFoundError(status, message, **common)
|
|
273
|
+
if status in (400, 422):
|
|
274
|
+
return ValidationError(status, message, **common)
|
|
275
|
+
if status == 409:
|
|
276
|
+
return ConflictError(status, message, **common)
|
|
277
|
+
if status == 410:
|
|
278
|
+
return GoneError(status, message, **common)
|
|
279
|
+
if status == 429:
|
|
280
|
+
return RateLimitError(status, message, retry_after=retry_after, **common)
|
|
281
|
+
if status == 502:
|
|
282
|
+
return BadGatewayError(status, message, **common)
|
|
283
|
+
if status == 503:
|
|
284
|
+
return ServiceUnavailableError(status, message, **common)
|
|
285
|
+
return ApiError(status, message, **common)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _drop_none_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
289
|
+
"""Strip ``None`` values from query params so they aren't serialized as the literal string ``"None"``."""
|
|
290
|
+
if params is None:
|
|
291
|
+
return None
|
|
292
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
296
|
+
"""Parse a numeric ``Retry-After`` value into seconds.
|
|
297
|
+
|
|
298
|
+
The HTTP spec also allows an HTTP-date format. We accept seconds (as
|
|
299
|
+
integer or float) and silently return ``None`` for date-based values
|
|
300
|
+
rather than synthesizing a synthetic delay — callers can pivot on
|
|
301
|
+
``RateLimitError.retry_after is None`` to fall back to a default.
|
|
302
|
+
"""
|
|
303
|
+
if value is None:
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
return float(value.strip())
|
|
307
|
+
except ValueError:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# Tuple form for tenacity's ``retry_if_exception_type`` predicate. We have
|
|
312
|
+
# to define this at module scope because tenacity inspects it at decorator-
|
|
313
|
+
# build time, and we want network errors AND our retryable-status sentinel
|
|
314
|
+
# both to trigger a retry.
|
|
315
|
+
_RETRYABLE_STATUS_CODES_TYPE: tuple[type[BaseException], ...] = (
|
|
316
|
+
_RetryableStatusError,
|
|
317
|
+
*_RETRYABLE_HTTPX_EXCEPTIONS,
|
|
318
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Idempotency-key helper.
|
|
2
|
+
|
|
3
|
+
The platform's idempotency contract: replaying a request with the same
|
|
4
|
+
``Idempotency-Key`` returns the original response without performing the
|
|
5
|
+
side effect a second time. Keys are scoped per (``api_key``, ``endpoint``)
|
|
6
|
+
and persisted for 24 hours.
|
|
7
|
+
|
|
8
|
+
This module exposes :func:`idempotency_key`, a tiny convenience wrapper
|
|
9
|
+
that prefixes a fresh UUID4 with a use-case tag so observability tooling
|
|
10
|
+
can group related retries (e.g. all ``refund:*`` keys belong to the refund
|
|
11
|
+
flow). Callers MAY also supply their own keys directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
# Match the OpenAPI ``Idempotency-Key`` schema constraint (max 255 chars).
|
|
20
|
+
_MAX_KEY_LENGTH = 255
|
|
21
|
+
|
|
22
|
+
# Conservative validation for the use-case tag — keep it printable ASCII
|
|
23
|
+
# so the resulting key fits cleanly in HTTP headers and log lines.
|
|
24
|
+
_USE_CASE_RE = re.compile(r"^[a-z0-9][a-z0-9_\-.]*$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def idempotency_key(use_case: str) -> str:
|
|
28
|
+
"""Generate a fresh idempotency key tagged with a use-case prefix.
|
|
29
|
+
|
|
30
|
+
The returned key has the form ``"<use_case>:<uuid4>"``, e.g.
|
|
31
|
+
``"refund:1f2dab36-2a55-4f11-bdc8-2a2a4a95b001"``.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
use_case: A short tag describing the operation
|
|
35
|
+
(``"refund"``, ``"cancel-recurring"``, ``"replay-confirm"``).
|
|
36
|
+
Must be lowercase ASCII letters, digits, ``_``, ``-``, or ``.``;
|
|
37
|
+
an empty or oddly-shaped tag raises :class:`ValueError`.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A string suitable for the ``Idempotency-Key`` HTTP header.
|
|
41
|
+
"""
|
|
42
|
+
if not use_case or not _USE_CASE_RE.match(use_case):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
"idempotency use_case must be a non-empty lowercase ASCII tag "
|
|
45
|
+
"matching ^[a-z0-9][a-z0-9_\\-.]*$"
|
|
46
|
+
)
|
|
47
|
+
key = f"{use_case}:{uuid.uuid4()}"
|
|
48
|
+
if len(key) > _MAX_KEY_LENGTH:
|
|
49
|
+
# Practically impossible (use_case + UUID4 is well under 255), but
|
|
50
|
+
# we enforce the contract explicitly so a future caller can't blow
|
|
51
|
+
# past the limit by passing a 250-char tag.
|
|
52
|
+
raise ValueError(f"generated idempotency key exceeds {_MAX_KEY_LENGTH} chars")
|
|
53
|
+
return key
|