meterflow 0.3.0__tar.gz → 0.5.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.3.0 → meterflow-0.5.0}/PKG-INFO +49 -5
- {meterflow-0.3.0 → meterflow-0.5.0}/README.md +48 -4
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/__init__.py +6 -0
- meterflow-0.5.0/src/meterflow/_entitlement_cache.py +134 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/_http.py +11 -5
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/_version.py +1 -1
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/client.py +47 -8
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/resources/credits.py +15 -4
- meterflow-0.5.0/src/meterflow/resources/entitlements.py +223 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/resources/subscriptions.py +34 -9
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/resources/usage.py +13 -2
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/resources/webhooks.py +39 -1
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/types.py +17 -1
- meterflow-0.3.0/src/meterflow/resources/entitlements.py +0 -60
- {meterflow-0.3.0 → meterflow-0.5.0}/LICENSE +0 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/pyproject.toml +0 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/_retry.py +0 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/errors.py +0 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/py.typed +0 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/resources/__init__.py +0 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/resources/plans.py +0 -0
- {meterflow-0.3.0 → meterflow-0.5.0}/src/meterflow/resources/portal.py +0 -0
- {meterflow-0.3.0 → meterflow-0.5.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.5.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
|
|
@@ -59,6 +59,10 @@ Official Python SDK for [MeterFlow](https://meter-flow.com) — usage-based bill
|
|
|
59
59
|
|
|
60
60
|
Track what your customers use, enforce credit balances, gate features, and manage subscriptions with a few lines of code. Fully typed, sync **and** async, one dependency (`httpx`).
|
|
61
61
|
|
|
62
|
+
- **Flat $29, never a percentage of your revenue.** MeterFlow's [plans](https://meter-flow.com/pricing) follow how many usage events you send, not what your customers pay you.
|
|
63
|
+
- **Works next to any payment provider.** MeterFlow never moves money: your provider charges your customers, and this SDK records their usage, keeps their credit balances and asks whether they may go on. Paddle, Polar, Lemon Squeezy, Chargebee and Razorpay connect from the dashboard; anything else calls `credits.grant` when a payment lands.
|
|
64
|
+
- **Test and live by key prefix.** An `mf_test_` key reads and writes test data, an `mf_live_` key live data — the same code, two worlds that never mix.
|
|
65
|
+
|
|
62
66
|
---
|
|
63
67
|
|
|
64
68
|
## Contents
|
|
@@ -186,6 +190,9 @@ client = MeterFlow(
|
|
|
186
190
|
timeout=30.0, # optional — per-request timeout in seconds (default 30)
|
|
187
191
|
retries=3, # optional — automatic retries (default 3, 0 disables)
|
|
188
192
|
transport=None, # optional — an httpx transport (tests, proxies)
|
|
193
|
+
entitlements_cache=True, # optional — reuse entitlement answers for their max-age (default on)
|
|
194
|
+
entitlement_defaults={"*": "deny"}, # optional — what check() answers while MeterFlow is unreachable
|
|
195
|
+
on_error=None, # optional — told when an entitlements call answers from a fallback
|
|
189
196
|
)
|
|
190
197
|
```
|
|
191
198
|
|
|
@@ -234,8 +241,11 @@ bal = client.credits.balance("customer_123")
|
|
|
234
241
|
bal["balance"] # "3545.000000" ← decimal string, see note below
|
|
235
242
|
bal["total_granted"] # "5250.000000"
|
|
236
243
|
bal["total_consumed"] # "1705.000000"
|
|
244
|
+
bal["usage_draws_credits"] # True | False | None
|
|
237
245
|
```
|
|
238
246
|
|
|
247
|
+
`usage_draws_credits` says whether the customer's plan charges usage to this balance: `True` when a metered limit carries a rate, `False` when the plan never does (the balance then moves only through your own `credits.deduct`), `None` without an active or trialing subscription.
|
|
248
|
+
|
|
239
249
|
**Transactions** — the history, newest first, **a page at a time**:
|
|
240
250
|
|
|
241
251
|
```python
|
|
@@ -371,6 +381,7 @@ print(sub["status"]) # "trialing" if the plan has trial days, else "active"
|
|
|
371
381
|
page = client.subscriptions.list() # whole project (in your key's environment)
|
|
372
382
|
theirs = client.subscriptions.list(customer_id="customer_123") # one customer
|
|
373
383
|
more = client.subscriptions.list(limit=200, cursor=page["next_cursor"])
|
|
384
|
+
behind = client.subscriptions.list(q="acme", status=["past_due", "paused"]) # ids containing "acme", in either status
|
|
374
385
|
|
|
375
386
|
for subscription in page["data"]:
|
|
376
387
|
print(subscription["customer_external_id"], subscription["status"])
|
|
@@ -421,13 +432,17 @@ receipts[0]["event_type"] # "usage.recorded"
|
|
|
421
432
|
receipts[0]["status"] # "pending" | "retrying" | "success" | "failed"
|
|
422
433
|
receipts[0]["response_code"] # what your server answered — 200, 500, or None if it never answered
|
|
423
434
|
receipts[0]["attempts"] # how many times we have tried
|
|
424
|
-
receipts[0]["next_retry_at"] # when we will try again, or None
|
|
425
435
|
receipts[0]["payload"] # exactly what we sent
|
|
436
|
+
|
|
437
|
+
page = client.webhooks.delivery_page(endpoint_id, limit=200) # the same rows, a page at a time
|
|
438
|
+
older = client.webhooks.delivery_page(endpoint_id, cursor=page["next_cursor"]) # None on the last page
|
|
426
439
|
```
|
|
427
440
|
|
|
441
|
+
A delivery is tried up to five times: at once, then after 30 s, 5 min, 30 min and 2 h, each later try scheduled through the queue, where it survives a worker restart. `attempts` counts the tries; the receipt shows `pending` before the first try and `retrying` between tries.
|
|
442
|
+
|
|
428
443
|
Typical use: answering "did that event reach my endpoint?" from your own tooling instead of opening the dashboard — and reading `response_body` when the answer is no.
|
|
429
444
|
|
|
430
|
-
Two things to know.
|
|
445
|
+
Two things to know. `deliveries()` returns the **most recent 100** attempts per endpoint; `delivery_page()` reads further back, one page at a time. And both follow your key: a `mf_test_*` key reads test receipts, a `mf_live_*` key live ones, while the endpoint list itself is shared by both worlds, because an endpoint is configuration and receives events from each.
|
|
431
446
|
|
|
432
447
|
> This is not the same as [verifying webhooks](#verifying-webhooks) below. That proves a request your server just received really came from us; this reads what we sent.
|
|
433
448
|
|
|
@@ -451,7 +466,7 @@ client.usage.record({"event_name": "images.generated", "customer_external_id": "
|
|
|
451
466
|
- A **soft** limit answers `allowed: True` with `reason: "soft_limit_exceeded"` — let it through, nudge the upgrade.
|
|
452
467
|
- A **metered** limit stays allowed while the customer's credits cover the overage; otherwise `insufficient_credits`.
|
|
453
468
|
- `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.
|
|
454
|
-
- The answer
|
|
469
|
+
- 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).
|
|
455
470
|
|
|
456
471
|
**All of them at once** — for a settings or pricing screen:
|
|
457
472
|
|
|
@@ -462,8 +477,35 @@ result["entitlements"]
|
|
|
462
477
|
# {"feature": "images.generated", "feature_type": "metered", "used": "120", "included": 500, "remaining": "380", ...}]
|
|
463
478
|
```
|
|
464
479
|
|
|
480
|
+
Each metered row answers for the **next** unit, exactly as `check()` would, so its `allowed` and `reason` match the gate. Reading the list fires no webhook; only `check()` announces `balance.exhausted`.
|
|
481
|
+
|
|
465
482
|
Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
|
|
466
483
|
|
|
484
|
+
#### When MeterFlow can't be reached
|
|
485
|
+
|
|
486
|
+
A MeterFlow blip should never take your product down with it. Three things keep `check()` answering, on `MeterFlow` and `AsyncMeterFlow` alike:
|
|
487
|
+
|
|
488
|
+
- **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()` or `credits.deduct()` makes that customer's answers stale, so the next `check()` asks again. `entitlements_cache=False` turns the cache off.
|
|
489
|
+
- **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`.
|
|
490
|
+
- **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.
|
|
491
|
+
|
|
492
|
+
```python
|
|
493
|
+
from meterflow import EntitlementFallbackContext, MeterFlow
|
|
494
|
+
|
|
495
|
+
def warn(error: Exception, context: EntitlementFallbackContext) -> None:
|
|
496
|
+
log.warning("meterflow fallback %s for %s / %s: %s", context.fallback, context.customer_id, context.feature, error)
|
|
497
|
+
|
|
498
|
+
client = MeterFlow(
|
|
499
|
+
api_key=os.environ["METERFLOW_API_KEY"],
|
|
500
|
+
timeout=2.0, # a gate on your hot path should give up fast
|
|
501
|
+
retries=1,
|
|
502
|
+
entitlement_defaults={"images.generated": "allow", "*": "deny"},
|
|
503
|
+
on_error=warn,
|
|
504
|
+
)
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
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`.
|
|
508
|
+
|
|
467
509
|
### The balance portal — a page you don't have to build
|
|
468
510
|
|
|
469
511
|
Mint a short-lived link to a MeterFlow-hosted page showing **one** customer's balance and ledger.
|
|
@@ -502,6 +544,8 @@ client.credits.deduct(
|
|
|
502
544
|
|
|
503
545
|
Every write method accepts the keyword: `credits.grant/deduct`, `usage.record/record_batch`, `subscriptions.create/update`. Use a key that identifies the *business operation* (order ID, job ID) — not a random value per attempt, which would defeat the purpose.
|
|
504
546
|
|
|
547
|
+
MeterFlow remembers a key for 24 hours; after that the same key starts a new operation, so a retry must come within the day (a late subscription retry meets the one-per-customer rule instead of replaying).
|
|
548
|
+
|
|
505
549
|
**A key is bound to the body you first used it with.** MeterFlow stores a fingerprint of the request alongside the key, so a retry only replays when it is genuinely the same request. Reuse the key with a *different* body and the call is refused with `ConflictError` (409) instead of quietly handing back the original record:
|
|
506
550
|
|
|
507
551
|
```python
|
|
@@ -604,7 +648,7 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
|
|
|
604
648
|
| `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` |
|
|
605
649
|
| `balance.low` | a spend takes a customer's balance **down through** this endpoint's `low_balance_threshold` — a crossing, so it fires once and not again while they stay below. Raised by both doors that spend: `credits.deduct` and a metered usage charge. **Both crossing events follow from the endpoint's `low_balance_threshold`: set one and the API adds them, clear it and the API removes them — so a line is the only thing to configure.** | `customer_id`, `balance`, `threshold`, `suggested_topup` (the subscription's `topup_amount`, `null` when unset), `transaction_id` |
|
|
606
650
|
| `balance.exhausted` | a balance reaches zero, **or** a request is refused for want of credits (a deduction returning 402, or `entitlements.check` answering `insufficient_credits`). Unlike `balance.low` this repeats: every refused request is its own blocked request | `customer_id`, `balance`, `reason` (`balance_zero` / `insufficient_credits`), `requested_amount` and `feature` where they apply, `transaction_id` on the zero case |
|
|
607
|
-
| `subscription.renewed` | a billing period opens —
|
|
651
|
+
| `subscription.renewed` | a billing period opens — a paid period rolling forward, a **trial converting** to its first paid period, or a canceled subscription **reactivated** after its period lapsed, told apart by `reason` (the renewer emits no `subscription.updated` for the first two) | `customer_id`, `subscription_id`, `plan_id`, `status`, `reason` (`period_renewed` / `trial_converted` / `period_reactivated`), `current_period_start`, `current_period_end`, `credits_granted` |
|
|
608
652
|
| `subscription.canceled` | a subscription's status becomes canceled — fired **alongside** `subscription.updated`, once, on the call that actually cancels | `customer_id`, `subscription_id`, `plan_id`, `canceled_at` |
|
|
609
653
|
| `balance.topped_up` | a grant takes a customer's balance **up through** this endpoint's `low_balance_threshold` — the upward mirror of `balance.low`, on the same per-endpoint line, so the endpoint told "low" is the one told "recovered". Subscribed together with `balance.low` by setting the threshold, never on its own | `customer_id`, `balance`, `threshold`, `amount`, `transaction_id` |
|
|
610
654
|
| `cap.reached` | a customer's credit spending reaches their subscription's `spend_cap` for the period — once per subscription per period, whether they landed on the cap or were refused by it. A refusal that still leaves headroom is not a reach and does not fire | `customer_id`, `subscription_id`, `cap`, `spent`, `period_start`, `period_end` |
|
|
@@ -7,6 +7,10 @@ Official Python SDK for [MeterFlow](https://meter-flow.com) — usage-based bill
|
|
|
7
7
|
|
|
8
8
|
Track what your customers use, enforce credit balances, gate features, and manage subscriptions with a few lines of code. Fully typed, sync **and** async, one dependency (`httpx`).
|
|
9
9
|
|
|
10
|
+
- **Flat $29, never a percentage of your revenue.** MeterFlow's [plans](https://meter-flow.com/pricing) follow how many usage events you send, not what your customers pay you.
|
|
11
|
+
- **Works next to any payment provider.** MeterFlow never moves money: your provider charges your customers, and this SDK records their usage, keeps their credit balances and asks whether they may go on. Paddle, Polar, Lemon Squeezy, Chargebee and Razorpay connect from the dashboard; anything else calls `credits.grant` when a payment lands.
|
|
12
|
+
- **Test and live by key prefix.** An `mf_test_` key reads and writes test data, an `mf_live_` key live data — the same code, two worlds that never mix.
|
|
13
|
+
|
|
10
14
|
---
|
|
11
15
|
|
|
12
16
|
## Contents
|
|
@@ -134,6 +138,9 @@ client = MeterFlow(
|
|
|
134
138
|
timeout=30.0, # optional — per-request timeout in seconds (default 30)
|
|
135
139
|
retries=3, # optional — automatic retries (default 3, 0 disables)
|
|
136
140
|
transport=None, # optional — an httpx transport (tests, proxies)
|
|
141
|
+
entitlements_cache=True, # optional — reuse entitlement answers for their max-age (default on)
|
|
142
|
+
entitlement_defaults={"*": "deny"}, # optional — what check() answers while MeterFlow is unreachable
|
|
143
|
+
on_error=None, # optional — told when an entitlements call answers from a fallback
|
|
137
144
|
)
|
|
138
145
|
```
|
|
139
146
|
|
|
@@ -182,8 +189,11 @@ bal = client.credits.balance("customer_123")
|
|
|
182
189
|
bal["balance"] # "3545.000000" ← decimal string, see note below
|
|
183
190
|
bal["total_granted"] # "5250.000000"
|
|
184
191
|
bal["total_consumed"] # "1705.000000"
|
|
192
|
+
bal["usage_draws_credits"] # True | False | None
|
|
185
193
|
```
|
|
186
194
|
|
|
195
|
+
`usage_draws_credits` says whether the customer's plan charges usage to this balance: `True` when a metered limit carries a rate, `False` when the plan never does (the balance then moves only through your own `credits.deduct`), `None` without an active or trialing subscription.
|
|
196
|
+
|
|
187
197
|
**Transactions** — the history, newest first, **a page at a time**:
|
|
188
198
|
|
|
189
199
|
```python
|
|
@@ -319,6 +329,7 @@ print(sub["status"]) # "trialing" if the plan has trial days, else "active"
|
|
|
319
329
|
page = client.subscriptions.list() # whole project (in your key's environment)
|
|
320
330
|
theirs = client.subscriptions.list(customer_id="customer_123") # one customer
|
|
321
331
|
more = client.subscriptions.list(limit=200, cursor=page["next_cursor"])
|
|
332
|
+
behind = client.subscriptions.list(q="acme", status=["past_due", "paused"]) # ids containing "acme", in either status
|
|
322
333
|
|
|
323
334
|
for subscription in page["data"]:
|
|
324
335
|
print(subscription["customer_external_id"], subscription["status"])
|
|
@@ -369,13 +380,17 @@ receipts[0]["event_type"] # "usage.recorded"
|
|
|
369
380
|
receipts[0]["status"] # "pending" | "retrying" | "success" | "failed"
|
|
370
381
|
receipts[0]["response_code"] # what your server answered — 200, 500, or None if it never answered
|
|
371
382
|
receipts[0]["attempts"] # how many times we have tried
|
|
372
|
-
receipts[0]["next_retry_at"] # when we will try again, or None
|
|
373
383
|
receipts[0]["payload"] # exactly what we sent
|
|
384
|
+
|
|
385
|
+
page = client.webhooks.delivery_page(endpoint_id, limit=200) # the same rows, a page at a time
|
|
386
|
+
older = client.webhooks.delivery_page(endpoint_id, cursor=page["next_cursor"]) # None on the last page
|
|
374
387
|
```
|
|
375
388
|
|
|
389
|
+
A delivery is tried up to five times: at once, then after 30 s, 5 min, 30 min and 2 h, each later try scheduled through the queue, where it survives a worker restart. `attempts` counts the tries; the receipt shows `pending` before the first try and `retrying` between tries.
|
|
390
|
+
|
|
376
391
|
Typical use: answering "did that event reach my endpoint?" from your own tooling instead of opening the dashboard — and reading `response_body` when the answer is no.
|
|
377
392
|
|
|
378
|
-
Two things to know.
|
|
393
|
+
Two things to know. `deliveries()` returns the **most recent 100** attempts per endpoint; `delivery_page()` reads further back, one page at a time. And both follow your key: a `mf_test_*` key reads test receipts, a `mf_live_*` key live ones, while the endpoint list itself is shared by both worlds, because an endpoint is configuration and receives events from each.
|
|
379
394
|
|
|
380
395
|
> This is not the same as [verifying webhooks](#verifying-webhooks) below. That proves a request your server just received really came from us; this reads what we sent.
|
|
381
396
|
|
|
@@ -399,7 +414,7 @@ client.usage.record({"event_name": "images.generated", "customer_external_id": "
|
|
|
399
414
|
- A **soft** limit answers `allowed: True` with `reason: "soft_limit_exceeded"` — let it through, nudge the upgrade.
|
|
400
415
|
- A **metered** limit stays allowed while the customer's credits cover the overage; otherwise `insufficient_credits`.
|
|
401
416
|
- `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.
|
|
402
|
-
- The answer
|
|
417
|
+
- 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).
|
|
403
418
|
|
|
404
419
|
**All of them at once** — for a settings or pricing screen:
|
|
405
420
|
|
|
@@ -410,8 +425,35 @@ result["entitlements"]
|
|
|
410
425
|
# {"feature": "images.generated", "feature_type": "metered", "used": "120", "included": 500, "remaining": "380", ...}]
|
|
411
426
|
```
|
|
412
427
|
|
|
428
|
+
Each metered row answers for the **next** unit, exactly as `check()` would, so its `allowed` and `reason` match the gate. Reading the list fires no webhook; only `check()` announces `balance.exhausted`.
|
|
429
|
+
|
|
413
430
|
Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
|
|
414
431
|
|
|
432
|
+
#### When MeterFlow can't be reached
|
|
433
|
+
|
|
434
|
+
A MeterFlow blip should never take your product down with it. Three things keep `check()` answering, on `MeterFlow` and `AsyncMeterFlow` alike:
|
|
435
|
+
|
|
436
|
+
- **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()` or `credits.deduct()` makes that customer's answers stale, so the next `check()` asks again. `entitlements_cache=False` turns the cache off.
|
|
437
|
+
- **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`.
|
|
438
|
+
- **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.
|
|
439
|
+
|
|
440
|
+
```python
|
|
441
|
+
from meterflow import EntitlementFallbackContext, MeterFlow
|
|
442
|
+
|
|
443
|
+
def warn(error: Exception, context: EntitlementFallbackContext) -> None:
|
|
444
|
+
log.warning("meterflow fallback %s for %s / %s: %s", context.fallback, context.customer_id, context.feature, error)
|
|
445
|
+
|
|
446
|
+
client = MeterFlow(
|
|
447
|
+
api_key=os.environ["METERFLOW_API_KEY"],
|
|
448
|
+
timeout=2.0, # a gate on your hot path should give up fast
|
|
449
|
+
retries=1,
|
|
450
|
+
entitlement_defaults={"images.generated": "allow", "*": "deny"},
|
|
451
|
+
on_error=warn,
|
|
452
|
+
)
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
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`.
|
|
456
|
+
|
|
415
457
|
### The balance portal — a page you don't have to build
|
|
416
458
|
|
|
417
459
|
Mint a short-lived link to a MeterFlow-hosted page showing **one** customer's balance and ledger.
|
|
@@ -450,6 +492,8 @@ client.credits.deduct(
|
|
|
450
492
|
|
|
451
493
|
Every write method accepts the keyword: `credits.grant/deduct`, `usage.record/record_batch`, `subscriptions.create/update`. Use a key that identifies the *business operation* (order ID, job ID) — not a random value per attempt, which would defeat the purpose.
|
|
452
494
|
|
|
495
|
+
MeterFlow remembers a key for 24 hours; after that the same key starts a new operation, so a retry must come within the day (a late subscription retry meets the one-per-customer rule instead of replaying).
|
|
496
|
+
|
|
453
497
|
**A key is bound to the body you first used it with.** MeterFlow stores a fingerprint of the request alongside the key, so a retry only replays when it is genuinely the same request. Reuse the key with a *different* body and the call is refused with `ConflictError` (409) instead of quietly handing back the original record:
|
|
454
498
|
|
|
455
499
|
```python
|
|
@@ -552,7 +596,7 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
|
|
|
552
596
|
| `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` |
|
|
553
597
|
| `balance.low` | a spend takes a customer's balance **down through** this endpoint's `low_balance_threshold` — a crossing, so it fires once and not again while they stay below. Raised by both doors that spend: `credits.deduct` and a metered usage charge. **Both crossing events follow from the endpoint's `low_balance_threshold`: set one and the API adds them, clear it and the API removes them — so a line is the only thing to configure.** | `customer_id`, `balance`, `threshold`, `suggested_topup` (the subscription's `topup_amount`, `null` when unset), `transaction_id` |
|
|
554
598
|
| `balance.exhausted` | a balance reaches zero, **or** a request is refused for want of credits (a deduction returning 402, or `entitlements.check` answering `insufficient_credits`). Unlike `balance.low` this repeats: every refused request is its own blocked request | `customer_id`, `balance`, `reason` (`balance_zero` / `insufficient_credits`), `requested_amount` and `feature` where they apply, `transaction_id` on the zero case |
|
|
555
|
-
| `subscription.renewed` | a billing period opens —
|
|
599
|
+
| `subscription.renewed` | a billing period opens — a paid period rolling forward, a **trial converting** to its first paid period, or a canceled subscription **reactivated** after its period lapsed, told apart by `reason` (the renewer emits no `subscription.updated` for the first two) | `customer_id`, `subscription_id`, `plan_id`, `status`, `reason` (`period_renewed` / `trial_converted` / `period_reactivated`), `current_period_start`, `current_period_end`, `credits_granted` |
|
|
556
600
|
| `subscription.canceled` | a subscription's status becomes canceled — fired **alongside** `subscription.updated`, once, on the call that actually cancels | `customer_id`, `subscription_id`, `plan_id`, `canceled_at` |
|
|
557
601
|
| `balance.topped_up` | a grant takes a customer's balance **up through** this endpoint's `low_balance_threshold` — the upward mirror of `balance.low`, on the same per-endpoint line, so the endpoint told "low" is the one told "recovered". Subscribed together with `balance.low` by setting the threshold, never on its own | `customer_id`, `balance`, `threshold`, `amount`, `transaction_id` |
|
|
558
602
|
| `cap.reached` | a customer's credit spending reaches their subscription's `spend_cap` for the period — once per subscription per period, whether they landed on the cap or were refused by it. A refusal that still leaves headroom is not a reach and does not fire | `customer_id`, `subscription_id`, `cap`, `spent`, `period_start`, `period_end` |
|
|
@@ -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)
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import json
|
|
11
11
|
import time
|
|
12
|
+
from collections.abc import Sequence
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
from typing import Any
|
|
14
15
|
from urllib.parse import quote
|
|
@@ -31,7 +32,7 @@ from .errors import (
|
|
|
31
32
|
|
|
32
33
|
USER_AGENT = f"meterflow-python/{__version__}"
|
|
33
34
|
|
|
34
|
-
QueryValue = str | int | float | bool | None
|
|
35
|
+
QueryValue = str | int | float | bool | Sequence[str] | None
|
|
35
36
|
|
|
36
37
|
# Indirections so tests can make the retry loop instantaneous without touching the clock.
|
|
37
38
|
_sleep = time.sleep
|
|
@@ -67,13 +68,18 @@ def build_headers(api_key: str, spec: RequestSpec) -> dict[str, str]:
|
|
|
67
68
|
return headers
|
|
68
69
|
|
|
69
70
|
|
|
70
|
-
def build_params(query: dict[str, QueryValue]) -> dict[str, str]:
|
|
71
|
-
"""Unset (``None``) filters are simply not sent; booleans go as ``true``/``false`` like the Node SDK."""
|
|
72
|
-
params: dict[str, str] = {}
|
|
71
|
+
def build_params(query: dict[str, QueryValue]) -> dict[str, str | list[str]]:
|
|
72
|
+
"""Unset (``None``) filters are simply not sent; booleans go as ``true``/``false`` like the Node SDK; a list repeats its key."""
|
|
73
|
+
params: dict[str, str | list[str]] = {}
|
|
73
74
|
for key, value in query.items():
|
|
74
75
|
if value is None:
|
|
75
76
|
continue
|
|
76
|
-
|
|
77
|
+
if isinstance(value, bool):
|
|
78
|
+
params[key] = "true" if value else "false"
|
|
79
|
+
elif isinstance(value, str | int | float):
|
|
80
|
+
params[key] = str(value)
|
|
81
|
+
else:
|
|
82
|
+
params[key] = [str(item) for item in value]
|
|
77
83
|
return params
|
|
78
84
|
|
|
79
85
|
|
|
@@ -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)
|
|
@@ -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 ..types import CreditBalanceResponse, CreditDeductRequest, CreditGrantRequest, CreditTransactionPage, CreditTransactionResponse
|
|
9
10
|
|
|
@@ -32,13 +33,18 @@ class CreditsResource:
|
|
|
32
33
|
self._client = client
|
|
33
34
|
|
|
34
35
|
def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
35
|
-
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
|
|
36
|
+
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line. The customer's cached entitlement answers go stale."""
|
|
36
37
|
result: CreditTransactionResponse = self._client.request(grant_spec(body, idempotency_key))
|
|
38
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
37
39
|
return result
|
|
38
40
|
|
|
39
41
|
def deduct(self, body: CreditDeductRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
40
|
-
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero.
|
|
42
|
+
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero.
|
|
43
|
+
|
|
44
|
+
The customer's cached entitlement answers go stale.
|
|
45
|
+
"""
|
|
41
46
|
result: CreditTransactionResponse = self._client.request(deduct_spec(body, idempotency_key))
|
|
47
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
42
48
|
return result
|
|
43
49
|
|
|
44
50
|
def balance(self, customer_id: str) -> CreditBalanceResponse:
|
|
@@ -62,13 +68,18 @@ class AsyncCreditsResource:
|
|
|
62
68
|
self._client = client
|
|
63
69
|
|
|
64
70
|
async def grant(self, body: CreditGrantRequest, *, idempotency_key: str | None = None) -> CreditTransactionResponse:
|
|
65
|
-
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line."""
|
|
71
|
+
"""Add credits (plan renewals, top-ups, goodwill). Returns the ledger line. The customer's cached entitlement answers go stale."""
|
|
66
72
|
result: CreditTransactionResponse = await self._client.request(grant_spec(body, idempotency_key))
|
|
73
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
67
74
|
return result
|
|
68
75
|
|
|
69
76
|
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.
|
|
77
|
+
"""Remove credits. Raises ``InsufficientCreditsError`` (402) rather than take the customer below zero.
|
|
78
|
+
|
|
79
|
+
The customer's cached entitlement answers go stale.
|
|
80
|
+
"""
|
|
71
81
|
result: CreditTransactionResponse = await self._client.request(deduct_spec(body, idempotency_key))
|
|
82
|
+
expire_entitlements(self._client.entitlement_cache, [body["customer_external_id"]])
|
|
72
83
|
return result
|
|
73
84
|
|
|
74
85
|
async def balance(self, customer_id: str) -> CreditBalanceResponse:
|
|
@@ -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)
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Sequence
|
|
5
6
|
from typing import TYPE_CHECKING
|
|
6
7
|
|
|
7
8
|
from .._http import RequestSpec, encode_path_segment
|
|
8
|
-
from ..types import SubscriptionCreateRequest, SubscriptionPage, SubscriptionResponse, SubscriptionUpdateRequest
|
|
9
|
+
from ..types import SubscriptionCreateRequest, SubscriptionPage, SubscriptionResponse, SubscriptionStatus, SubscriptionUpdateRequest
|
|
9
10
|
|
|
10
11
|
if TYPE_CHECKING:
|
|
11
12
|
from ..client import AsyncMeterFlow, MeterFlow
|
|
@@ -15,8 +16,12 @@ def create_spec(body: SubscriptionCreateRequest, idempotency_key: str | None) ->
|
|
|
15
16
|
return RequestSpec("POST", "subscriptions", body=body, idempotency_key=idempotency_key)
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
def list_spec(
|
|
19
|
-
|
|
19
|
+
def list_spec(
|
|
20
|
+
customer_id: str | None, q: str | None, status: Sequence[SubscriptionStatus] | None, limit: int | None, cursor: str | None
|
|
21
|
+
) -> RequestSpec:
|
|
22
|
+
return RequestSpec(
|
|
23
|
+
"GET", "subscriptions", query={"customer_id": customer_id, "q": q, "status": status, "limit": limit, "cursor": cursor}
|
|
24
|
+
)
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
def get_spec(subscription_id: str) -> RequestSpec:
|
|
@@ -40,14 +45,24 @@ class SubscriptionsResource:
|
|
|
40
45
|
result: SubscriptionResponse = self._client.request(create_spec(body, idempotency_key))
|
|
41
46
|
return result
|
|
42
47
|
|
|
43
|
-
def list(
|
|
48
|
+
def list(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
customer_id: str | None = None,
|
|
52
|
+
q: str | None = None,
|
|
53
|
+
status: Sequence[SubscriptionStatus] | None = None,
|
|
54
|
+
limit: int | None = None,
|
|
55
|
+
cursor: str | None = None,
|
|
56
|
+
) -> SubscriptionPage:
|
|
44
57
|
"""One page of the project's subscriptions in this key's environment, newest first.
|
|
45
58
|
|
|
46
59
|
``data`` holds the rows and ``next_cursor`` the mark for the page after this one (``None``
|
|
47
60
|
on the last). Pass it back as ``cursor`` unchanged — it is opaque, and nothing but this API
|
|
48
|
-
reads it
|
|
61
|
+
reads it — with the same filters, which apply before the page is cut. ``customer_id``
|
|
62
|
+
narrows the list to one customer, matched exactly; ``q`` to customers whose id contains it,
|
|
63
|
+
ignoring case (``%`` and ``_`` are literal); ``status`` to subscriptions in one of those statuses.
|
|
49
64
|
"""
|
|
50
|
-
result: SubscriptionPage = self._client.request(list_spec(customer_id, limit, cursor))
|
|
65
|
+
result: SubscriptionPage = self._client.request(list_spec(customer_id, q, status, limit, cursor))
|
|
51
66
|
return result
|
|
52
67
|
|
|
53
68
|
def get(self, subscription_id: str) -> SubscriptionResponse:
|
|
@@ -73,14 +88,24 @@ class AsyncSubscriptionsResource:
|
|
|
73
88
|
result: SubscriptionResponse = await self._client.request(create_spec(body, idempotency_key))
|
|
74
89
|
return result
|
|
75
90
|
|
|
76
|
-
async def list(
|
|
91
|
+
async def list(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
customer_id: str | None = None,
|
|
95
|
+
q: str | None = None,
|
|
96
|
+
status: Sequence[SubscriptionStatus] | None = None,
|
|
97
|
+
limit: int | None = None,
|
|
98
|
+
cursor: str | None = None,
|
|
99
|
+
) -> SubscriptionPage:
|
|
77
100
|
"""One page of the project's subscriptions in this key's environment, newest first.
|
|
78
101
|
|
|
79
102
|
``data`` holds the rows and ``next_cursor`` the mark for the page after this one (``None``
|
|
80
103
|
on the last). Pass it back as ``cursor`` unchanged — it is opaque, and nothing but this API
|
|
81
|
-
reads it
|
|
104
|
+
reads it — with the same filters, which apply before the page is cut. ``customer_id``
|
|
105
|
+
narrows the list to one customer, matched exactly; ``q`` to customers whose id contains it,
|
|
106
|
+
ignoring case (``%`` and ``_`` are literal); ``status`` to subscriptions in one of those statuses.
|
|
82
107
|
"""
|
|
83
|
-
result: SubscriptionPage = await self._client.request(list_spec(customer_id, limit, cursor))
|
|
108
|
+
result: SubscriptionPage = await self._client.request(list_spec(customer_id, q, status, limit, cursor))
|
|
84
109
|
return result
|
|
85
110
|
|
|
86
111
|
async def get(self, subscription_id: str) -> SubscriptionResponse:
|
|
@@ -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:
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
from typing import TYPE_CHECKING
|
|
14
14
|
|
|
15
15
|
from .._http import RequestSpec, encode_path_segment
|
|
16
|
-
from ..types import WebhookDeliveryResponse, WebhookResponse
|
|
16
|
+
from ..types import WebhookDeliveryPage, WebhookDeliveryResponse, WebhookResponse
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
19
19
|
from ..client import AsyncMeterFlow, MeterFlow
|
|
@@ -35,6 +35,16 @@ def deliveries_spec(webhook_id: str) -> RequestSpec:
|
|
|
35
35
|
return RequestSpec("GET", f"webhooks/{encode_path_segment(webhook_id)}/deliveries")
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def delivery_page_spec(webhook_id: str, limit: int | None, cursor: str | None) -> RequestSpec:
|
|
39
|
+
"""The request for one page of an endpoint's delivery attempts.
|
|
40
|
+
|
|
41
|
+
webhook_id: the endpoint whose receipts are read.
|
|
42
|
+
limit: rows per page, 1–200; None for the API's default.
|
|
43
|
+
cursor: ``next_cursor`` of the page before; None for the first page.
|
|
44
|
+
"""
|
|
45
|
+
return RequestSpec("GET", f"webhooks/{encode_path_segment(webhook_id)}/deliveries/page", query={"limit": limit, "cursor": cursor})
|
|
46
|
+
|
|
47
|
+
|
|
38
48
|
class WebhooksResource:
|
|
39
49
|
def __init__(self, client: MeterFlow) -> None:
|
|
40
50
|
self._client = client
|
|
@@ -54,6 +64,20 @@ class WebhooksResource:
|
|
|
54
64
|
result: DeliveryList = self._client.request(deliveries_spec(webhook_id))
|
|
55
65
|
return result
|
|
56
66
|
|
|
67
|
+
def delivery_page(self, webhook_id: str, *, limit: int | None = None, cursor: str | None = None) -> WebhookDeliveryPage:
|
|
68
|
+
"""One page of an endpoint's delivery attempts, newest first, with no cap on how far back it reads.
|
|
69
|
+
|
|
70
|
+
The same rows as ``deliveries()``, in the environment of the key. ``data`` holds the rows and
|
|
71
|
+
``next_cursor`` the mark for the page after this one (``None`` on the last); pass it back as
|
|
72
|
+
``cursor`` unchanged.
|
|
73
|
+
|
|
74
|
+
webhook_id: the endpoint whose receipts are read.
|
|
75
|
+
limit: rows per page, 1–200; None for the API's default (50).
|
|
76
|
+
cursor: ``next_cursor`` of the page before; None for the first page.
|
|
77
|
+
"""
|
|
78
|
+
result: WebhookDeliveryPage = self._client.request(delivery_page_spec(webhook_id, limit, cursor))
|
|
79
|
+
return result
|
|
80
|
+
|
|
57
81
|
|
|
58
82
|
class AsyncWebhooksResource:
|
|
59
83
|
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
@@ -73,3 +97,17 @@ class AsyncWebhooksResource:
|
|
|
73
97
|
"""
|
|
74
98
|
result: DeliveryList = await self._client.request(deliveries_spec(webhook_id))
|
|
75
99
|
return result
|
|
100
|
+
|
|
101
|
+
async def delivery_page(self, webhook_id: str, *, limit: int | None = None, cursor: str | None = None) -> WebhookDeliveryPage:
|
|
102
|
+
"""One page of an endpoint's delivery attempts, newest first, with no cap on how far back it reads.
|
|
103
|
+
|
|
104
|
+
The same rows as ``deliveries()``, in the environment of the key. ``data`` holds the rows and
|
|
105
|
+
``next_cursor`` the mark for the page after this one (``None`` on the last); pass it back as
|
|
106
|
+
``cursor`` unchanged.
|
|
107
|
+
|
|
108
|
+
webhook_id: the endpoint whose receipts are read.
|
|
109
|
+
limit: rows per page, 1–200; None for the API's default (50).
|
|
110
|
+
cursor: ``next_cursor`` of the page before; None for the first page.
|
|
111
|
+
"""
|
|
112
|
+
result: WebhookDeliveryPage = await self._client.request(delivery_page_spec(webhook_id, limit, cursor))
|
|
113
|
+
return result
|
|
@@ -31,6 +31,7 @@ class CreditBalanceResponse(TypedDict):
|
|
|
31
31
|
version: int
|
|
32
32
|
created_at: str
|
|
33
33
|
updated_at: str
|
|
34
|
+
usage_draws_credits: bool | None
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
class _CreditDeductRequestRequired(TypedDict):
|
|
@@ -119,6 +120,8 @@ class PlanMeterLimitResponse(TypedDict):
|
|
|
119
120
|
|
|
120
121
|
|
|
121
122
|
class PlanResponse(TypedDict):
|
|
123
|
+
created_by: str | None
|
|
124
|
+
created_by_email: str | None
|
|
122
125
|
id: str
|
|
123
126
|
project_id: str
|
|
124
127
|
name: str
|
|
@@ -261,6 +264,7 @@ class _UsageSummaryItemRequired(TypedDict):
|
|
|
261
264
|
meter_name: str
|
|
262
265
|
event_name: str
|
|
263
266
|
aggregation_type: str
|
|
267
|
+
aggregation_field: str | None
|
|
264
268
|
value: str
|
|
265
269
|
event_count: int
|
|
266
270
|
|
|
@@ -278,6 +282,16 @@ class UsageSummaryResponse(TypedDict):
|
|
|
278
282
|
meters: list[UsageSummaryItem]
|
|
279
283
|
|
|
280
284
|
|
|
285
|
+
class _WebhookDeliveryPageRequired(TypedDict):
|
|
286
|
+
data: list[WebhookDeliveryResponse]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class WebhookDeliveryPage(_WebhookDeliveryPageRequired, total=False):
|
|
290
|
+
"""One page of an endpoint's delivery attempts, newest first; pass `next_cursor` back as `cursor` for the next."""
|
|
291
|
+
|
|
292
|
+
next_cursor: str | None
|
|
293
|
+
|
|
294
|
+
|
|
281
295
|
class WebhookDeliveryResponse(TypedDict):
|
|
282
296
|
id: str
|
|
283
297
|
webhook_id: str
|
|
@@ -287,7 +301,6 @@ class WebhookDeliveryResponse(TypedDict):
|
|
|
287
301
|
response_code: int | None
|
|
288
302
|
response_body: str | None
|
|
289
303
|
attempts: int
|
|
290
|
-
next_retry_at: str | None
|
|
291
304
|
created_at: str
|
|
292
305
|
completed_at: str | None
|
|
293
306
|
|
|
@@ -295,6 +308,8 @@ class WebhookDeliveryResponse(TypedDict):
|
|
|
295
308
|
class WebhookResponse(TypedDict):
|
|
296
309
|
"""Returned on all reads — secret is never included."""
|
|
297
310
|
|
|
311
|
+
created_by: str | None
|
|
312
|
+
created_by_email: str | None
|
|
298
313
|
id: str
|
|
299
314
|
project_id: str
|
|
300
315
|
url: str
|
|
@@ -332,6 +347,7 @@ __all__ = [
|
|
|
332
347
|
"UsageEventResponse",
|
|
333
348
|
"UsageSummaryItem",
|
|
334
349
|
"UsageSummaryResponse",
|
|
350
|
+
"WebhookDeliveryPage",
|
|
335
351
|
"WebhookDeliveryResponse",
|
|
336
352
|
"WebhookResponse",
|
|
337
353
|
]
|
|
@@ -1,60 +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. Empty ``entitlements`` without a subscription."""
|
|
39
|
-
result: EntitlementsResponse = self._client.request(get_spec(customer_id))
|
|
40
|
-
return result
|
|
41
|
-
|
|
42
|
-
def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
|
|
43
|
-
"""May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
|
|
44
|
-
result: EntitlementResponse = self._client.request(check_spec(customer_id, feature, quantity))
|
|
45
|
-
return result
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class AsyncEntitlementsResource:
|
|
49
|
-
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
50
|
-
self._client = client
|
|
51
|
-
|
|
52
|
-
async def get(self, customer_id: str) -> EntitlementsResponse:
|
|
53
|
-
"""Every feature on the customer's plan, with this period's usage. Empty ``entitlements`` without a subscription."""
|
|
54
|
-
result: EntitlementsResponse = await self._client.request(get_spec(customer_id))
|
|
55
|
-
return result
|
|
56
|
-
|
|
57
|
-
async def check(self, customer_id: str, feature: str, *, quantity: int | None = None) -> EntitlementResponse:
|
|
58
|
-
"""May this customer use ``feature`` (``quantity`` more units of it — "may they do 5 more?") right now?"""
|
|
59
|
-
result: EntitlementResponse = await self._client.request(check_spec(customer_id, feature, quantity))
|
|
60
|
-
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
|