meterflow 0.4.0__tar.gz → 0.6.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.4.0 → meterflow-0.6.0}/PKG-INFO +59 -3
- {meterflow-0.4.0 → meterflow-0.6.0}/README.md +58 -2
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/__init__.py +6 -0
- meterflow-0.6.0/src/meterflow/_entitlement_cache.py +134 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/_version.py +1 -1
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/client.py +47 -8
- meterflow-0.6.0/src/meterflow/resources/credits.py +215 -0
- meterflow-0.6.0/src/meterflow/resources/entitlements.py +223 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/resources/usage.py +13 -2
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/types.py +47 -0
- meterflow-0.4.0/src/meterflow/resources/credits.py +0 -87
- meterflow-0.4.0/src/meterflow/resources/entitlements.py +0 -66
- {meterflow-0.4.0 → meterflow-0.6.0}/LICENSE +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/pyproject.toml +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/_http.py +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/_retry.py +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/errors.py +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/py.typed +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/resources/__init__.py +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/resources/plans.py +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/resources/portal.py +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/resources/subscriptions.py +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/resources/webhooks.py +0 -0
- {meterflow-0.4.0 → meterflow-0.6.0}/src/meterflow/webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: meterflow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -76,6 +76,7 @@ Track what your customers use, enforce credit balances, gate features, and manag
|
|
|
76
76
|
- [Configuration](#configuration)
|
|
77
77
|
- [Usage guide](#usage-guide)
|
|
78
78
|
- [Credits](#credits)
|
|
79
|
+
- [Holding credits for a long job](#holding-credits-for-a-long-job)
|
|
79
80
|
- [Usage events](#usage-events)
|
|
80
81
|
- [Subscriptions](#subscriptions)
|
|
81
82
|
- [Plans](#plans)
|
|
@@ -190,6 +191,9 @@ client = MeterFlow(
|
|
|
190
191
|
timeout=30.0, # optional — per-request timeout in seconds (default 30)
|
|
191
192
|
retries=3, # optional — automatic retries (default 3, 0 disables)
|
|
192
193
|
transport=None, # optional — an httpx transport (tests, proxies)
|
|
194
|
+
entitlements_cache=True, # optional — reuse entitlement answers for their max-age (default on)
|
|
195
|
+
entitlement_defaults={"*": "deny"}, # optional — what check() answers while MeterFlow is unreachable
|
|
196
|
+
on_error=None, # optional — told when an entitlements call answers from a fallback
|
|
193
197
|
)
|
|
194
198
|
```
|
|
195
199
|
|
|
@@ -215,7 +219,7 @@ txn = client.credits.grant({
|
|
|
215
219
|
print(txn["balance_after"]) # "5000.000000"
|
|
216
220
|
```
|
|
217
221
|
|
|
218
|
-
**Deduct** — remove credits. Raises `InsufficientCreditsError` (HTTP 402) if the
|
|
222
|
+
**Deduct** — remove credits. Raises `InsufficientCreditsError` (HTTP 402) if the available credits can't cover it (the balance less what [holds](#holding-credits-for-a-long-job) keep) — the customer is never taken below zero:
|
|
219
223
|
|
|
220
224
|
```python
|
|
221
225
|
from meterflow import InsufficientCreditsError
|
|
@@ -238,6 +242,8 @@ bal = client.credits.balance("customer_123")
|
|
|
238
242
|
bal["balance"] # "3545.000000" ← decimal string, see note below
|
|
239
243
|
bal["total_granted"] # "5250.000000"
|
|
240
244
|
bal["total_consumed"] # "1705.000000"
|
|
245
|
+
bal["reserved"] # "200.000000" ← held by reservations, still in balance
|
|
246
|
+
bal["available"] # "3345.000000" ← balance − reserved: what can be spent now
|
|
241
247
|
bal["usage_draws_credits"] # True | False | None
|
|
242
248
|
```
|
|
243
249
|
|
|
@@ -265,6 +271,31 @@ while True:
|
|
|
265
271
|
|
|
266
272
|
> **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.
|
|
267
273
|
|
|
274
|
+
### Holding credits for a long job
|
|
275
|
+
|
|
276
|
+
A job that runs for minutes — a render, an agent run — should not find, at the end, that other spending took the credits it was counting on. **Reserve** them first: the balance stays where it is, but `available` drops, and every other spend (`credits.deduct()`, usage charged by a metered plan, a `check()` that would charge, another hold) is measured against `available`.
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
hold = client.credits.reserve(
|
|
280
|
+
{"customer_external_id": "customer_123", "amount": 200, "ttl_seconds": 600, "description": "render #42"},
|
|
281
|
+
idempotency_key="render-42",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
used = render() # the long job
|
|
286
|
+
client.credits.confirm_reservation(hold["id"], {"amount": used.credits}) # take what it used; the rest is freed
|
|
287
|
+
except Exception:
|
|
288
|
+
client.credits.release_reservation(hold["id"]) # nothing taken
|
|
289
|
+
raise
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`AsyncMeterFlow` has the same four methods, awaited.
|
|
293
|
+
|
|
294
|
+
- **Reserve** raises `InsufficientCreditsError` (402) when `available` cannot cover the amount, and `ConflictError` (409) when held credits would pass the subscription's spend cap: a hold counts against the cap as it will when confirmed. `ttl_seconds` defaults to 300 and is at most 86,400 (a day).
|
|
295
|
+
- **Confirm** writes one ordinary `deduct` to the ledger, with your hold's description and `metadata["reservation_id"]`, and fires the same webhooks a deduct does. Leave out `amount` to take the whole hold; more than the hold is a `ValidationError` (422). Whatever it does not take is freed.
|
|
296
|
+
- **Release** frees the hold with nothing taken. A hold left alone expires at `expires_at` and frees itself: `reservation(id)` then answers `"status": "expired"`, a confirm raises `ConflictError` (409), and a release answers it as expired.
|
|
297
|
+
- Confirming or releasing a hold that is already confirmed or released raises `ConflictError` (409). If a retry of a confirm meets a 409, `client.credits.reservation(hold["id"])` tells you whether the first attempt landed.
|
|
298
|
+
|
|
268
299
|
### Usage events
|
|
269
300
|
|
|
270
301
|
Record an event every time a customer does the thing you charge for. Events are matched to a meter by `event_name` and processed asynchronously into totals (and, if the meter is metered on a plan, into credit deductions).
|
|
@@ -463,7 +494,7 @@ client.usage.record({"event_name": "images.generated", "customer_external_id": "
|
|
|
463
494
|
- A **soft** limit answers `allowed: True` with `reason: "soft_limit_exceeded"` — let it through, nudge the upgrade.
|
|
464
495
|
- A **metered** limit stays allowed while the customer's credits cover the overage; otherwise `insufficient_credits`.
|
|
465
496
|
- `allowed: False` is a normal returned answer, never an exception. A feature key the project does not know at all is a `NotFoundError` — that is a typo, not a plan.
|
|
466
|
-
- The answer
|
|
497
|
+
- The SDK reuses an answer for the `Cache-Control: max-age` it came with (15 s today), per customer, feature and `quantity` — `allowed: False` included. See [When MeterFlow can't be reached](#when-meterflow-cant-be-reached).
|
|
467
498
|
|
|
468
499
|
**All of them at once** — for a settings or pricing screen:
|
|
469
500
|
|
|
@@ -478,6 +509,31 @@ Each metered row answers for the **next** unit, exactly as `check()` would, so i
|
|
|
478
509
|
|
|
479
510
|
Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
|
|
480
511
|
|
|
512
|
+
#### When MeterFlow can't be reached
|
|
513
|
+
|
|
514
|
+
A MeterFlow blip should never take your product down with it. Three things keep `check()` answering, on `MeterFlow` and `AsyncMeterFlow` alike:
|
|
515
|
+
|
|
516
|
+
- **The cache.** Answers from `check()` and `get()` are reused for the `max-age` the API sends (15 s), per client, up to 1,000 of them. A response without `Cache-Control` is never cached. A successful `usage.record()`, `usage.record_batch()`, `credits.grant()`, `credits.deduct()`, `credits.reserve()`, `confirm_reservation()` or `release_reservation()` makes that customer's answers stale, so the next `check()` asks again. `entitlements_cache=False` turns the cache off.
|
|
517
|
+
- **The last answer.** When the API cannot be reached — a network error, a timeout, a 5xx or a 429, once the retries are spent — a call returns its last answer for that customer and feature, past its age, with `"stale": True`.
|
|
518
|
+
- **Your defaults.** With no earlier answer, `check()` returns your `entitlement_defaults` entry for the feature, else its `"*"` entry: `{"feature", "feature_type": None, "allowed", "reason": "offline_default"}`. With neither, it raises as before. `get()` has no default: a whole plan cannot be guessed.
|
|
519
|
+
|
|
520
|
+
```python
|
|
521
|
+
from meterflow import EntitlementFallbackContext, MeterFlow
|
|
522
|
+
|
|
523
|
+
def warn(error: Exception, context: EntitlementFallbackContext) -> None:
|
|
524
|
+
log.warning("meterflow fallback %s for %s / %s: %s", context.fallback, context.customer_id, context.feature, error)
|
|
525
|
+
|
|
526
|
+
client = MeterFlow(
|
|
527
|
+
api_key=os.environ["METERFLOW_API_KEY"],
|
|
528
|
+
timeout=2.0, # a gate on your hot path should give up fast
|
|
529
|
+
retries=1,
|
|
530
|
+
entitlement_defaults={"images.generated": "allow", "*": "deny"},
|
|
531
|
+
on_error=warn,
|
|
532
|
+
)
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
A 401, 403, 404 or 422 is an answer, not an outage: it is raised, never replaced by a fallback. `on_error` is called before a fallback is used; anything it raises is ignored. `check()` returns an `EntitlementCheck` and `get()` an `EntitlementList` (both exported from `meterflow`): the API's types, widened for `stale` and `offline_default`.
|
|
536
|
+
|
|
481
537
|
### The balance portal — a page you don't have to build
|
|
482
538
|
|
|
483
539
|
Mint a short-lived link to a MeterFlow-hosted page showing **one** customer's balance and ledger.
|
|
@@ -24,6 +24,7 @@ Track what your customers use, enforce credit balances, gate features, and manag
|
|
|
24
24
|
- [Configuration](#configuration)
|
|
25
25
|
- [Usage guide](#usage-guide)
|
|
26
26
|
- [Credits](#credits)
|
|
27
|
+
- [Holding credits for a long job](#holding-credits-for-a-long-job)
|
|
27
28
|
- [Usage events](#usage-events)
|
|
28
29
|
- [Subscriptions](#subscriptions)
|
|
29
30
|
- [Plans](#plans)
|
|
@@ -138,6 +139,9 @@ client = MeterFlow(
|
|
|
138
139
|
timeout=30.0, # optional — per-request timeout in seconds (default 30)
|
|
139
140
|
retries=3, # optional — automatic retries (default 3, 0 disables)
|
|
140
141
|
transport=None, # optional — an httpx transport (tests, proxies)
|
|
142
|
+
entitlements_cache=True, # optional — reuse entitlement answers for their max-age (default on)
|
|
143
|
+
entitlement_defaults={"*": "deny"}, # optional — what check() answers while MeterFlow is unreachable
|
|
144
|
+
on_error=None, # optional — told when an entitlements call answers from a fallback
|
|
141
145
|
)
|
|
142
146
|
```
|
|
143
147
|
|
|
@@ -163,7 +167,7 @@ txn = client.credits.grant({
|
|
|
163
167
|
print(txn["balance_after"]) # "5000.000000"
|
|
164
168
|
```
|
|
165
169
|
|
|
166
|
-
**Deduct** — remove credits. Raises `InsufficientCreditsError` (HTTP 402) if the
|
|
170
|
+
**Deduct** — remove credits. Raises `InsufficientCreditsError` (HTTP 402) if the available credits can't cover it (the balance less what [holds](#holding-credits-for-a-long-job) keep) — the customer is never taken below zero:
|
|
167
171
|
|
|
168
172
|
```python
|
|
169
173
|
from meterflow import InsufficientCreditsError
|
|
@@ -186,6 +190,8 @@ bal = client.credits.balance("customer_123")
|
|
|
186
190
|
bal["balance"] # "3545.000000" ← decimal string, see note below
|
|
187
191
|
bal["total_granted"] # "5250.000000"
|
|
188
192
|
bal["total_consumed"] # "1705.000000"
|
|
193
|
+
bal["reserved"] # "200.000000" ← held by reservations, still in balance
|
|
194
|
+
bal["available"] # "3345.000000" ← balance − reserved: what can be spent now
|
|
189
195
|
bal["usage_draws_credits"] # True | False | None
|
|
190
196
|
```
|
|
191
197
|
|
|
@@ -213,6 +219,31 @@ while True:
|
|
|
213
219
|
|
|
214
220
|
> **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.
|
|
215
221
|
|
|
222
|
+
### Holding credits for a long job
|
|
223
|
+
|
|
224
|
+
A job that runs for minutes — a render, an agent run — should not find, at the end, that other spending took the credits it was counting on. **Reserve** them first: the balance stays where it is, but `available` drops, and every other spend (`credits.deduct()`, usage charged by a metered plan, a `check()` that would charge, another hold) is measured against `available`.
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
hold = client.credits.reserve(
|
|
228
|
+
{"customer_external_id": "customer_123", "amount": 200, "ttl_seconds": 600, "description": "render #42"},
|
|
229
|
+
idempotency_key="render-42",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
used = render() # the long job
|
|
234
|
+
client.credits.confirm_reservation(hold["id"], {"amount": used.credits}) # take what it used; the rest is freed
|
|
235
|
+
except Exception:
|
|
236
|
+
client.credits.release_reservation(hold["id"]) # nothing taken
|
|
237
|
+
raise
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`AsyncMeterFlow` has the same four methods, awaited.
|
|
241
|
+
|
|
242
|
+
- **Reserve** raises `InsufficientCreditsError` (402) when `available` cannot cover the amount, and `ConflictError` (409) when held credits would pass the subscription's spend cap: a hold counts against the cap as it will when confirmed. `ttl_seconds` defaults to 300 and is at most 86,400 (a day).
|
|
243
|
+
- **Confirm** writes one ordinary `deduct` to the ledger, with your hold's description and `metadata["reservation_id"]`, and fires the same webhooks a deduct does. Leave out `amount` to take the whole hold; more than the hold is a `ValidationError` (422). Whatever it does not take is freed.
|
|
244
|
+
- **Release** frees the hold with nothing taken. A hold left alone expires at `expires_at` and frees itself: `reservation(id)` then answers `"status": "expired"`, a confirm raises `ConflictError` (409), and a release answers it as expired.
|
|
245
|
+
- Confirming or releasing a hold that is already confirmed or released raises `ConflictError` (409). If a retry of a confirm meets a 409, `client.credits.reservation(hold["id"])` tells you whether the first attempt landed.
|
|
246
|
+
|
|
216
247
|
### Usage events
|
|
217
248
|
|
|
218
249
|
Record an event every time a customer does the thing you charge for. Events are matched to a meter by `event_name` and processed asynchronously into totals (and, if the meter is metered on a plan, into credit deductions).
|
|
@@ -411,7 +442,7 @@ client.usage.record({"event_name": "images.generated", "customer_external_id": "
|
|
|
411
442
|
- A **soft** limit answers `allowed: True` with `reason: "soft_limit_exceeded"` — let it through, nudge the upgrade.
|
|
412
443
|
- A **metered** limit stays allowed while the customer's credits cover the overage; otherwise `insufficient_credits`.
|
|
413
444
|
- `allowed: False` is a normal returned answer, never an exception. A feature key the project does not know at all is a `NotFoundError` — that is a typo, not a plan.
|
|
414
|
-
- The answer
|
|
445
|
+
- The SDK reuses an answer for the `Cache-Control: max-age` it came with (15 s today), per customer, feature and `quantity` — `allowed: False` included. See [When MeterFlow can't be reached](#when-meterflow-cant-be-reached).
|
|
415
446
|
|
|
416
447
|
**All of them at once** — for a settings or pricing screen:
|
|
417
448
|
|
|
@@ -426,6 +457,31 @@ Each metered row answers for the **next** unit, exactly as `check()` would, so i
|
|
|
426
457
|
|
|
427
458
|
Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
|
|
428
459
|
|
|
460
|
+
#### When MeterFlow can't be reached
|
|
461
|
+
|
|
462
|
+
A MeterFlow blip should never take your product down with it. Three things keep `check()` answering, on `MeterFlow` and `AsyncMeterFlow` alike:
|
|
463
|
+
|
|
464
|
+
- **The cache.** Answers from `check()` and `get()` are reused for the `max-age` the API sends (15 s), per client, up to 1,000 of them. A response without `Cache-Control` is never cached. A successful `usage.record()`, `usage.record_batch()`, `credits.grant()`, `credits.deduct()`, `credits.reserve()`, `confirm_reservation()` or `release_reservation()` makes that customer's answers stale, so the next `check()` asks again. `entitlements_cache=False` turns the cache off.
|
|
465
|
+
- **The last answer.** When the API cannot be reached — a network error, a timeout, a 5xx or a 429, once the retries are spent — a call returns its last answer for that customer and feature, past its age, with `"stale": True`.
|
|
466
|
+
- **Your defaults.** With no earlier answer, `check()` returns your `entitlement_defaults` entry for the feature, else its `"*"` entry: `{"feature", "feature_type": None, "allowed", "reason": "offline_default"}`. With neither, it raises as before. `get()` has no default: a whole plan cannot be guessed.
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
from meterflow import EntitlementFallbackContext, MeterFlow
|
|
470
|
+
|
|
471
|
+
def warn(error: Exception, context: EntitlementFallbackContext) -> None:
|
|
472
|
+
log.warning("meterflow fallback %s for %s / %s: %s", context.fallback, context.customer_id, context.feature, error)
|
|
473
|
+
|
|
474
|
+
client = MeterFlow(
|
|
475
|
+
api_key=os.environ["METERFLOW_API_KEY"],
|
|
476
|
+
timeout=2.0, # a gate on your hot path should give up fast
|
|
477
|
+
retries=1,
|
|
478
|
+
entitlement_defaults={"images.generated": "allow", "*": "deny"},
|
|
479
|
+
on_error=warn,
|
|
480
|
+
)
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
A 401, 403, 404 or 422 is an answer, not an outage: it is raised, never replaced by a fallback. `on_error` is called before a fallback is used; anything it raises is ignored. `check()` returns an `EntitlementCheck` and `get()` an `EntitlementList` (both exported from `meterflow`): the API's types, widened for `stale` and `offline_default`.
|
|
484
|
+
|
|
429
485
|
### The balance portal — a page you don't have to build
|
|
430
486
|
|
|
431
487
|
Mint a short-lived link to a MeterFlow-hosted page showing **one** customer's balance and ledger.
|
|
@@ -6,6 +6,7 @@ client = MeterFlow(api_key="mf_live_...")
|
|
|
6
6
|
client.credits.grant({"customer_external_id": "customer_123", "amount": 5000, "metadata": {}})
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
from ._entitlement_cache import EntitlementDefault, EntitlementFallbackContext
|
|
9
10
|
from ._version import __version__
|
|
10
11
|
from .client import AsyncMeterFlow, ClientOptions, MeterFlow
|
|
11
12
|
from .errors import (
|
|
@@ -20,6 +21,7 @@ from .errors import (
|
|
|
20
21
|
ValidationError,
|
|
21
22
|
ValidationFieldError,
|
|
22
23
|
)
|
|
24
|
+
from .resources.entitlements import EntitlementCheck, EntitlementList
|
|
23
25
|
from .resources.usage import MAX_BATCH_EVENTS
|
|
24
26
|
from .webhook import verify_webhook
|
|
25
27
|
|
|
@@ -39,5 +41,9 @@ __all__ = [
|
|
|
39
41
|
"RateLimitError",
|
|
40
42
|
"ServerError",
|
|
41
43
|
"MAX_BATCH_EVENTS",
|
|
44
|
+
"EntitlementCheck",
|
|
45
|
+
"EntitlementList",
|
|
46
|
+
"EntitlementDefault",
|
|
47
|
+
"EntitlementFallbackContext",
|
|
42
48
|
"verify_webhook",
|
|
43
49
|
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The entitlement answers one client keeps, and the rules for falling back on them. Mirrors ``sdks/node/src/utils/entitlement-cache.ts``.
|
|
2
|
+
|
|
3
|
+
Each answer is fresh for the ``max-age`` the API sends and kept past it as the fallback for an outage.
|
|
4
|
+
Bounded, least recently used out first.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from .errors import MeterFlowError, RateLimitError, ServerError
|
|
17
|
+
|
|
18
|
+
# How many answers one client keeps; the least recently used goes first past it.
|
|
19
|
+
MAX_CACHED_ENTITLEMENTS = 1_000
|
|
20
|
+
|
|
21
|
+
EntitlementDefault = Literal["allow", "deny"]
|
|
22
|
+
|
|
23
|
+
# The clock answers are aged by; an indirection so tests can step it.
|
|
24
|
+
_now = time.monotonic
|
|
25
|
+
|
|
26
|
+
_MAX_AGE = re.compile(r"\bmax-age=(\d+)", re.IGNORECASE)
|
|
27
|
+
_NO_STORE = re.compile(r"\bno-(store|cache)\b", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class EntitlementFallbackContext:
|
|
32
|
+
"""Which call fell back, and to what, as ``on_error`` is told.
|
|
33
|
+
|
|
34
|
+
customer_id: the customer the call was for.
|
|
35
|
+
feature: the feature checked; None for ``entitlements.get()``.
|
|
36
|
+
fallback: ``stale``, the last answer past its ``max-age``; ``default``, the configured ``entitlement_defaults`` entry.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
customer_id: str
|
|
40
|
+
feature: str | None
|
|
41
|
+
fallback: Literal["stale", "default"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
OnEntitlementError = Callable[[Exception, EntitlementFallbackContext], None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CacheRead:
|
|
49
|
+
"""An answer found in the cache, and whether it is still fresh."""
|
|
50
|
+
|
|
51
|
+
value: Any
|
|
52
|
+
fresh: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class _CachedAnswer:
|
|
57
|
+
customer_id: str
|
|
58
|
+
value: Any
|
|
59
|
+
fresh_until: float
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def max_age_seconds(header: str | None) -> int:
|
|
63
|
+
"""The seconds a response may be reused for: its ``max-age``; 0 when absent, ``no-store`` / ``no-cache``, or naming none.
|
|
64
|
+
|
|
65
|
+
header: the response's ``Cache-Control`` value, or None.
|
|
66
|
+
"""
|
|
67
|
+
if not header or _NO_STORE.search(header):
|
|
68
|
+
return 0
|
|
69
|
+
match = _MAX_AGE.search(header)
|
|
70
|
+
return int(match.group(1)) if match else 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_outage(error: BaseException) -> bool:
|
|
74
|
+
"""Whether a failed call is MeterFlow being unreachable rather than an answer: no response at all, a 5xx after the retries, or a 429.
|
|
75
|
+
|
|
76
|
+
error: what the call raised.
|
|
77
|
+
"""
|
|
78
|
+
if not isinstance(error, MeterFlowError):
|
|
79
|
+
return True
|
|
80
|
+
return isinstance(error, ServerError | RateLimitError)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class EntitlementCache:
|
|
84
|
+
"""The entitlement answers one client has read, each fresh for the API's ``max-age`` and kept past it as an outage fallback."""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._answers: OrderedDict[str, _CachedAnswer] = OrderedDict()
|
|
88
|
+
|
|
89
|
+
def read(self, key: str) -> CacheRead | None:
|
|
90
|
+
"""The answer stored under ``key``, with whether it is still fresh; None when there is none.
|
|
91
|
+
|
|
92
|
+
key: the call's cache key.
|
|
93
|
+
"""
|
|
94
|
+
cached = self._answers.get(key)
|
|
95
|
+
if cached is None:
|
|
96
|
+
return None
|
|
97
|
+
self._answers.move_to_end(key)
|
|
98
|
+
return CacheRead(cached.value, _now() < cached.fresh_until)
|
|
99
|
+
|
|
100
|
+
def write(self, key: str, customer_id: str, value: Any, seconds: int) -> None:
|
|
101
|
+
"""Keep an answer for ``seconds``; nothing is kept when that is 0.
|
|
102
|
+
|
|
103
|
+
key: the call's cache key.
|
|
104
|
+
customer_id: whose answer it is, so a write for them can expire it.
|
|
105
|
+
value: the answer.
|
|
106
|
+
seconds: how long it stays fresh.
|
|
107
|
+
"""
|
|
108
|
+
if seconds <= 0:
|
|
109
|
+
return
|
|
110
|
+
self._answers[key] = _CachedAnswer(customer_id, value, _now() + seconds)
|
|
111
|
+
self._answers.move_to_end(key)
|
|
112
|
+
if len(self._answers) > MAX_CACHED_ENTITLEMENTS:
|
|
113
|
+
self._answers.popitem(last=False)
|
|
114
|
+
|
|
115
|
+
def invalidate(self, customer_id: str) -> None:
|
|
116
|
+
"""Make every answer for a customer stale, after a write that changed what they may do. They stay as an outage fallback.
|
|
117
|
+
|
|
118
|
+
customer_id: the customer written for.
|
|
119
|
+
"""
|
|
120
|
+
for cached in self._answers.values():
|
|
121
|
+
if cached.customer_id == customer_id:
|
|
122
|
+
cached.fresh_until = 0.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def expire_entitlements(cache: EntitlementCache | None, customer_ids: list[str]) -> None:
|
|
126
|
+
"""Make the cached answers of every customer a successful write touched stale; nothing when the cache is off.
|
|
127
|
+
|
|
128
|
+
cache: the client's cache, or None.
|
|
129
|
+
customer_ids: the customers written for, repeats allowed.
|
|
130
|
+
"""
|
|
131
|
+
if cache is None:
|
|
132
|
+
return
|
|
133
|
+
for customer_id in set(customer_ids):
|
|
134
|
+
cache.invalidate(customer_id)
|
|
@@ -7,13 +7,15 @@ thin and cannot drift apart.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
11
12
|
from itertools import count
|
|
12
13
|
from types import TracebackType
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
15
16
|
import httpx
|
|
16
17
|
|
|
18
|
+
from ._entitlement_cache import EntitlementCache, EntitlementDefault, OnEntitlementError
|
|
17
19
|
from ._http import RequestSpec, _async_sleep, _sleep, decode_response, map_response_error, request_kwargs
|
|
18
20
|
from ._retry import RetryPolicy
|
|
19
21
|
from .resources.credits import AsyncCreditsResource, CreditsResource
|
|
@@ -38,10 +40,22 @@ class ClientOptions:
|
|
|
38
40
|
base_url: str
|
|
39
41
|
timeout: float
|
|
40
42
|
retries: int
|
|
43
|
+
entitlements_cache: bool = True
|
|
44
|
+
entitlement_defaults: Mapping[str, EntitlementDefault] = field(default_factory=dict)
|
|
45
|
+
on_error: OnEntitlementError | None = None
|
|
41
46
|
|
|
42
47
|
|
|
43
48
|
class _BaseClient:
|
|
44
|
-
def __init__(
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
api_key: str,
|
|
52
|
+
base_url: str | None,
|
|
53
|
+
timeout: float | None,
|
|
54
|
+
retries: int | None,
|
|
55
|
+
entitlements_cache: bool,
|
|
56
|
+
entitlement_defaults: Mapping[str, EntitlementDefault] | None,
|
|
57
|
+
on_error: OnEntitlementError | None,
|
|
58
|
+
) -> None:
|
|
45
59
|
if not api_key.startswith(KEY_PREFIXES):
|
|
46
60
|
raise ValueError("api_key must start with mf_live_ or mf_test_")
|
|
47
61
|
self.options = ClientOptions(
|
|
@@ -49,8 +63,13 @@ class _BaseClient:
|
|
|
49
63
|
base_url=(base_url or DEFAULT_BASE_URL).rstrip("/") + "/",
|
|
50
64
|
timeout=DEFAULT_TIMEOUT_SECONDS if timeout is None else timeout,
|
|
51
65
|
retries=DEFAULT_RETRIES if retries is None else retries,
|
|
66
|
+
entitlements_cache=entitlements_cache,
|
|
67
|
+
entitlement_defaults=dict(entitlement_defaults or {}),
|
|
68
|
+
on_error=on_error,
|
|
52
69
|
)
|
|
53
70
|
self._retry = RetryPolicy(retries=self.options.retries)
|
|
71
|
+
# Internal: the entitlement answers this client has read; None when ``entitlements_cache`` is False.
|
|
72
|
+
self.entitlement_cache = EntitlementCache() if entitlements_cache else None
|
|
54
73
|
|
|
55
74
|
def _kwargs(self, spec: RequestSpec) -> dict[str, Any]:
|
|
56
75
|
return request_kwargs(self.options.api_key, spec)
|
|
@@ -61,6 +80,8 @@ class MeterFlow(_BaseClient):
|
|
|
61
80
|
|
|
62
81
|
``transport`` injects an ``httpx`` transport (tests, proxies) — the Python counterpart of the
|
|
63
82
|
Node SDK's ``fetch`` option. Use it as a context manager or call ``close()`` to release connections.
|
|
83
|
+
``entitlements_cache``, ``entitlement_defaults`` and ``on_error`` set how ``entitlements`` caches its
|
|
84
|
+
answers and what it answers while MeterFlow is unreachable (``resources/entitlements.py``).
|
|
64
85
|
"""
|
|
65
86
|
|
|
66
87
|
def __init__(
|
|
@@ -71,8 +92,11 @@ class MeterFlow(_BaseClient):
|
|
|
71
92
|
timeout: float | None = None,
|
|
72
93
|
retries: int | None = None,
|
|
73
94
|
transport: httpx.BaseTransport | None = None,
|
|
95
|
+
entitlements_cache: bool = True,
|
|
96
|
+
entitlement_defaults: Mapping[str, EntitlementDefault] | None = None,
|
|
97
|
+
on_error: OnEntitlementError | None = None,
|
|
74
98
|
) -> None:
|
|
75
|
-
super().__init__(api_key, base_url, timeout, retries)
|
|
99
|
+
super().__init__(api_key, base_url, timeout, retries, entitlements_cache, entitlement_defaults, on_error)
|
|
76
100
|
self._http = httpx.Client(base_url=self.options.base_url, timeout=self.options.timeout, transport=transport)
|
|
77
101
|
self.credits = CreditsResource(self)
|
|
78
102
|
self.usage = UsageResource(self)
|
|
@@ -82,13 +106,19 @@ class MeterFlow(_BaseClient):
|
|
|
82
106
|
self.entitlements = EntitlementsResource(self)
|
|
83
107
|
self.webhooks = WebhooksResource(self)
|
|
84
108
|
|
|
85
|
-
def request(self, spec: RequestSpec) -> Any:
|
|
86
|
-
"""Internal — used by resource classes; not part of the public API.
|
|
109
|
+
def request(self, spec: RequestSpec, on_headers: Callable[[httpx.Headers], None] | None = None) -> Any:
|
|
110
|
+
"""Internal — used by resource classes; not part of the public API.
|
|
111
|
+
|
|
112
|
+
spec: the call.
|
|
113
|
+
on_headers: given a successful response's headers; how the entitlements cache reads ``Cache-Control``.
|
|
114
|
+
"""
|
|
87
115
|
for attempt in count():
|
|
88
116
|
try:
|
|
89
117
|
response = self._http.request(spec.method, spec.path, **self._kwargs(spec))
|
|
90
118
|
if response.is_error:
|
|
91
119
|
raise map_response_error(response)
|
|
120
|
+
if on_headers is not None:
|
|
121
|
+
on_headers(response.headers)
|
|
92
122
|
return decode_response(response)
|
|
93
123
|
except Exception as error:
|
|
94
124
|
delay = self._retry.delay_before_retry(attempt, error)
|
|
@@ -118,8 +148,11 @@ class AsyncMeterFlow(_BaseClient):
|
|
|
118
148
|
timeout: float | None = None,
|
|
119
149
|
retries: int | None = None,
|
|
120
150
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
151
|
+
entitlements_cache: bool = True,
|
|
152
|
+
entitlement_defaults: Mapping[str, EntitlementDefault] | None = None,
|
|
153
|
+
on_error: OnEntitlementError | None = None,
|
|
121
154
|
) -> None:
|
|
122
|
-
super().__init__(api_key, base_url, timeout, retries)
|
|
155
|
+
super().__init__(api_key, base_url, timeout, retries, entitlements_cache, entitlement_defaults, on_error)
|
|
123
156
|
self._http = httpx.AsyncClient(base_url=self.options.base_url, timeout=self.options.timeout, transport=transport)
|
|
124
157
|
self.credits = AsyncCreditsResource(self)
|
|
125
158
|
self.usage = AsyncUsageResource(self)
|
|
@@ -129,13 +162,19 @@ class AsyncMeterFlow(_BaseClient):
|
|
|
129
162
|
self.entitlements = AsyncEntitlementsResource(self)
|
|
130
163
|
self.webhooks = AsyncWebhooksResource(self)
|
|
131
164
|
|
|
132
|
-
async def request(self, spec: RequestSpec) -> Any:
|
|
133
|
-
"""Internal — used by resource classes; not part of the public API.
|
|
165
|
+
async def request(self, spec: RequestSpec, on_headers: Callable[[httpx.Headers], None] | None = None) -> Any:
|
|
166
|
+
"""Internal — used by resource classes; not part of the public API.
|
|
167
|
+
|
|
168
|
+
spec: the call.
|
|
169
|
+
on_headers: given a successful response's headers; how the entitlements cache reads ``Cache-Control``.
|
|
170
|
+
"""
|
|
134
171
|
for attempt in count():
|
|
135
172
|
try:
|
|
136
173
|
response = await self._http.request(spec.method, spec.path, **self._kwargs(spec))
|
|
137
174
|
if response.is_error:
|
|
138
175
|
raise map_response_error(response)
|
|
176
|
+
if on_headers is not None:
|
|
177
|
+
on_headers(response.headers)
|
|
139
178
|
return decode_response(response)
|
|
140
179
|
except Exception as error:
|
|
141
180
|
delay = self._retry.delay_before_retry(attempt, error)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Credits — an append-only ledger per customer: grant up, deduct down, never edited in place; holds set credits aside."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .._entitlement_cache import expire_entitlements
|
|
8
|
+
from .._http import RequestSpec, encode_path_segment
|
|
9
|
+
from ..types import (
|
|
10
|
+
CreditBalanceResponse,
|
|
11
|
+
CreditDeductRequest,
|
|
12
|
+
CreditGrantRequest,
|
|
13
|
+
CreditReservationConfirmRequest,
|
|
14
|
+
CreditReservationRequest,
|
|
15
|
+
CreditReservationResponse,
|
|
16
|
+
CreditTransactionPage,
|
|
17
|
+
CreditTransactionResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..client import AsyncMeterFlow, MeterFlow
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def grant_spec(body: CreditGrantRequest, idempotency_key: str | None) -> RequestSpec:
|
|
25
|
+
return RequestSpec("POST", "credits/grant", body=body, idempotency_key=idempotency_key)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def deduct_spec(body: CreditDeductRequest, idempotency_key: str | None) -> RequestSpec:
|
|
29
|
+
return RequestSpec("POST", "credits/deduct", body=body, idempotency_key=idempotency_key)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def reserve_spec(body: CreditReservationRequest, idempotency_key: str | None) -> RequestSpec:
|
|
33
|
+
return RequestSpec("POST", "credits/reservations", body=body, idempotency_key=idempotency_key)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def confirm_reservation_spec(reservation_id: str, body: CreditReservationConfirmRequest | None) -> RequestSpec:
|
|
37
|
+
return RequestSpec("POST", f"credits/reservations/{encode_path_segment(reservation_id)}/confirm", body=body or {})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def release_reservation_spec(reservation_id: str) -> RequestSpec:
|
|
41
|
+
return RequestSpec("POST", f"credits/reservations/{encode_path_segment(reservation_id)}/release")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reservation_spec(reservation_id: str) -> RequestSpec:
|
|
45
|
+
return RequestSpec("GET", f"credits/reservations/{encode_path_segment(reservation_id)}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def balance_spec(customer_id: str) -> RequestSpec:
|
|
49
|
+
return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/balance")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def transactions_spec(customer_id: str, limit: int | None, cursor: str | None) -> RequestSpec:
|
|
53
|
+
return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/transactions", query={"limit": limit, "cursor": cursor})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CreditsResource:
|
|
57
|
+
def __init__(self, client: MeterFlow) -> None:
|
|
58
|
+
self._client = client
|
|
59
|
+
|
|
60
|
+
def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
61
|
+
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line. The customer's cached entitlement answers go stale."""
|
|
62
|
+
result: CreditTransactionResponse = self._client.request(grant_spec(body, idempotency_key))
|
|
63
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
67
|
+
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero.
|
|
68
|
+
|
|
69
|
+
The customer's cached entitlement answers go stale.
|
|
70
|
+
"""
|
|
71
|
+
result: CreditTransactionResponse = self._client.request(deduct_spec(body, idempotency_key))
|
|
72
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
def reserve(self, body: CreditReservationRequest, *, idempotency_key: str | None = None) -> CreditReservationResponse:
|
|
76
|
+
"""Hold credits until confirmed, released or ``ttl_seconds`` (default 300) runs out; the balance stays, ``available`` drops.
|
|
77
|
+
|
|
78
|
+
Raises ``InsufficientCreditsError`` (402) when the available credits cannot cover it. The customer's cached entitlement
|
|
79
|
+
answers go stale.
|
|
80
|
+
|
|
81
|
+
body: the customer, the amount, how long to hold it, a description and metadata.
|
|
82
|
+
idempotency_key: makes a retried hold the same hold.
|
|
83
|
+
"""
|
|
84
|
+
result: CreditReservationResponse = self._client.request(reserve_spec(body, idempotency_key))
|
|
85
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
def confirm_reservation(self, reservation_id: str, body: CreditReservationConfirmRequest | None = None) -> CreditReservationResponse:
|
|
89
|
+
"""Take some or all of a hold from the balance, as one ``deduct`` ledger row, and free the rest.
|
|
90
|
+
|
|
91
|
+
Raises ``ConflictError`` (409) unless it is still held, ``ValidationError`` (422) above the amount held. The customer's
|
|
92
|
+
cached entitlement answers go stale.
|
|
93
|
+
|
|
94
|
+
reservation_id: the hold's ``id``.
|
|
95
|
+
body: ``amount`` to take; the whole hold when left out.
|
|
96
|
+
"""
|
|
97
|
+
result: CreditReservationResponse = self._client.request(confirm_reservation_spec(reservation_id, body))
|
|
98
|
+
expire_entitlements(self._client.entitlement_cache, [result["customer_external_id"]])
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def release_reservation(self, reservation_id: str) -> CreditReservationResponse:
|
|
102
|
+
"""Free a hold with nothing taken; an expired hold answers ``status: "expired"``, a settled one raises ``ConflictError`` (409).
|
|
103
|
+
|
|
104
|
+
The customer's cached entitlement answers go stale.
|
|
105
|
+
|
|
106
|
+
reservation_id: the hold's ``id``.
|
|
107
|
+
"""
|
|
108
|
+
result: CreditReservationResponse = self._client.request(release_reservation_spec(reservation_id))
|
|
109
|
+
expire_entitlements(self._client.entitlement_cache, [result["customer_external_id"]])
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
def reservation(self, reservation_id: str) -> CreditReservationResponse:
|
|
113
|
+
"""One hold and how it ended: ``held``, ``confirmed``, ``released``, or ``expired`` once held past ``expires_at``.
|
|
114
|
+
|
|
115
|
+
reservation_id: the hold's ``id``.
|
|
116
|
+
"""
|
|
117
|
+
result: CreditReservationResponse = self._client.request(reservation_spec(reservation_id))
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
def balance(self, customer_id: str) -> CreditBalanceResponse:
|
|
121
|
+
"""The customer's current position — amounts are decimal strings."""
|
|
122
|
+
result: CreditBalanceResponse = self._client.request(balance_spec(customer_id))
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
def transactions(self, customer_id: str, *, limit: int | None = None, cursor: str | None = None) -> CreditTransactionPage:
|
|
126
|
+
"""One page of the ledger, newest first.
|
|
127
|
+
|
|
128
|
+
The ledger grows with every charge, so it is read a page at a time: ``data`` holds the rows
|
|
129
|
+
and ``next_cursor`` the mark for the page after this one (``None`` on the last). Pass it
|
|
130
|
+
back as ``cursor`` unchanged — it is opaque, and nothing but this API reads it.
|
|
131
|
+
"""
|
|
132
|
+
result: CreditTransactionPage = self._client.request(transactions_spec(customer_id, limit, cursor))
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AsyncCreditsResource:
|
|
137
|
+
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
138
|
+
self._client = client
|
|
139
|
+
|
|
140
|
+
async def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
141
|
+
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line. The customer's cached entitlement answers go stale."""
|
|
142
|
+
result: CreditTransactionResponse = await self._client.request(grant_spec(body, idempotency_key))
|
|
143
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
async def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
147
|
+
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero.
|
|
148
|
+
|
|
149
|
+
The customer's cached entitlement answers go stale.
|
|
150
|
+
"""
|
|
151
|
+
result: CreditTransactionResponse = await self._client.request(deduct_spec(body, idempotency_key))
|
|
152
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
async def reserve(self, body: CreditReservationRequest, *, idempotency_key: str | None = None) -> CreditReservationResponse:
|
|
156
|
+
"""Hold credits until confirmed, released or ``ttl_seconds`` (default 300) runs out; the balance stays, ``available`` drops.
|
|
157
|
+
|
|
158
|
+
Raises ``InsufficientCreditsError`` (402) when the available credits cannot cover it. The customer's cached entitlement
|
|
159
|
+
answers go stale.
|
|
160
|
+
|
|
161
|
+
body: the customer, the amount, how long to hold it, a description and metadata.
|
|
162
|
+
idempotency_key: makes a retried hold the same hold.
|
|
163
|
+
"""
|
|
164
|
+
result: CreditReservationResponse = await self._client.request(reserve_spec(body, idempotency_key))
|
|
165
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
async def confirm_reservation(
|
|
169
|
+
self, reservation_id: str, body: CreditReservationConfirmRequest | None = None
|
|
170
|
+
) -> CreditReservationResponse:
|
|
171
|
+
"""Take some or all of a hold from the balance, as one ``deduct`` ledger row, and free the rest.
|
|
172
|
+
|
|
173
|
+
Raises ``ConflictError`` (409) unless it is still held, ``ValidationError`` (422) above the amount held. The customer's
|
|
174
|
+
cached entitlement answers go stale.
|
|
175
|
+
|
|
176
|
+
reservation_id: the hold's ``id``.
|
|
177
|
+
body: ``amount`` to take; the whole hold when left out.
|
|
178
|
+
"""
|
|
179
|
+
result: CreditReservationResponse = await self._client.request(confirm_reservation_spec(reservation_id, body))
|
|
180
|
+
expire_entitlements(self._client.entitlement_cache, [result["customer_external_id"]])
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
async def release_reservation(self, reservation_id: str) -> CreditReservationResponse:
|
|
184
|
+
"""Free a hold with nothing taken; an expired hold answers ``status: "expired"``, a settled one raises ``ConflictError`` (409).
|
|
185
|
+
|
|
186
|
+
The customer's cached entitlement answers go stale.
|
|
187
|
+
|
|
188
|
+
reservation_id: the hold's ``id``.
|
|
189
|
+
"""
|
|
190
|
+
result: CreditReservationResponse = await self._client.request(release_reservation_spec(reservation_id))
|
|
191
|
+
expire_entitlements(self._client.entitlement_cache, [result["customer_external_id"]])
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
async def reservation(self, reservation_id: str) -> CreditReservationResponse:
|
|
195
|
+
"""One hold and how it ended: ``held``, ``confirmed``, ``released``, or ``expired`` once held past ``expires_at``.
|
|
196
|
+
|
|
197
|
+
reservation_id: the hold's ``id``.
|
|
198
|
+
"""
|
|
199
|
+
result: CreditReservationResponse = await self._client.request(reservation_spec(reservation_id))
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
async def balance(self, customer_id: str) -> CreditBalanceResponse:
|
|
203
|
+
"""The customer's current position — amounts are decimal strings."""
|
|
204
|
+
result: CreditBalanceResponse = await self._client.request(balance_spec(customer_id))
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
async def transactions(self, customer_id: str, *, limit: int | None = None, cursor: str | None = None) -> CreditTransactionPage:
|
|
208
|
+
"""One page of the ledger, newest first.
|
|
209
|
+
|
|
210
|
+
The ledger grows with every charge, so it is read a page at a time: ``data`` holds the rows
|
|
211
|
+
and ``next_cursor`` the mark for the page after this one (``None`` on the last). Pass it
|
|
212
|
+
back as ``cursor`` unchanged — it is opaque, and nothing but this API reads it.
|
|
213
|
+
"""
|
|
214
|
+
result: CreditTransactionPage = await self._client.request(transactions_spec(customer_id, limit, cursor))
|
|
215
|
+
return result
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Entitlements — ask BEFORE doing the work.
|
|
2
|
+
|
|
3
|
+
``check()`` is where a hard limit says no; recording usage afterwards never blocks, so an app that
|
|
4
|
+
skips this call gets no enforcement at all — only an honest ledger.
|
|
5
|
+
|
|
6
|
+
``allowed: False`` is a normal answer, not an error: the call returns and ``reason`` says why
|
|
7
|
+
(``no_subscription``, ``not_in_plan``, ``hard_limit_reached``, ``insufficient_credits``). A feature
|
|
8
|
+
key the project does not know at all is a 404 → ``NotFoundError``, because that is a typo, not a plan.
|
|
9
|
+
|
|
10
|
+
Answers are reused for the ``Cache-Control: max-age`` the API sends (15 s), per customer, feature and
|
|
11
|
+
quantity, unless ``entitlements_cache=False``. A usage record, grant or deduct for a customer makes
|
|
12
|
+
their answers stale. While MeterFlow is unreachable, a call returns its last answer with
|
|
13
|
+
``stale: True``, or else ``check()`` returns the ``entitlement_defaults`` entry for the feature.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .._entitlement_cache import CacheRead, EntitlementFallbackContext, is_outage, max_age_seconds
|
|
24
|
+
from .._http import RequestSpec, encode_path_segment
|
|
25
|
+
from ..types import EntitlementReason, EntitlementsResponse, FeatureType
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from ..client import AsyncMeterFlow, MeterFlow, _BaseClient
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _EntitlementCheckRequired(TypedDict):
|
|
32
|
+
feature: str
|
|
33
|
+
feature_type: FeatureType | None
|
|
34
|
+
allowed: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EntitlementCheck(_EntitlementCheckRequired, total=False):
|
|
38
|
+
"""What ``check()`` returns: the API's ``EntitlementResponse``, or a fallback while MeterFlow is unreachable.
|
|
39
|
+
|
|
40
|
+
``stale: True`` marks the last answer reused past its ``max-age``; ``reason: "offline_default"`` marks
|
|
41
|
+
your ``entitlement_defaults`` entry, which knows nothing of the plan, so its ``feature_type`` is None.
|
|
42
|
+
The other keys are ``EntitlementResponse``'s, repeated because a generated type cannot be widened.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
reason: EntitlementReason | Literal["offline_default"] | None
|
|
46
|
+
limit_type: str | None
|
|
47
|
+
included: int | None
|
|
48
|
+
used: str | None
|
|
49
|
+
remaining: str | None
|
|
50
|
+
period_start: str | None
|
|
51
|
+
period_end: str | None
|
|
52
|
+
stale: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EntitlementList(EntitlementsResponse, total=False):
|
|
56
|
+
"""What ``get()`` returns: the API's answer, with ``stale: True`` when it is the last one reused during an outage."""
|
|
57
|
+
|
|
58
|
+
stale: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_spec(customer_id: str) -> RequestSpec:
|
|
62
|
+
return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_spec(customer_id: str, feature: str, quantity: int | None) -> RequestSpec:
|
|
66
|
+
return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}/check", query={"feature": feature, "quantity": quantity})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_key(customer_id: str) -> str:
|
|
70
|
+
return f"get\0{customer_id}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_key(customer_id: str, feature: str, quantity: int | None) -> str:
|
|
74
|
+
return f"check\0{customer_id}\0{feature}\0{quantity or 1}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Answer:
|
|
78
|
+
"""One entitlements call's cache handling, shared by the sync and async resources, which only differ in how they wait.
|
|
79
|
+
|
|
80
|
+
client: the client whose cache and options apply.
|
|
81
|
+
key: the call's cache key.
|
|
82
|
+
customer_id: the customer the call is for.
|
|
83
|
+
feature: the feature checked; None for ``get()``, which has no default.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, client: _BaseClient, key: str, customer_id: str, feature: str | None) -> None:
|
|
87
|
+
self._client = client
|
|
88
|
+
self._key = key
|
|
89
|
+
self._customer_id = customer_id
|
|
90
|
+
self._feature = feature
|
|
91
|
+
self._max_age = 0
|
|
92
|
+
self.cached: CacheRead | None = client.entitlement_cache.read(key) if client.entitlement_cache is not None else None
|
|
93
|
+
|
|
94
|
+
def read_headers(self, headers: httpx.Headers) -> None:
|
|
95
|
+
"""Note the response's ``max-age``.
|
|
96
|
+
|
|
97
|
+
headers: the successful response's headers.
|
|
98
|
+
"""
|
|
99
|
+
self._max_age = max_age_seconds(headers.get("cache-control"))
|
|
100
|
+
|
|
101
|
+
def keep(self, value: Any) -> Any:
|
|
102
|
+
"""Store a fresh answer for its ``max-age``, and return it.
|
|
103
|
+
|
|
104
|
+
value: the API's answer.
|
|
105
|
+
"""
|
|
106
|
+
if self._client.entitlement_cache is not None:
|
|
107
|
+
self._client.entitlement_cache.write(self._key, self._customer_id, value, self._max_age)
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
def fallback(self, error: Exception) -> Any:
|
|
111
|
+
"""The answer for a failed call: the last one marked stale, else the feature's default.
|
|
112
|
+
|
|
113
|
+
Re-raises ``error`` when it was an answer rather than an outage, or when there is nothing to fall back on.
|
|
114
|
+
|
|
115
|
+
error: what the call raised.
|
|
116
|
+
"""
|
|
117
|
+
if not is_outage(error):
|
|
118
|
+
raise error
|
|
119
|
+
if self.cached is not None:
|
|
120
|
+
self._tell(error, "stale")
|
|
121
|
+
return {**self.cached.value, "stale": True}
|
|
122
|
+
default = None if self._feature is None else self._offline_default(self._feature)
|
|
123
|
+
if default is None:
|
|
124
|
+
raise error
|
|
125
|
+
self._tell(error, "default")
|
|
126
|
+
return default
|
|
127
|
+
|
|
128
|
+
def _offline_default(self, feature: str) -> EntitlementCheck | None:
|
|
129
|
+
"""The configured answer for a feature: its own entry, else ``"*"``; None when neither is set.
|
|
130
|
+
|
|
131
|
+
feature: the feature checked.
|
|
132
|
+
"""
|
|
133
|
+
defaults = self._client.options.entitlement_defaults
|
|
134
|
+
setting = defaults.get(feature, defaults.get("*"))
|
|
135
|
+
if setting is None:
|
|
136
|
+
return None
|
|
137
|
+
return {"feature": feature, "feature_type": None, "allowed": setting == "allow", "reason": "offline_default"}
|
|
138
|
+
|
|
139
|
+
def _tell(self, error: Exception, fallback: Literal["stale", "default"]) -> None:
|
|
140
|
+
"""Hand the error and the fallback to ``on_error``, ignoring anything it raises: it is the caller's code.
|
|
141
|
+
|
|
142
|
+
error: what the call raised.
|
|
143
|
+
fallback: what the call answers with instead.
|
|
144
|
+
"""
|
|
145
|
+
on_error: Callable[[Exception, EntitlementFallbackContext], None] | None = self._client.options.on_error
|
|
146
|
+
if on_error is None:
|
|
147
|
+
return
|
|
148
|
+
try:
|
|
149
|
+
on_error(error, EntitlementFallbackContext(self._customer_id, self._feature, fallback))
|
|
150
|
+
except Exception: # noqa: S110 — the hook is the caller's; its failure must not undo the fallback
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class EntitlementsResource:
|
|
155
|
+
def __init__(self, client: MeterFlow) -> None:
|
|
156
|
+
self._client = client
|
|
157
|
+
|
|
158
|
+
def get(self, customer_id: str) -> EntitlementList:
|
|
159
|
+
"""Every feature on the customer's plan, with this period's usage; a metered row answers for the next unit, as ``check()`` would.
|
|
160
|
+
|
|
161
|
+
Empty ``entitlements`` without a subscription.
|
|
162
|
+
customer_id: the customer asked about.
|
|
163
|
+
"""
|
|
164
|
+
return self._answer(_Answer(self._client, _get_key(customer_id), customer_id, None), get_spec(customer_id)) # type: ignore[no-any-return]
|
|
165
|
+
|
|
166
|
+
def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementCheck:
|
|
167
|
+
"""May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?
|
|
168
|
+
|
|
169
|
+
customer_id: the customer asked about.
|
|
170
|
+
feature: a meter's event name or a plan feature key.
|
|
171
|
+
quantity: how many more units the app is about to use; 1 on the server when None.
|
|
172
|
+
"""
|
|
173
|
+
answer = _Answer(self._client, _check_key(customer_id, feature, quantity), customer_id, feature)
|
|
174
|
+
return self._answer(answer, check_spec(customer_id, feature, quantity)) # type: ignore[no-any-return]
|
|
175
|
+
|
|
176
|
+
def _answer(self, answer: _Answer, spec: RequestSpec) -> Any:
|
|
177
|
+
"""Return the fresh cached answer, else the API's, else a fallback.
|
|
178
|
+
|
|
179
|
+
answer: the call's cache handling.
|
|
180
|
+
spec: the request to make when nothing fresh is cached.
|
|
181
|
+
"""
|
|
182
|
+
if answer.cached is not None and answer.cached.fresh:
|
|
183
|
+
return answer.cached.value
|
|
184
|
+
try:
|
|
185
|
+
return answer.keep(self._client.request(spec, answer.read_headers))
|
|
186
|
+
except Exception as error:
|
|
187
|
+
return answer.fallback(error)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class AsyncEntitlementsResource:
|
|
191
|
+
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
192
|
+
self._client = client
|
|
193
|
+
|
|
194
|
+
async def get(self, customer_id: str) -> EntitlementList:
|
|
195
|
+
"""Every feature on the customer's plan, with this period's usage; a metered row answers for the next unit, as ``check()`` would.
|
|
196
|
+
|
|
197
|
+
Empty ``entitlements`` without a subscription.
|
|
198
|
+
customer_id: the customer asked about.
|
|
199
|
+
"""
|
|
200
|
+
return await self._answer(_Answer(self._client, _get_key(customer_id), customer_id, None), get_spec(customer_id)) # type: ignore[no-any-return]
|
|
201
|
+
|
|
202
|
+
async def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementCheck:
|
|
203
|
+
"""May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?
|
|
204
|
+
|
|
205
|
+
customer_id: the customer asked about.
|
|
206
|
+
feature: a meter's event name or a plan feature key.
|
|
207
|
+
quantity: how many more units the app is about to use; 1 on the server when None.
|
|
208
|
+
"""
|
|
209
|
+
answer = _Answer(self._client, _check_key(customer_id, feature, quantity), customer_id, feature)
|
|
210
|
+
return await self._answer(answer, check_spec(customer_id, feature, quantity)) # type: ignore[no-any-return]
|
|
211
|
+
|
|
212
|
+
async def _answer(self, answer: _Answer, spec: RequestSpec) -> Any:
|
|
213
|
+
"""Return the fresh cached answer, else the API's, else a fallback.
|
|
214
|
+
|
|
215
|
+
answer: the call's cache handling.
|
|
216
|
+
spec: the request to make when nothing fresh is cached.
|
|
217
|
+
"""
|
|
218
|
+
if answer.cached is not None and answer.cached.fresh:
|
|
219
|
+
return answer.cached.value
|
|
220
|
+
try:
|
|
221
|
+
return answer.keep(await self._client.request(spec, answer.read_headers))
|
|
222
|
+
except Exception as error:
|
|
223
|
+
return answer.fallback(error)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
+
from .._entitlement_cache import expire_entitlements
|
|
7
8
|
from .._http import RequestSpec, encode_path_segment
|
|
8
9
|
from ..errors import PayloadTooLargeError
|
|
9
10
|
from ..types import UsageEventRequest, UsageEventResponse, UsageSummaryResponse
|
|
@@ -42,13 +43,18 @@ class UsageResource:
|
|
|
42
43
|
self._client = client
|
|
43
44
|
|
|
44
45
|
def record(self, body: UsageEventRequest, *, idempotency_key: str | None = None) -> UsageEventResponse:
|
|
45
|
-
"""Record one event. Returns immediately (``processed: False``); totals materialise moments later.
|
|
46
|
+
"""Record one event. Returns immediately (``processed: False``); totals materialise moments later.
|
|
47
|
+
|
|
48
|
+
The customer's cached entitlement answers go stale.
|
|
49
|
+
"""
|
|
46
50
|
result: UsageEventResponse = self._client.request(record_spec(body, idempotency_key))
|
|
51
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
47
52
|
return result
|
|
48
53
|
|
|
49
54
|
def record_batch(self, events: list[UsageEventRequest], *, idempotency_key: str | None = None) -> list[UsageEventResponse]:
|
|
50
55
|
"""Record up to ``MAX_BATCH_EVENTS`` events in one request. A larger list is refused locally, never sent."""
|
|
51
56
|
result: list[UsageEventResponse] = self._client.request(record_batch_spec(events, idempotency_key))
|
|
57
|
+
expire_entitlements(self._client.entitlement_cache, [event["customer_external_id"] for event in events])
|
|
52
58
|
return result
|
|
53
59
|
|
|
54
60
|
def event(self, event_id: str) -> UsageEventResponse:
|
|
@@ -74,13 +80,18 @@ class AsyncUsageResource:
|
|
|
74
80
|
self._client = client
|
|
75
81
|
|
|
76
82
|
async def record(self, body: UsageEventRequest, *, idempotency_key: str | None = None) -> UsageEventResponse:
|
|
77
|
-
"""Record one event. Returns immediately (``processed: False``); totals materialise moments later.
|
|
83
|
+
"""Record one event. Returns immediately (``processed: False``); totals materialise moments later.
|
|
84
|
+
|
|
85
|
+
The customer's cached entitlement answers go stale.
|
|
86
|
+
"""
|
|
78
87
|
result: UsageEventResponse = await self._client.request(record_spec(body, idempotency_key))
|
|
88
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
79
89
|
return result
|
|
80
90
|
|
|
81
91
|
async def record_batch(self, events: list[UsageEventRequest], *, idempotency_key: str | None = None) -> list[UsageEventResponse]:
|
|
82
92
|
"""Record up to ``MAX_BATCH_EVENTS`` events in one request. A larger list is refused locally, never sent."""
|
|
83
93
|
result: list[UsageEventResponse] = await self._client.request(record_batch_spec(events, idempotency_key))
|
|
94
|
+
expire_entitlements(self._client.entitlement_cache, [event["customer_external_id"] for event in events])
|
|
84
95
|
return result
|
|
85
96
|
|
|
86
97
|
async def event(self, event_id: str) -> UsageEventResponse:
|
|
@@ -17,6 +17,8 @@ EntitlementReason = Literal[
|
|
|
17
17
|
|
|
18
18
|
FeatureType = Literal["boolean", "metered"]
|
|
19
19
|
|
|
20
|
+
ReservationStatus = Literal["held", "confirmed", "released", "expired"]
|
|
21
|
+
|
|
20
22
|
SubscriptionStatus = Literal["active", "trialing", "past_due", "paused", "canceled", "expired"]
|
|
21
23
|
|
|
22
24
|
|
|
@@ -31,6 +33,8 @@ class CreditBalanceResponse(TypedDict):
|
|
|
31
33
|
version: int
|
|
32
34
|
created_at: str
|
|
33
35
|
updated_at: str
|
|
36
|
+
reserved: str
|
|
37
|
+
available: str
|
|
34
38
|
usage_draws_credits: bool | None
|
|
35
39
|
|
|
36
40
|
|
|
@@ -56,6 +60,45 @@ class CreditGrantRequest(_CreditGrantRequestRequired, total=False):
|
|
|
56
60
|
metadata: dict[str, Any]
|
|
57
61
|
|
|
58
62
|
|
|
63
|
+
class CreditReservationConfirmRequest(TypedDict, total=False):
|
|
64
|
+
"""Settle a hold: take some or all of it from the balance, and free the rest."""
|
|
65
|
+
|
|
66
|
+
amount: float | str | None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _CreditReservationRequestRequired(TypedDict):
|
|
70
|
+
customer_external_id: str
|
|
71
|
+
amount: float | str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CreditReservationRequest(_CreditReservationRequestRequired, total=False):
|
|
75
|
+
"""Hold part of a customer's balance for a while, so other spending cannot take it first."""
|
|
76
|
+
|
|
77
|
+
ttl_seconds: int
|
|
78
|
+
description: str | None
|
|
79
|
+
idempotency_key: str | None
|
|
80
|
+
metadata: dict[str, Any]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CreditReservationResponse(TypedDict):
|
|
84
|
+
"""A hold on part of a customer's balance, and how it ended."""
|
|
85
|
+
|
|
86
|
+
id: str
|
|
87
|
+
project_id: str
|
|
88
|
+
environment: str
|
|
89
|
+
customer_external_id: str
|
|
90
|
+
amount: str
|
|
91
|
+
status: ReservationStatus
|
|
92
|
+
expires_at: str
|
|
93
|
+
description: str | None
|
|
94
|
+
metadata_: dict[str, Any]
|
|
95
|
+
idempotency_key: str | None
|
|
96
|
+
confirmed_amount: str | None
|
|
97
|
+
transaction_id: str | None
|
|
98
|
+
created_at: str
|
|
99
|
+
settled_at: str | None
|
|
100
|
+
|
|
101
|
+
|
|
59
102
|
class _CreditTransactionPageRequired(TypedDict):
|
|
60
103
|
data: list[CreditTransactionResponse]
|
|
61
104
|
|
|
@@ -327,6 +370,9 @@ __all__ = [
|
|
|
327
370
|
"CreditBalanceResponse",
|
|
328
371
|
"CreditDeductRequest",
|
|
329
372
|
"CreditGrantRequest",
|
|
373
|
+
"CreditReservationConfirmRequest",
|
|
374
|
+
"CreditReservationRequest",
|
|
375
|
+
"CreditReservationResponse",
|
|
330
376
|
"CreditTransactionPage",
|
|
331
377
|
"CreditTransactionResponse",
|
|
332
378
|
"EntitlementReason",
|
|
@@ -337,6 +383,7 @@ __all__ = [
|
|
|
337
383
|
"PlanResponse",
|
|
338
384
|
"PortalLinkRequest",
|
|
339
385
|
"PortalLinkResponse",
|
|
386
|
+
"ReservationStatus",
|
|
340
387
|
"SubscriptionCreateRequest",
|
|
341
388
|
"SubscriptionPage",
|
|
342
389
|
"SubscriptionResponse",
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
"""Credits — an append-only ledger per customer: grant up, deduct down, never edited in place."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
6
|
-
|
|
7
|
-
from .._http import RequestSpec, encode_path_segment
|
|
8
|
-
from ..types import CreditBalanceResponse, CreditDeductRequest, CreditGrantRequest, CreditTransactionPage, CreditTransactionResponse
|
|
9
|
-
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from ..client import AsyncMeterFlow, MeterFlow
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def grant_spec(body: CreditGrantRequest, idempotency_key: str | None) -> RequestSpec:
|
|
15
|
-
return RequestSpec("POST", "credits/grant", body=body, idempotency_key=idempotency_key)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def deduct_spec(body: CreditDeductRequest, idempotency_key: str | None) -> RequestSpec:
|
|
19
|
-
return RequestSpec("POST", "credits/deduct", body=body, idempotency_key=idempotency_key)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def balance_spec(customer_id: str) -> RequestSpec:
|
|
23
|
-
return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/balance")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def transactions_spec(customer_id: str, limit: int | None, cursor: str | None) -> RequestSpec:
|
|
27
|
-
return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/transactions", query={"limit": limit, "cursor": cursor})
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class CreditsResource:
|
|
31
|
-
def __init__(self, client: MeterFlow) -> None:
|
|
32
|
-
self._client = client
|
|
33
|
-
|
|
34
|
-
def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
35
|
-
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
|
|
36
|
-
result: CreditTransactionResponse = self._client.request(grant_spec(body, idempotency_key))
|
|
37
|
-
return result
|
|
38
|
-
|
|
39
|
-
def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
40
|
-
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero."""
|
|
41
|
-
result: CreditTransactionResponse = self._client.request(deduct_spec(body, idempotency_key))
|
|
42
|
-
return result
|
|
43
|
-
|
|
44
|
-
def balance(self, customer_id: str) -> CreditBalanceResponse:
|
|
45
|
-
"""The customer's current position — amounts are decimal strings."""
|
|
46
|
-
result: CreditBalanceResponse = self._client.request(balance_spec(customer_id))
|
|
47
|
-
return result
|
|
48
|
-
|
|
49
|
-
def transactions(self, customer_id: str, *, limit: int | None = None, cursor: str | None = None) -> CreditTransactionPage:
|
|
50
|
-
"""One page of the ledger, newest first.
|
|
51
|
-
|
|
52
|
-
The ledger grows with every charge, so it is read a page at a time: ``data`` holds the rows
|
|
53
|
-
and ``next_cursor`` the mark for the page after this one (``None`` on the last). Pass it
|
|
54
|
-
back as ``cursor`` unchanged — it is opaque, and nothing but this API reads it.
|
|
55
|
-
"""
|
|
56
|
-
result: CreditTransactionPage = self._client.request(transactions_spec(customer_id, limit, cursor))
|
|
57
|
-
return result
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class AsyncCreditsResource:
|
|
61
|
-
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
62
|
-
self._client = client
|
|
63
|
-
|
|
64
|
-
async def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
65
|
-
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
|
|
66
|
-
result: CreditTransactionResponse = await self._client.request(grant_spec(body, idempotency_key))
|
|
67
|
-
return result
|
|
68
|
-
|
|
69
|
-
async def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
70
|
-
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero."""
|
|
71
|
-
result: CreditTransactionResponse = await self._client.request(deduct_spec(body, idempotency_key))
|
|
72
|
-
return result
|
|
73
|
-
|
|
74
|
-
async def balance(self, customer_id: str) -> CreditBalanceResponse:
|
|
75
|
-
"""The customer's current position — amounts are decimal strings."""
|
|
76
|
-
result: CreditBalanceResponse = await self._client.request(balance_spec(customer_id))
|
|
77
|
-
return result
|
|
78
|
-
|
|
79
|
-
async def transactions(self, customer_id: str, *, limit: int | None = None, cursor: str | None = None) -> CreditTransactionPage:
|
|
80
|
-
"""One page of the ledger, newest first.
|
|
81
|
-
|
|
82
|
-
The ledger grows with every charge, so it is read a page at a time: ``data`` holds the rows
|
|
83
|
-
and ``next_cursor`` the mark for the page after this one (``None`` on the last). Pass it
|
|
84
|
-
back as ``cursor`` unchanged — it is opaque, and nothing but this API reads it.
|
|
85
|
-
"""
|
|
86
|
-
result: CreditTransactionPage = await self._client.request(transactions_spec(customer_id, limit, cursor))
|
|
87
|
-
return result
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
"""Entitlements — ask BEFORE doing the work.
|
|
2
|
-
|
|
3
|
-
``check()`` is where a hard limit says no; recording usage afterwards never blocks, so an app that
|
|
4
|
-
skips this call gets no enforcement at all — only an honest ledger.
|
|
5
|
-
|
|
6
|
-
``allowed: False`` is a normal answer, not an error: the call returns and ``reason`` says why
|
|
7
|
-
(``no_subscription``, ``not_in_plan``, ``hard_limit_reached``, ``insufficient_credits``). A feature
|
|
8
|
-
key the project does not know at all is a 404 → ``NotFoundError``, because that is a typo, not a plan.
|
|
9
|
-
|
|
10
|
-
Both calls return ``Cache-Control: private, max-age=15`` — cache per customer for a few seconds on
|
|
11
|
-
hot paths rather than calling on every request.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
from typing import TYPE_CHECKING
|
|
17
|
-
|
|
18
|
-
from .._http import RequestSpec, encode_path_segment
|
|
19
|
-
from ..types import EntitlementResponse, EntitlementsResponse
|
|
20
|
-
|
|
21
|
-
if TYPE_CHECKING:
|
|
22
|
-
from ..client import AsyncMeterFlow, MeterFlow
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def get_spec(customer_id: str) -> RequestSpec:
|
|
26
|
-
return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def check_spec(customer_id: str, feature: str, quantity: int | None) -> RequestSpec:
|
|
30
|
-
return RequestSpec("GET", f"entitlements/{encode_path_segment(customer_id)}/check", query={"feature": feature, "quantity": quantity})
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class EntitlementsResource:
|
|
34
|
-
def __init__(self, client: MeterFlow) -> None:
|
|
35
|
-
self._client = client
|
|
36
|
-
|
|
37
|
-
def get(self, customer_id: str) -> EntitlementsResponse:
|
|
38
|
-
"""Every feature on the customer's plan, with this period's usage; a metered row answers for the next unit, as ``check()`` would.
|
|
39
|
-
|
|
40
|
-
Empty ``entitlements`` without a subscription.
|
|
41
|
-
"""
|
|
42
|
-
result: EntitlementsResponse = self._client.request(get_spec(customer_id))
|
|
43
|
-
return result
|
|
44
|
-
|
|
45
|
-
def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
|
|
46
|
-
"""May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
|
|
47
|
-
result: EntitlementResponse = self._client.request(check_spec(customer_id, feature, quantity))
|
|
48
|
-
return result
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class AsyncEntitlementsResource:
|
|
52
|
-
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
53
|
-
self._client = client
|
|
54
|
-
|
|
55
|
-
async def get(self, customer_id: str) -> EntitlementsResponse:
|
|
56
|
-
"""Every feature on the customer's plan, with this period's usage; a metered row answers for the next unit, as ``check()`` would.
|
|
57
|
-
|
|
58
|
-
Empty ``entitlements`` without a subscription.
|
|
59
|
-
"""
|
|
60
|
-
result: EntitlementsResponse = await self._client.request(get_spec(customer_id))
|
|
61
|
-
return result
|
|
62
|
-
|
|
63
|
-
async def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
|
|
64
|
-
"""May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
|
|
65
|
-
result: EntitlementResponse = await self._client.request(check_spec(customer_id, feature, quantity))
|
|
66
|
-
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|