meterflow 0.6.0__tar.gz → 0.7.0__tar.gz
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.
- {meterflow-0.6.0 → meterflow-0.7.0}/PKG-INFO +11 -5
- {meterflow-0.6.0 → meterflow-0.7.0}/README.md +10 -4
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/__init__.py +6 -0
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/_entitlement_cache.py +11 -2
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/_http.py +51 -13
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/_retry.py +19 -7
- meterflow-0.7.0/src/meterflow/_version.py +2 -0
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/client.py +55 -17
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/errors.py +25 -15
- meterflow-0.7.0/src/meterflow/resources/__init__.py +1 -0
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/credits.py +95 -29
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/entitlements.py +33 -20
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/plans.py +23 -5
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/portal.py +2 -4
- meterflow-0.7.0/src/meterflow/resources/subscriptions.py +190 -0
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/usage.py +59 -22
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/webhooks.py +27 -32
- meterflow-0.7.0/src/meterflow/webhook.py +22 -0
- meterflow-0.6.0/src/meterflow/_version.py +0 -3
- meterflow-0.6.0/src/meterflow/resources/__init__.py +0 -1
- meterflow-0.6.0/src/meterflow/resources/subscriptions.py +0 -124
- meterflow-0.6.0/src/meterflow/webhook.py +0 -21
- {meterflow-0.6.0 → meterflow-0.7.0}/LICENSE +0 -0
- {meterflow-0.6.0 → meterflow-0.7.0}/pyproject.toml +0 -0
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/py.typed +0 -0
- {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: meterflow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Official Python SDK for MeterFlow — usage-based billing, credit management, and metering.
|
|
5
5
|
Project-URL: Homepage, https://meter-flow.com
|
|
6
6
|
Project-URL: Documentation, https://meter-flow.com/guide
|
|
@@ -264,10 +264,14 @@ while True:
|
|
|
264
264
|
cursor = page["next_cursor"]
|
|
265
265
|
if cursor is None:
|
|
266
266
|
break
|
|
267
|
+
|
|
268
|
+
# Another order: `sort` is "created_at" (the default) or "amount", `order` "desc" (the default) or "asc".
|
|
269
|
+
largest_first = client.credits.transactions("customer_123", sort="amount", order="desc")
|
|
267
270
|
```
|
|
268
271
|
|
|
269
|
-
> **The cursor is opaque.** Pass back exactly what `next_cursor` gave you
|
|
270
|
-
>
|
|
272
|
+
> **The cursor is opaque.** Pass back exactly what `next_cursor` gave you, with the same `sort` and `order`
|
|
273
|
+
> (another order with it is a `ValidationError`); do not build one, and do not read anything out of it.
|
|
274
|
+
> `limit` is 1–200 and defaults to 50.
|
|
271
275
|
|
|
272
276
|
> **Amounts are decimal strings.** Balances and amounts come back as strings (`"3545.000000"`) to avoid floating-point drift on money-like values. Parse deliberately (`decimal.Decimal(...)`) when you need arithmetic. When *sending* an amount, a number or a numeric string are both accepted.
|
|
273
277
|
|
|
@@ -303,12 +307,13 @@ Record an event every time a customer does the thing you charge for. Events are
|
|
|
303
307
|
**Record one event:**
|
|
304
308
|
|
|
305
309
|
```python
|
|
310
|
+
|
|
306
311
|
client.usage.record({
|
|
307
312
|
"event_name": "images.generated",
|
|
308
313
|
"customer_external_id": "customer_123",
|
|
309
314
|
"value": 1, # optional — what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
|
|
310
315
|
"properties": {}, # free-form context; {} when unused
|
|
311
|
-
"timestamp":
|
|
316
|
+
# "timestamp": leave it out — the API stamps arrival time; one more than 300 s old or 60 s ahead is refused (422)
|
|
312
317
|
})
|
|
313
318
|
```
|
|
314
319
|
|
|
@@ -410,6 +415,7 @@ page = client.subscriptions.list() # whole project
|
|
|
410
415
|
theirs = client.subscriptions.list(customer_id="customer_123") # one customer
|
|
411
416
|
more = client.subscriptions.list(limit=200, cursor=page["next_cursor"])
|
|
412
417
|
behind = client.subscriptions.list(q="acme", status=["past_due", "paused"]) # ids containing "acme", in either status
|
|
418
|
+
by_status = client.subscriptions.list(sort="status", order="asc") # or "created_at" (default), "customer_external_id"
|
|
413
419
|
|
|
414
420
|
for subscription in page["data"]:
|
|
415
421
|
print(subscription["customer_external_id"], subscription["status"])
|
|
@@ -670,7 +676,7 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
|
|
|
670
676
|
|---|---|---|
|
|
671
677
|
| `usage.recorded` | a usage event is ingested (single or batch) | `customer_id`, `event_name`, `value`, `event_id` |
|
|
672
678
|
| `credit.granted` | credits are granted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
|
|
673
|
-
| `credit.deducted` | credits are deducted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
|
|
679
|
+
| `credit.deducted` | credits are deducted: by a deduct call, or by a metered usage charge | `customer_id`, `amount`, `balance_after`, `transaction_id`, `transaction_type` (`deduct` or `usage`), `usage_event_id` (the billed event for `usage`, else null) |
|
|
674
680
|
| `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
|
|
675
681
|
| `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
|
|
676
682
|
| `limit.reached` | a customer first reaches a **hard** or **soft** limit's included units this billing period (once per meter per period; metered limits report through `credit.deducted` instead) | `customer_id`, `subscription_id`, `feature` (the meter's `event_name`), `meter_id`, `limit_type`, `included`, `used`, `period_start`, `period_end` |
|
|
@@ -212,10 +212,14 @@ while True:
|
|
|
212
212
|
cursor = page["next_cursor"]
|
|
213
213
|
if cursor is None:
|
|
214
214
|
break
|
|
215
|
+
|
|
216
|
+
# Another order: `sort` is "created_at" (the default) or "amount", `order` "desc" (the default) or "asc".
|
|
217
|
+
largest_first = client.credits.transactions("customer_123", sort="amount", order="desc")
|
|
215
218
|
```
|
|
216
219
|
|
|
217
|
-
> **The cursor is opaque.** Pass back exactly what `next_cursor` gave you
|
|
218
|
-
>
|
|
220
|
+
> **The cursor is opaque.** Pass back exactly what `next_cursor` gave you, with the same `sort` and `order`
|
|
221
|
+
> (another order with it is a `ValidationError`); do not build one, and do not read anything out of it.
|
|
222
|
+
> `limit` is 1–200 and defaults to 50.
|
|
219
223
|
|
|
220
224
|
> **Amounts are decimal strings.** Balances and amounts come back as strings (`"3545.000000"`) to avoid floating-point drift on money-like values. Parse deliberately (`decimal.Decimal(...)`) when you need arithmetic. When *sending* an amount, a number or a numeric string are both accepted.
|
|
221
225
|
|
|
@@ -251,12 +255,13 @@ Record an event every time a customer does the thing you charge for. Events are
|
|
|
251
255
|
**Record one event:**
|
|
252
256
|
|
|
253
257
|
```python
|
|
258
|
+
|
|
254
259
|
client.usage.record({
|
|
255
260
|
"event_name": "images.generated",
|
|
256
261
|
"customer_external_id": "customer_123",
|
|
257
262
|
"value": 1, # optional — what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
|
|
258
263
|
"properties": {}, # free-form context; {} when unused
|
|
259
|
-
"timestamp":
|
|
264
|
+
# "timestamp": leave it out — the API stamps arrival time; one more than 300 s old or 60 s ahead is refused (422)
|
|
260
265
|
})
|
|
261
266
|
```
|
|
262
267
|
|
|
@@ -358,6 +363,7 @@ page = client.subscriptions.list() # whole project
|
|
|
358
363
|
theirs = client.subscriptions.list(customer_id="customer_123") # one customer
|
|
359
364
|
more = client.subscriptions.list(limit=200, cursor=page["next_cursor"])
|
|
360
365
|
behind = client.subscriptions.list(q="acme", status=["past_due", "paused"]) # ids containing "acme", in either status
|
|
366
|
+
by_status = client.subscriptions.list(sort="status", order="asc") # or "created_at" (default), "customer_external_id"
|
|
361
367
|
|
|
362
368
|
for subscription in page["data"]:
|
|
363
369
|
print(subscription["customer_external_id"], subscription["status"])
|
|
@@ -618,7 +624,7 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
|
|
|
618
624
|
|---|---|---|
|
|
619
625
|
| `usage.recorded` | a usage event is ingested (single or batch) | `customer_id`, `event_name`, `value`, `event_id` |
|
|
620
626
|
| `credit.granted` | credits are granted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
|
|
621
|
-
| `credit.deducted` | credits are deducted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
|
|
627
|
+
| `credit.deducted` | credits are deducted: by a deduct call, or by a metered usage charge | `customer_id`, `amount`, `balance_after`, `transaction_id`, `transaction_type` (`deduct` or `usage`), `usage_event_id` (the billed event for `usage`, else null) |
|
|
622
628
|
| `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
|
|
623
629
|
| `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
|
|
624
630
|
| `limit.reached` | a customer first reaches a **hard** or **soft** limit's included units this billing period (once per meter per period; metered limits report through `credit.deducted` instead) | `customer_id`, `subscription_id`, `feature` (the meter's `event_name`), `meter_id`, `limit_type`, `included`, `used`, `period_start`, `period_end` |
|
|
@@ -7,6 +7,7 @@ client.credits.grant({"customer_external_id": "customer_123", "amount": 5000, "m
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from ._entitlement_cache import EntitlementDefault, EntitlementFallbackContext
|
|
10
|
+
from ._http import SortOrder
|
|
10
11
|
from ._version import __version__
|
|
11
12
|
from .client import AsyncMeterFlow, ClientOptions, MeterFlow
|
|
12
13
|
from .errors import (
|
|
@@ -21,7 +22,9 @@ from .errors import (
|
|
|
21
22
|
ValidationError,
|
|
22
23
|
ValidationFieldError,
|
|
23
24
|
)
|
|
25
|
+
from .resources.credits import TransactionSort
|
|
24
26
|
from .resources.entitlements import EntitlementCheck, EntitlementList
|
|
27
|
+
from .resources.subscriptions import SubscriptionSort
|
|
25
28
|
from .resources.usage import MAX_BATCH_EVENTS
|
|
26
29
|
from .webhook import verify_webhook
|
|
27
30
|
|
|
@@ -45,5 +48,8 @@ __all__ = [
|
|
|
45
48
|
"EntitlementList",
|
|
46
49
|
"EntitlementDefault",
|
|
47
50
|
"EntitlementFallbackContext",
|
|
51
|
+
"SortOrder",
|
|
52
|
+
"SubscriptionSort",
|
|
53
|
+
"TransactionSort",
|
|
48
54
|
"verify_webhook",
|
|
49
55
|
]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The entitlement answers one client keeps, and the rules for falling back on them.
|
|
1
|
+
"""The entitlement answers one client keeps, and the rules for falling back on them.
|
|
2
2
|
|
|
3
3
|
Each answer is fresh for the ``max-age`` the API sends and kept past it as the fallback for an outage.
|
|
4
4
|
Bounded, least recently used out first.
|
|
@@ -54,6 +54,13 @@ class CacheRead:
|
|
|
54
54
|
|
|
55
55
|
@dataclass
|
|
56
56
|
class _CachedAnswer:
|
|
57
|
+
"""One stored answer.
|
|
58
|
+
|
|
59
|
+
customer_id: whose answer it is.
|
|
60
|
+
value: the answer itself.
|
|
61
|
+
fresh_until: the clock reading after which it is no longer fresh; it is still kept as a fallback.
|
|
62
|
+
"""
|
|
63
|
+
|
|
57
64
|
customer_id: str
|
|
58
65
|
value: Any
|
|
59
66
|
fresh_until: float
|
|
@@ -71,7 +78,9 @@ def max_age_seconds(header: str | None) -> int:
|
|
|
71
78
|
|
|
72
79
|
|
|
73
80
|
def is_outage(error: BaseException) -> bool:
|
|
74
|
-
"""Whether a failed call is MeterFlow being unreachable rather than an answer: no response
|
|
81
|
+
"""Whether a failed call is MeterFlow being unreachable rather than an answer: no response, a 5xx after the retries, or a 429.
|
|
82
|
+
|
|
83
|
+
A 401, 403, 404 or 422 is an answer.
|
|
75
84
|
|
|
76
85
|
error: what the call raised.
|
|
77
86
|
"""
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
"""The HTTP layer both clients share:
|
|
1
|
+
"""The HTTP layer both clients share: headers, query parameters, reading error answers and choosing the error class.
|
|
2
2
|
|
|
3
|
-
Only the
|
|
4
|
-
everything they call lives here, once. Mirrors ``sdks/node/src/utils/http.ts``.
|
|
3
|
+
Only the waiting differs between the sync and async clients, so their request loops live in ``client.py``.
|
|
5
4
|
"""
|
|
6
5
|
|
|
7
6
|
from __future__ import annotations
|
|
@@ -11,7 +10,7 @@ import json
|
|
|
11
10
|
import time
|
|
12
11
|
from collections.abc import Sequence
|
|
13
12
|
from dataclasses import dataclass, field
|
|
14
|
-
from typing import Any
|
|
13
|
+
from typing import Any, Literal
|
|
15
14
|
from urllib.parse import quote
|
|
16
15
|
|
|
17
16
|
import httpx
|
|
@@ -34,7 +33,7 @@ USER_AGENT = f"meterflow-python/{__version__}"
|
|
|
34
33
|
|
|
35
34
|
QueryValue = str | int | float | bool | Sequence[str] | None
|
|
36
35
|
|
|
37
|
-
# Indirections so tests can make the retry loop
|
|
36
|
+
# Indirections, so tests can make the retry loop instant.
|
|
38
37
|
_sleep = time.sleep
|
|
39
38
|
|
|
40
39
|
|
|
@@ -42,9 +41,21 @@ async def _async_sleep(seconds: float) -> None:
|
|
|
42
41
|
await asyncio.sleep(seconds)
|
|
43
42
|
|
|
44
43
|
|
|
44
|
+
# Which way a list is ordered: ``desc`` (each list's default) or ``asc``.
|
|
45
|
+
SortOrder = Literal["asc", "desc"]
|
|
46
|
+
|
|
47
|
+
|
|
45
48
|
@dataclass(frozen=True)
|
|
46
49
|
class RequestSpec:
|
|
47
|
-
"""One API call, described by a resource method and
|
|
50
|
+
"""One API call, described by a resource method and made by a client.
|
|
51
|
+
|
|
52
|
+
method: the HTTP method.
|
|
53
|
+
path: the endpoint, relative to the base URL.
|
|
54
|
+
body: the JSON body; None for none.
|
|
55
|
+
query: the query parameters; a None one is not sent.
|
|
56
|
+
idempotency_key: sent as the ``Idempotency-Key`` header.
|
|
57
|
+
timeout: this call's timeout in seconds; None for the client's.
|
|
58
|
+
"""
|
|
48
59
|
|
|
49
60
|
method: str
|
|
50
61
|
path: str
|
|
@@ -55,11 +66,19 @@ class RequestSpec:
|
|
|
55
66
|
|
|
56
67
|
|
|
57
68
|
def encode_path_segment(value: str) -> str:
|
|
58
|
-
"""
|
|
69
|
+
"""A path segment, percent-encoded: a customer id is any string (an email, ``user/42``) and must never split the path.
|
|
70
|
+
|
|
71
|
+
value: the id to encode.
|
|
72
|
+
"""
|
|
59
73
|
return quote(value, safe="")
|
|
60
74
|
|
|
61
75
|
|
|
62
76
|
def build_headers(api_key: str, spec: RequestSpec) -> dict[str, str]:
|
|
77
|
+
"""The request's headers: the key, the SDK's User-Agent, JSON, and the idempotency key when there is one.
|
|
78
|
+
|
|
79
|
+
api_key: the client's key.
|
|
80
|
+
spec: the call.
|
|
81
|
+
"""
|
|
63
82
|
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
64
83
|
if spec.body is not None:
|
|
65
84
|
headers["Content-Type"] = "application/json"
|
|
@@ -69,7 +88,10 @@ def build_headers(api_key: str, spec: RequestSpec) -> dict[str, str]:
|
|
|
69
88
|
|
|
70
89
|
|
|
71
90
|
def build_params(query: dict[str, QueryValue]) -> dict[str, str | list[str]]:
|
|
72
|
-
"""
|
|
91
|
+
"""The query parameters as sent: a None one is left out, a boolean is ``true`` / ``false``, a list repeats its key.
|
|
92
|
+
|
|
93
|
+
query: the call's parameters.
|
|
94
|
+
"""
|
|
73
95
|
params: dict[str, str | list[str]] = {}
|
|
74
96
|
for key, value in query.items():
|
|
75
97
|
if value is None:
|
|
@@ -85,19 +107,24 @@ def build_params(query: dict[str, QueryValue]) -> dict[str, str | list[str]]:
|
|
|
85
107
|
|
|
86
108
|
@dataclass(frozen=True)
|
|
87
109
|
class ParsedError:
|
|
110
|
+
"""An error answer's message, and the fields a 422 named."""
|
|
111
|
+
|
|
88
112
|
message: str
|
|
89
113
|
fields: list[ValidationFieldError]
|
|
90
114
|
|
|
91
115
|
|
|
92
116
|
def _is_field_error(value: object) -> bool:
|
|
117
|
+
"""Whether a value is one ``{field, message}`` entry of a 422's ``fields``.
|
|
118
|
+
|
|
119
|
+
value: one entry, as parsed.
|
|
120
|
+
"""
|
|
93
121
|
return isinstance(value, dict) and isinstance(value.get("field"), str) and isinstance(value.get("message"), str)
|
|
94
122
|
|
|
95
123
|
|
|
96
124
|
def parse_error_body(response: httpx.Response) -> ParsedError:
|
|
97
|
-
"""
|
|
125
|
+
"""Read an error answer, ``{"error": {"code", "message", "fields"?}}``; a bare ``{"detail"}`` or any other body still gives a message.
|
|
98
126
|
|
|
99
|
-
|
|
100
|
-
yields a readable message rather than a JSON blob.
|
|
127
|
+
response: the failed response.
|
|
101
128
|
"""
|
|
102
129
|
try:
|
|
103
130
|
payload = response.json()
|
|
@@ -124,6 +151,10 @@ def parse_error_body(response: httpx.Response) -> ParsedError:
|
|
|
124
151
|
|
|
125
152
|
|
|
126
153
|
def map_response_error(response: httpx.Response) -> MeterFlowError:
|
|
154
|
+
"""The error class for a failed response's status, carrying its message, request id and, for a 429, its ``Retry-After``.
|
|
155
|
+
|
|
156
|
+
response: the failed response.
|
|
157
|
+
"""
|
|
127
158
|
status = response.status_code
|
|
128
159
|
parsed = parse_error_body(response)
|
|
129
160
|
request_id = response.headers.get("x-request-id")
|
|
@@ -150,14 +181,21 @@ def map_response_error(response: httpx.Response) -> MeterFlowError:
|
|
|
150
181
|
|
|
151
182
|
|
|
152
183
|
def decode_response(response: httpx.Response) -> Any:
|
|
153
|
-
"""
|
|
184
|
+
"""A successful response's JSON body; None for a 204 or an empty body.
|
|
185
|
+
|
|
186
|
+
response: the successful response.
|
|
187
|
+
"""
|
|
154
188
|
if response.status_code == 204 or not response.content:
|
|
155
189
|
return None
|
|
156
190
|
return response.json()
|
|
157
191
|
|
|
158
192
|
|
|
159
193
|
def request_kwargs(api_key: str, spec: RequestSpec) -> dict[str, Any]:
|
|
160
|
-
"""Everything ``httpx.Client.request`` / ``AsyncClient.request`` needs beyond the method and path.
|
|
194
|
+
"""Everything ``httpx.Client.request`` / ``AsyncClient.request`` needs beyond the method and path.
|
|
195
|
+
|
|
196
|
+
api_key: the client's key.
|
|
197
|
+
spec: the call.
|
|
198
|
+
"""
|
|
161
199
|
kwargs: dict[str, Any] = {"headers": build_headers(api_key, spec), "params": build_params(spec.query)}
|
|
162
200
|
if spec.body is not None:
|
|
163
201
|
kwargs["content"] = json.dumps(spec.body).encode("utf-8")
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The retry policy both clients use: it decides how long to wait, and the client does the waiting.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
The clients do the sleeping (``time.sleep`` vs ``asyncio.sleep``), so the backoff maths lives once.
|
|
5
|
-
Mirrors the Node SDK: exponential base (1 s × factor^attempt, capped), ±25 % jitter, and a 429's
|
|
6
|
-
``Retry-After`` taken verbatim instead of the computed delay.
|
|
3
|
+
Exponential backoff (1 s × factor^attempt, capped) with ±25 % jitter; a 429 waits its ``Retry-After`` instead.
|
|
7
4
|
"""
|
|
8
5
|
|
|
9
6
|
from __future__ import annotations
|
|
@@ -16,19 +13,34 @@ from .errors import MeterFlowError, RateLimitError
|
|
|
16
13
|
|
|
17
14
|
@dataclass(frozen=True)
|
|
18
15
|
class RetryPolicy:
|
|
16
|
+
"""How many times a failed request is tried again, and the backoff between attempts.
|
|
17
|
+
|
|
18
|
+
retries: the attempts after the first.
|
|
19
|
+
factor: the backoff's growth per attempt.
|
|
20
|
+
jitter: whether each delay is spread by ±25 %.
|
|
21
|
+
max_delay_seconds: the longest computed delay.
|
|
22
|
+
"""
|
|
23
|
+
|
|
19
24
|
retries: int = 3
|
|
20
25
|
factor: float = 2.0
|
|
21
26
|
jitter: bool = True
|
|
22
27
|
max_delay_seconds: float = 10.0
|
|
23
28
|
|
|
24
29
|
def should_retry(self, error: BaseException) -> bool:
|
|
25
|
-
"""
|
|
30
|
+
"""Whether a retry can help: an API error says so itself; anything else is a network failure, which can.
|
|
31
|
+
|
|
32
|
+
error: what the attempt raised.
|
|
33
|
+
"""
|
|
26
34
|
if isinstance(error, MeterFlowError):
|
|
27
35
|
return error.retryable
|
|
28
36
|
return True
|
|
29
37
|
|
|
30
38
|
def delay_before_retry(self, attempt: int, error: BaseException) -> float | None:
|
|
31
|
-
"""Seconds to wait before
|
|
39
|
+
"""Seconds to wait before the next attempt, or ``None`` when the caller must re-raise.
|
|
40
|
+
|
|
41
|
+
attempt: the attempt that failed, from 0.
|
|
42
|
+
error: what it raised.
|
|
43
|
+
"""
|
|
32
44
|
is_last = attempt >= self.retries
|
|
33
45
|
if is_last or not self.should_retry(error):
|
|
34
46
|
return None
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
"""``MeterFlow`` (sync) and ``AsyncMeterFlow
|
|
2
|
-
|
|
3
|
-
Resources describe a call as a ``RequestSpec``; the client executes it with retries. Everything that
|
|
4
|
-
does not depend on *how* you wait lives in ``_http.py`` and ``_retry.py``, so the two clients are
|
|
5
|
-
thin and cannot drift apart.
|
|
6
|
-
"""
|
|
1
|
+
"""``MeterFlow`` (sync) and ``AsyncMeterFlow``: the same options, resources and errors."""
|
|
7
2
|
|
|
8
3
|
from __future__ import annotations
|
|
9
4
|
|
|
@@ -34,7 +29,16 @@ KEY_PREFIXES = ("mf_live_", "mf_test_")
|
|
|
34
29
|
|
|
35
30
|
@dataclass(frozen=True)
|
|
36
31
|
class ClientOptions:
|
|
37
|
-
"""The
|
|
32
|
+
"""The options in force, with every default filled in, as ``client.options``.
|
|
33
|
+
|
|
34
|
+
api_key: the client's key.
|
|
35
|
+
base_url: the API root, ending in ``/``.
|
|
36
|
+
timeout: one attempt's timeout, in seconds.
|
|
37
|
+
retries: the attempts after the first.
|
|
38
|
+
entitlements_cache: whether entitlement answers are reused.
|
|
39
|
+
entitlement_defaults: what ``check()`` answers per feature while MeterFlow is unreachable.
|
|
40
|
+
on_error: told when an entitlements call answers from a fallback.
|
|
41
|
+
"""
|
|
38
42
|
|
|
39
43
|
api_key: str
|
|
40
44
|
base_url: str
|
|
@@ -46,6 +50,8 @@ class ClientOptions:
|
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
class _BaseClient:
|
|
53
|
+
"""What both clients share: the key check, the options, the retry policy and the entitlement cache."""
|
|
54
|
+
|
|
49
55
|
def __init__(
|
|
50
56
|
self,
|
|
51
57
|
api_key: str,
|
|
@@ -68,20 +74,34 @@ class _BaseClient:
|
|
|
68
74
|
on_error=on_error,
|
|
69
75
|
)
|
|
70
76
|
self._retry = RetryPolicy(retries=self.options.retries)
|
|
71
|
-
#
|
|
77
|
+
# The entitlement answers this client has read; None when ``entitlements_cache`` is False.
|
|
72
78
|
self.entitlement_cache = EntitlementCache() if entitlements_cache else None
|
|
73
79
|
|
|
74
80
|
def _kwargs(self, spec: RequestSpec) -> dict[str, Any]:
|
|
81
|
+
"""The arguments ``httpx`` needs for a call.
|
|
82
|
+
|
|
83
|
+
spec: the call.
|
|
84
|
+
"""
|
|
75
85
|
return request_kwargs(self.options.api_key, spec)
|
|
76
86
|
|
|
77
87
|
|
|
78
88
|
class MeterFlow(_BaseClient):
|
|
79
|
-
"""
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
"""The MeterFlow client, synchronous: one per API key, with an attribute per resource.
|
|
90
|
+
|
|
91
|
+
Use it in a ``with`` block, or call ``close()``, to release its connections. Raises ``ValueError`` when the key is
|
|
92
|
+
neither ``mf_live_…`` nor ``mf_test_…``.
|
|
93
|
+
|
|
94
|
+
api_key: your secret API key: ``mf_live_…`` reads and writes live data, ``mf_test_…`` test data. Keep it on your server.
|
|
95
|
+
base_url: the API root, ``https://api.meter-flow.com/api/v1`` by default; change it only for another MeterFlow server.
|
|
96
|
+
timeout: how long one attempt may take, in seconds (30 by default).
|
|
97
|
+
retries: how many times a failed request is tried again (3 by default; 0 turns retries off). Only network errors,
|
|
98
|
+
429s and 5xx are retried.
|
|
99
|
+
transport: an ``httpx`` transport to send through, for tests or a proxy.
|
|
100
|
+
entitlements_cache: reuse ``entitlements.check()`` / ``get()`` answers for the ``max-age`` the API sends (15 s today).
|
|
101
|
+
entitlement_defaults: per feature, what ``check()`` answers while MeterFlow is unreachable and nothing is cached;
|
|
102
|
+
``"*"`` covers every other feature.
|
|
103
|
+
on_error: called before an entitlements call answers from a fallback, with the error that caused it. What it raises
|
|
104
|
+
is ignored.
|
|
85
105
|
"""
|
|
86
106
|
|
|
87
107
|
def __init__(
|
|
@@ -107,7 +127,7 @@ class MeterFlow(_BaseClient):
|
|
|
107
127
|
self.webhooks = WebhooksResource(self)
|
|
108
128
|
|
|
109
129
|
def request(self, spec: RequestSpec, on_headers: Callable[[httpx.Headers], None] | None = None) -> Any:
|
|
110
|
-
"""Internal
|
|
130
|
+
"""Internal: used by the resources; not part of the public API.
|
|
111
131
|
|
|
112
132
|
spec: the call.
|
|
113
133
|
on_headers: given a successful response's headers; how the entitlements cache reads ``Cache-Control``.
|
|
@@ -128,6 +148,7 @@ class MeterFlow(_BaseClient):
|
|
|
128
148
|
raise AssertionError("unreachable") # pragma: no cover — `count()` never ends
|
|
129
149
|
|
|
130
150
|
def close(self) -> None:
|
|
151
|
+
"""Release the client's connections."""
|
|
131
152
|
self._http.close()
|
|
132
153
|
|
|
133
154
|
def __enter__(self) -> MeterFlow:
|
|
@@ -138,7 +159,23 @@ class MeterFlow(_BaseClient):
|
|
|
138
159
|
|
|
139
160
|
|
|
140
161
|
class AsyncMeterFlow(_BaseClient):
|
|
141
|
-
"""
|
|
162
|
+
"""The MeterFlow client, asynchronous: the same resources as ``MeterFlow``, every method awaitable.
|
|
163
|
+
|
|
164
|
+
Use it in an ``async with`` block, or call ``aclose()``, to release its connections. Raises ``ValueError`` when the
|
|
165
|
+
key is neither ``mf_live_…`` nor ``mf_test_…``.
|
|
166
|
+
|
|
167
|
+
api_key: your secret API key: ``mf_live_…`` reads and writes live data, ``mf_test_…`` test data. Keep it on your server.
|
|
168
|
+
base_url: the API root, ``https://api.meter-flow.com/api/v1`` by default; change it only for another MeterFlow server.
|
|
169
|
+
timeout: how long one attempt may take, in seconds (30 by default).
|
|
170
|
+
retries: how many times a failed request is tried again (3 by default; 0 turns retries off). Only network errors,
|
|
171
|
+
429s and 5xx are retried.
|
|
172
|
+
transport: an ``httpx`` async transport to send through, for tests or a proxy.
|
|
173
|
+
entitlements_cache: reuse ``entitlements.check()`` / ``get()`` answers for the ``max-age`` the API sends (15 s today).
|
|
174
|
+
entitlement_defaults: per feature, what ``check()`` answers while MeterFlow is unreachable and nothing is cached;
|
|
175
|
+
``"*"`` covers every other feature.
|
|
176
|
+
on_error: called before an entitlements call answers from a fallback, with the error that caused it. What it raises
|
|
177
|
+
is ignored.
|
|
178
|
+
"""
|
|
142
179
|
|
|
143
180
|
def __init__(
|
|
144
181
|
self,
|
|
@@ -163,7 +200,7 @@ class AsyncMeterFlow(_BaseClient):
|
|
|
163
200
|
self.webhooks = AsyncWebhooksResource(self)
|
|
164
201
|
|
|
165
202
|
async def request(self, spec: RequestSpec, on_headers: Callable[[httpx.Headers], None] | None = None) -> Any:
|
|
166
|
-
"""Internal
|
|
203
|
+
"""Internal: used by the resources; not part of the public API.
|
|
167
204
|
|
|
168
205
|
spec: the call.
|
|
169
206
|
on_headers: given a successful response's headers; how the entitlements cache reads ``Cache-Control``.
|
|
@@ -184,6 +221,7 @@ class AsyncMeterFlow(_BaseClient):
|
|
|
184
221
|
raise AssertionError("unreachable") # pragma: no cover — `count()` never ends
|
|
185
222
|
|
|
186
223
|
async def aclose(self) -> None:
|
|
224
|
+
"""Release the client's connections."""
|
|
187
225
|
await self._http.aclose()
|
|
188
226
|
|
|
189
227
|
async def __aenter__(self) -> AsyncMeterFlow:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The errors the SDK raises: one class per HTTP outcome.
|
|
2
2
|
|
|
3
|
-
Every error carries the API's ``X-Request-ID``
|
|
4
|
-
|
|
3
|
+
Every error carries the API's ``X-Request-ID`` as ``request_id`` (quote it to support), with ``status_code``,
|
|
4
|
+
``error_type`` and ``retryable``.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -10,13 +10,26 @@ from typing import TypedDict
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class ValidationFieldError(TypedDict):
|
|
13
|
-
"""One field the API rejected, as returned in a 422's ``error.fields``.
|
|
13
|
+
"""One field the API rejected, as returned in a 422's ``error.fields``.
|
|
14
|
+
|
|
15
|
+
field: the field's name in the request.
|
|
16
|
+
message: why it was refused.
|
|
17
|
+
"""
|
|
14
18
|
|
|
15
19
|
field: str
|
|
16
20
|
message: str
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
class MeterFlowError(Exception):
|
|
24
|
+
"""Every error the SDK raises for an API answer; each HTTP outcome has its own subclass.
|
|
25
|
+
|
|
26
|
+
message: what went wrong, with the refused fields for a 422.
|
|
27
|
+
error_type: a stable name for the kind of error: ``auth_error``, ``not_found``, ``rate_limit`` and so on.
|
|
28
|
+
retryable: whether the SDK retries this kind of error (it already has, by the time you see it).
|
|
29
|
+
request_id: the API's ``X-Request-ID`` for the request; None when there was no answer.
|
|
30
|
+
status_code: the HTTP status; None when there was no answer.
|
|
31
|
+
"""
|
|
32
|
+
|
|
20
33
|
def __init__(
|
|
21
34
|
self,
|
|
22
35
|
message: str,
|
|
@@ -38,35 +51,35 @@ class MeterFlowError(Exception):
|
|
|
38
51
|
|
|
39
52
|
|
|
40
53
|
class AuthError(MeterFlowError):
|
|
41
|
-
"""401 / 403
|
|
54
|
+
"""401 / 403: the key is missing, invalid or revoked, or not allowed to do this."""
|
|
42
55
|
|
|
43
56
|
def __init__(self, message: str, *, request_id: str | None = None, status_code: int = 401) -> None:
|
|
44
57
|
super().__init__(message, "auth_error", retryable=False, request_id=request_id, status_code=status_code)
|
|
45
58
|
|
|
46
59
|
|
|
47
60
|
class NotFoundError(MeterFlowError):
|
|
48
|
-
"""404
|
|
61
|
+
"""404: no such customer, plan, subscription or hold, or an unknown feature key (a typo, not a plan)."""
|
|
49
62
|
|
|
50
63
|
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
51
64
|
super().__init__(message, "not_found", retryable=False, request_id=request_id, status_code=404)
|
|
52
65
|
|
|
53
66
|
|
|
54
67
|
class InsufficientCreditsError(MeterFlowError):
|
|
55
|
-
"""402
|
|
68
|
+
"""402: a deduction or a hold the customer's available credits cannot cover. A business outcome: offer a top-up."""
|
|
56
69
|
|
|
57
70
|
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
58
71
|
super().__init__(message, "insufficient_credits", retryable=False, request_id=request_id, status_code=402)
|
|
59
72
|
|
|
60
73
|
|
|
61
74
|
class ConflictError(MeterFlowError):
|
|
62
|
-
"""409
|
|
75
|
+
"""409: the write clashes with the current state, for example a deactivated project or a hold already settled."""
|
|
63
76
|
|
|
64
77
|
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
65
78
|
super().__init__(message, "conflict", retryable=False, request_id=request_id, status_code=409)
|
|
66
79
|
|
|
67
80
|
|
|
68
81
|
class ValidationError(MeterFlowError):
|
|
69
|
-
"""422
|
|
82
|
+
"""422: the API refused the request body or query; ``fields`` says which field and why (empty when it named none)."""
|
|
70
83
|
|
|
71
84
|
def __init__(self, message: str, *, request_id: str | None = None, fields: list[ValidationFieldError] | None = None) -> None:
|
|
72
85
|
super().__init__(message, "validation_error", retryable=False, request_id=request_id, status_code=422)
|
|
@@ -74,17 +87,14 @@ class ValidationError(MeterFlowError):
|
|
|
74
87
|
|
|
75
88
|
|
|
76
89
|
class PayloadTooLargeError(MeterFlowError):
|
|
77
|
-
"""413
|
|
78
|
-
|
|
79
|
-
Never retried: the same payload would be refused again. Split it at the call site.
|
|
80
|
-
"""
|
|
90
|
+
"""413: too large; today only ``usage.record_batch`` above ``MAX_BATCH_EVENTS`` events. Never retried: split the batch."""
|
|
81
91
|
|
|
82
92
|
def __init__(self, message: str, *, request_id: str | None = None) -> None:
|
|
83
93
|
super().__init__(message, "payload_too_large", retryable=False, request_id=request_id, status_code=413)
|
|
84
94
|
|
|
85
95
|
|
|
86
96
|
class RateLimitError(MeterFlowError):
|
|
87
|
-
"""429
|
|
97
|
+
"""429: too many requests. Retried automatically after the server's ``Retry-After``, in seconds, kept as ``retry_after``."""
|
|
88
98
|
|
|
89
99
|
def __init__(self, message: str, *, retry_after: int | None = None, request_id: str | None = None) -> None:
|
|
90
100
|
super().__init__(message, "rate_limit", retryable=True, request_id=request_id, status_code=429)
|
|
@@ -92,7 +102,7 @@ class RateLimitError(MeterFlowError):
|
|
|
92
102
|
|
|
93
103
|
|
|
94
104
|
class ServerError(MeterFlowError):
|
|
95
|
-
"""5xx
|
|
105
|
+
"""5xx: a failure on MeterFlow's side. Retried automatically, with backoff."""
|
|
96
106
|
|
|
97
107
|
def __init__(self, message: str, *, request_id: str | None = None, status_code: int = 500) -> None:
|
|
98
108
|
super().__init__(message, "server_error", retryable=True, request_id=request_id, status_code=status_code)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The client's seven resources, each in a sync and an async form sharing one set of request specs."""
|