meterbase 0.2.0__tar.gz → 0.4.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.
- {meterbase-0.2.0 → meterbase-0.4.0}/PKG-INFO +118 -10
- {meterbase-0.2.0 → meterbase-0.4.0}/README.md +117 -9
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/__init__.py +2 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/customer_allowances.py +37 -10
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/customer_plan.py +59 -15
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/customers.py +68 -10
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/meters.py +11 -5
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/plans.py +22 -13
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_http.py +27 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_models.py +12 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/customer_allowances.py +37 -10
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/customer_plan.py +59 -15
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/customers.py +68 -10
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/meters.py +11 -5
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/plans.py +22 -13
- meterbase-0.4.0/src/meterbase/_version.py +1 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/errors.py +12 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/types.py +30 -1
- meterbase-0.2.0/src/meterbase/_version.py +0 -1
- {meterbase-0.2.0 → meterbase-0.4.0}/.gitignore +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/LICENSE +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/pyproject.toml +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/__init__.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/_io.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/_transport.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/client.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/__init__.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_json.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_keys.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_not_given.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/__init__.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/_io.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/_transport.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/client.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/__init__.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_time.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/py.typed +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/resources.py +0 -0
- {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: meterbase
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python SDK for the Meterbase engine
|
|
5
5
|
Project-URL: Homepage, https://meterbase.tech
|
|
6
6
|
Project-URL: Documentation, https://meterbase.tech/docs
|
|
@@ -278,7 +278,7 @@ id and gives them a first plan, and running it again changes nothing:
|
|
|
278
278
|
ensured = meterbase.customers.ensure(
|
|
279
279
|
external_id="acct_1",
|
|
280
280
|
name="Acme", # applied only when it creates the customer
|
|
281
|
-
plan="free", # a plan key
|
|
281
|
+
plan="free", # a plan's key or id
|
|
282
282
|
)
|
|
283
283
|
ensured.created, ensured.plan_result, ensured.customer.plan
|
|
284
284
|
```
|
|
@@ -303,7 +303,6 @@ customer = meterbase.customers.create(
|
|
|
303
303
|
)
|
|
304
304
|
|
|
305
305
|
found = meterbase.customers.retrieve_by_external_id("acct_1") # or None
|
|
306
|
-
everyone = meterbase.customers.list(include_deleted=True).data
|
|
307
306
|
|
|
308
307
|
meterbase.customers.update(customer.id, name="Acme Inc")
|
|
309
308
|
meterbase.customers.delete(customer.id) # soft delete, idempotent
|
|
@@ -328,6 +327,27 @@ embed is which plan, and `customers.plan.retrieve` is where instructions are
|
|
|
328
327
|
identified. `None` means they hold no plan, which is a default deny — a
|
|
329
328
|
customer with no plan and no grant is denied.
|
|
330
329
|
|
|
330
|
+
**Lists come a page at a time.** `customers.list` answers one page, newest
|
|
331
|
+
first: `limit` customers, 100 unless you set it and at most 1,000. `list_all`
|
|
332
|
+
follows the pages for you, fetching each one as the loop reaches it:
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
page = meterbase.customers.list(plan="pro", limit=1000)
|
|
336
|
+
page.data # the newest 1,000 customers on pro
|
|
337
|
+
page.has_more # True when another page follows the last of them
|
|
338
|
+
|
|
339
|
+
for customer in meterbase.customers.list_all(has_plan=False):
|
|
340
|
+
... # every customer holding no plan
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
On the async client it is `async for customer in meterbase.customers.list_all()`.
|
|
344
|
+
`plan`, a key or an id, and `has_plan` filter on the plan in force now; a
|
|
345
|
+
pending change counts once it lands. `include_deleted=True` adds deleted
|
|
346
|
+
customers. To page by hand, pass the last `id` as `starting_after` while
|
|
347
|
+
`has_more` is `True`. A walk reads no customer twice, and one created while it
|
|
348
|
+
runs is not in it. Both raise before sending when `plan` or `starting_after` is
|
|
349
|
+
`None`, which would otherwise widen the list.
|
|
350
|
+
|
|
331
351
|
`customers.list` stays lean and does not embed, along with `create`, `update`
|
|
332
352
|
and `delete`; the same read would run per row for an answer most callers of a
|
|
333
353
|
list do not want.
|
|
@@ -363,9 +383,17 @@ Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
|
|
|
363
383
|
```python
|
|
364
384
|
meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
|
|
365
385
|
meters = meterbase.meters.list().data
|
|
386
|
+
meterbase.meters.retrieve("api_calls") # by key, or by id
|
|
366
387
|
meterbase.meters.archive(meter_id) # soft delete, idempotent
|
|
367
388
|
```
|
|
368
389
|
|
|
390
|
+
A meter's key works wherever its id does: `retrieve`, `update`, `usage` and
|
|
391
|
+
`archive`, `meter_id` on a grant or a plan allowance, and the `meter_id` filter
|
|
392
|
+
on their lists. Code written against a sandbox then runs unchanged in live,
|
|
393
|
+
where the ids differ. A key that names no meter raises `NotFoundError`. A
|
|
394
|
+
canonical UUID is always read as an id, so creating or renaming a meter to a
|
|
395
|
+
key shaped like one raises `InvalidRequestError`.
|
|
396
|
+
|
|
369
397
|
### Plans
|
|
370
398
|
|
|
371
399
|
A plan has one billing period, fixed when the plan is created. Everything it
|
|
@@ -387,6 +415,11 @@ meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
|
|
|
387
415
|
customers were already measured against, so a different cadence is a different
|
|
388
416
|
plan — move a customer with `customers.plan.assign`.
|
|
389
417
|
|
|
418
|
+
A plan's key works wherever its id does, on the same terms as a meter's:
|
|
419
|
+
`plans.retrieve("pro")`, `plans.allowances.set("pro", ...)`, `plan_id` on
|
|
420
|
+
`customers.plan.assign` and `plan` on each change. A key that names no plan
|
|
421
|
+
raises `NotFoundError`, and a key shaped like a UUID is refused.
|
|
422
|
+
|
|
390
423
|
### Plan allowances
|
|
391
424
|
|
|
392
425
|
What a plan grants, one meter at a time. An allowance says _how much_, never
|
|
@@ -416,6 +449,12 @@ versions = meterbase.plans.allowances.list(plan.id).data
|
|
|
416
449
|
Each `(plan, meter)` pair carries up to two lineages, `recurring` and
|
|
417
450
|
`one_time`, numbering themselves separately. `kind` defaults to `recurring`.
|
|
418
451
|
|
|
452
|
+
`apply_mode` says when a version reaches customers already on the plan:
|
|
453
|
+
`immediate`, for the whole period they are in, or `next_cycle`, at each one's
|
|
454
|
+
next boundary. Omitted, a meter's first `recurring` version on the plan is
|
|
455
|
+
`immediate`, since there is nothing to phase out, and every later one is
|
|
456
|
+
`next_cycle`. `one_time` is always `immediate`.
|
|
457
|
+
|
|
419
458
|
**A soft cap** keeps the amount and drops the refusal: included usage, then
|
|
420
459
|
overage.
|
|
421
460
|
|
|
@@ -521,6 +560,26 @@ except CycleChangeRequiresResetError:
|
|
|
521
560
|
meterbase.customers.plan.restart(customer.id, plan=weekly.id)
|
|
522
561
|
```
|
|
523
562
|
|
|
563
|
+
#### From a webhook
|
|
564
|
+
|
|
565
|
+
A provider may deliver a webhook twice, and a second `restart` would start a
|
|
566
|
+
second fresh period. Pass the event's id as `idempotency_key`, 1–255
|
|
567
|
+
characters, and a retry repeats nothing:
|
|
568
|
+
|
|
569
|
+
```python
|
|
570
|
+
moved = meterbase.customers.plan.restart(
|
|
571
|
+
customer.id, plan="pro", idempotency_key=event.id
|
|
572
|
+
)
|
|
573
|
+
moved.replayed # True when this event had already moved them: nothing was written
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
`assign`, `change`, `change_now` and `change_at_next_cycle` take one too. The
|
|
577
|
+
same request under the key again answers with the assignment it made, as it is
|
|
578
|
+
now, and writes nothing — so a second delivery of an old event cannot cancel a
|
|
579
|
+
change scheduled since. Another request under the key raises
|
|
580
|
+
`IdempotencyKeyReusedError`. A key is kept for good, per workspace. With a key,
|
|
581
|
+
the SDK retries the call too.
|
|
582
|
+
|
|
524
583
|
#### Reading where they stand
|
|
525
584
|
|
|
526
585
|
```python
|
|
@@ -552,6 +611,11 @@ instruction, and writes no new row of its own — so this reads the plan in forc
|
|
|
552
611
|
and names it back. It returns the assignment still in force, or `None` for a
|
|
553
612
|
customer holding no plan, who can have nothing pending.
|
|
554
613
|
|
|
614
|
+
It takes no `idempotency_key`: it names whatever plan is in force when it runs,
|
|
615
|
+
so a retry could name a different one and be refused as a different request.
|
|
616
|
+
For a cancel a webhook orders, `assign` the plan the event says they keep, under
|
|
617
|
+
the event's id.
|
|
618
|
+
|
|
555
619
|
#### How much they have used
|
|
556
620
|
|
|
557
621
|
```python
|
|
@@ -655,6 +719,37 @@ Revoking withdraws what is left without erasing what was consumed, and is
|
|
|
655
719
|
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
656
720
|
appear here with `source="plan"`; they cannot be created through `grant`.
|
|
657
721
|
|
|
722
|
+
**A grant made from a payment's webhook** takes the payment's id as its
|
|
723
|
+
`idempotency_key`, 1–255 characters: the provider may deliver the webhook
|
|
724
|
+
twice, and a retry under the key cannot grant twice.
|
|
725
|
+
|
|
726
|
+
```python
|
|
727
|
+
grant = meterbase.customers.allowances.grant(
|
|
728
|
+
customer.id,
|
|
729
|
+
meter_id="ai_tokens",
|
|
730
|
+
amount=500_000,
|
|
731
|
+
source="purchased",
|
|
732
|
+
idempotency_key=payment.id,
|
|
733
|
+
)
|
|
734
|
+
grant.replayed # True when this payment had already granted: nothing was written
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
The same request under the key again answers with that grant as it is now,
|
|
738
|
+
perhaps spent or revoked since, and writes nothing. Another request under the
|
|
739
|
+
key raises `IdempotencyKeyReusedError`, so build the body the same way every
|
|
740
|
+
time: compute `expires_at` from the payment's own time, not from when the
|
|
741
|
+
handler ran. A key is kept for good, per workspace. With a key, the SDK retries
|
|
742
|
+
the call too.
|
|
743
|
+
|
|
744
|
+
To find what a payment granted — to take it back on a refund, say — list by its
|
|
745
|
+
key. Once the grant is revoked or has expired, add `include_closed=True`.
|
|
746
|
+
|
|
747
|
+
```python
|
|
748
|
+
paid = meterbase.customers.allowances.list(customer.id, idempotency_key=payment.id)
|
|
749
|
+
for grant in paid.data: # the grant the payment made, or none
|
|
750
|
+
meterbase.customers.allowances.revoke(customer.id, grant.id)
|
|
751
|
+
```
|
|
752
|
+
|
|
658
753
|
## Webhooks
|
|
659
754
|
|
|
660
755
|
Meterbase posts to your endpoints when a customer crosses a usage threshold,
|
|
@@ -825,6 +920,7 @@ except RateLimitError as error:
|
|
|
825
920
|
| `NotFoundError` | 404 |
|
|
826
921
|
| `ConflictError` | 409 — a key or external id is taken |
|
|
827
922
|
| `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
|
|
923
|
+
| `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant or plan change was already used for a different request. Nothing was written |
|
|
828
924
|
| `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
|
|
829
925
|
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
830
926
|
| `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
|
|
@@ -881,13 +977,15 @@ an attempt already on the wire may still land, but it is never replayed, so a
|
|
|
881
977
|
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
882
978
|
bound it with `timeout` and `max_retries` instead.
|
|
883
979
|
|
|
884
|
-
**Retries** apply to `GET`, `DELETE`, `track`, `reserve`
|
|
885
|
-
`
|
|
886
|
-
exponential backoff and full jitter.
|
|
887
|
-
sends it, up to eight seconds. Every
|
|
888
|
-
replayed, because a retried create could
|
|
889
|
-
be, because running it twice changes
|
|
890
|
-
answers `created` false.
|
|
980
|
+
**Retries** apply to `GET`, `DELETE`, `track`, `reserve`, `customers.ensure`,
|
|
981
|
+
and a grant or plan change given an `idempotency_key`, on connection failures,
|
|
982
|
+
timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
|
|
983
|
+
`Retry-After` is honoured when the engine sends it, up to eight seconds. Every
|
|
984
|
+
other `POST` and `PATCH` is never replayed, because a retried create could
|
|
985
|
+
produce a second row. An `ensure` can be, because running it twice changes
|
|
986
|
+
nothing: one whose first attempt landed answers `created` false. So can a keyed
|
|
987
|
+
grant or plan change, which the engine answers with what the first attempt
|
|
988
|
+
wrote: one whose first attempt landed answers `replayed` true.
|
|
891
989
|
|
|
892
990
|
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
893
991
|
and because the SDK fixes that key **before** the retry loop starts: every
|
|
@@ -916,6 +1014,16 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
|
916
1014
|
change that will not run against your code cannot arrive as a patch. The SDK
|
|
917
1015
|
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
918
1016
|
|
|
1017
|
+
**0.4.0** pages the customer list, and adds `customers.list_all` and the
|
|
1018
|
+
`limit`, `starting_after`, `plan` and `has_plan` parameters. One change can break
|
|
1019
|
+
code: `customers.list()` answers one page of 100, newest first, where it used to
|
|
1020
|
+
answer every customer. Iterate `list_all` to read them all. The engine pages the
|
|
1021
|
+
same way, so 0.3.0 and earlier now read only the newest 100.
|
|
1022
|
+
|
|
1023
|
+
**0.3.0** adds an `idempotency_key` on grants and plan changes, which also makes
|
|
1024
|
+
them safe to retry, `replayed` on their answers, and `IdempotencyKeyReusedError`.
|
|
1025
|
+
Nothing in it breaks code written for 0.2.0.
|
|
1026
|
+
|
|
919
1027
|
**0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
|
|
920
1028
|
behaviours:
|
|
921
1029
|
|
|
@@ -251,7 +251,7 @@ id and gives them a first plan, and running it again changes nothing:
|
|
|
251
251
|
ensured = meterbase.customers.ensure(
|
|
252
252
|
external_id="acct_1",
|
|
253
253
|
name="Acme", # applied only when it creates the customer
|
|
254
|
-
plan="free", # a plan key
|
|
254
|
+
plan="free", # a plan's key or id
|
|
255
255
|
)
|
|
256
256
|
ensured.created, ensured.plan_result, ensured.customer.plan
|
|
257
257
|
```
|
|
@@ -276,7 +276,6 @@ customer = meterbase.customers.create(
|
|
|
276
276
|
)
|
|
277
277
|
|
|
278
278
|
found = meterbase.customers.retrieve_by_external_id("acct_1") # or None
|
|
279
|
-
everyone = meterbase.customers.list(include_deleted=True).data
|
|
280
279
|
|
|
281
280
|
meterbase.customers.update(customer.id, name="Acme Inc")
|
|
282
281
|
meterbase.customers.delete(customer.id) # soft delete, idempotent
|
|
@@ -301,6 +300,27 @@ embed is which plan, and `customers.plan.retrieve` is where instructions are
|
|
|
301
300
|
identified. `None` means they hold no plan, which is a default deny — a
|
|
302
301
|
customer with no plan and no grant is denied.
|
|
303
302
|
|
|
303
|
+
**Lists come a page at a time.** `customers.list` answers one page, newest
|
|
304
|
+
first: `limit` customers, 100 unless you set it and at most 1,000. `list_all`
|
|
305
|
+
follows the pages for you, fetching each one as the loop reaches it:
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
page = meterbase.customers.list(plan="pro", limit=1000)
|
|
309
|
+
page.data # the newest 1,000 customers on pro
|
|
310
|
+
page.has_more # True when another page follows the last of them
|
|
311
|
+
|
|
312
|
+
for customer in meterbase.customers.list_all(has_plan=False):
|
|
313
|
+
... # every customer holding no plan
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
On the async client it is `async for customer in meterbase.customers.list_all()`.
|
|
317
|
+
`plan`, a key or an id, and `has_plan` filter on the plan in force now; a
|
|
318
|
+
pending change counts once it lands. `include_deleted=True` adds deleted
|
|
319
|
+
customers. To page by hand, pass the last `id` as `starting_after` while
|
|
320
|
+
`has_more` is `True`. A walk reads no customer twice, and one created while it
|
|
321
|
+
runs is not in it. Both raise before sending when `plan` or `starting_after` is
|
|
322
|
+
`None`, which would otherwise widen the list.
|
|
323
|
+
|
|
304
324
|
`customers.list` stays lean and does not embed, along with `create`, `update`
|
|
305
325
|
and `delete`; the same read would run per row for an answer most callers of a
|
|
306
326
|
list do not want.
|
|
@@ -336,9 +356,17 @@ Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
|
|
|
336
356
|
```python
|
|
337
357
|
meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
|
|
338
358
|
meters = meterbase.meters.list().data
|
|
359
|
+
meterbase.meters.retrieve("api_calls") # by key, or by id
|
|
339
360
|
meterbase.meters.archive(meter_id) # soft delete, idempotent
|
|
340
361
|
```
|
|
341
362
|
|
|
363
|
+
A meter's key works wherever its id does: `retrieve`, `update`, `usage` and
|
|
364
|
+
`archive`, `meter_id` on a grant or a plan allowance, and the `meter_id` filter
|
|
365
|
+
on their lists. Code written against a sandbox then runs unchanged in live,
|
|
366
|
+
where the ids differ. A key that names no meter raises `NotFoundError`. A
|
|
367
|
+
canonical UUID is always read as an id, so creating or renaming a meter to a
|
|
368
|
+
key shaped like one raises `InvalidRequestError`.
|
|
369
|
+
|
|
342
370
|
### Plans
|
|
343
371
|
|
|
344
372
|
A plan has one billing period, fixed when the plan is created. Everything it
|
|
@@ -360,6 +388,11 @@ meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
|
|
|
360
388
|
customers were already measured against, so a different cadence is a different
|
|
361
389
|
plan — move a customer with `customers.plan.assign`.
|
|
362
390
|
|
|
391
|
+
A plan's key works wherever its id does, on the same terms as a meter's:
|
|
392
|
+
`plans.retrieve("pro")`, `plans.allowances.set("pro", ...)`, `plan_id` on
|
|
393
|
+
`customers.plan.assign` and `plan` on each change. A key that names no plan
|
|
394
|
+
raises `NotFoundError`, and a key shaped like a UUID is refused.
|
|
395
|
+
|
|
363
396
|
### Plan allowances
|
|
364
397
|
|
|
365
398
|
What a plan grants, one meter at a time. An allowance says _how much_, never
|
|
@@ -389,6 +422,12 @@ versions = meterbase.plans.allowances.list(plan.id).data
|
|
|
389
422
|
Each `(plan, meter)` pair carries up to two lineages, `recurring` and
|
|
390
423
|
`one_time`, numbering themselves separately. `kind` defaults to `recurring`.
|
|
391
424
|
|
|
425
|
+
`apply_mode` says when a version reaches customers already on the plan:
|
|
426
|
+
`immediate`, for the whole period they are in, or `next_cycle`, at each one's
|
|
427
|
+
next boundary. Omitted, a meter's first `recurring` version on the plan is
|
|
428
|
+
`immediate`, since there is nothing to phase out, and every later one is
|
|
429
|
+
`next_cycle`. `one_time` is always `immediate`.
|
|
430
|
+
|
|
392
431
|
**A soft cap** keeps the amount and drops the refusal: included usage, then
|
|
393
432
|
overage.
|
|
394
433
|
|
|
@@ -494,6 +533,26 @@ except CycleChangeRequiresResetError:
|
|
|
494
533
|
meterbase.customers.plan.restart(customer.id, plan=weekly.id)
|
|
495
534
|
```
|
|
496
535
|
|
|
536
|
+
#### From a webhook
|
|
537
|
+
|
|
538
|
+
A provider may deliver a webhook twice, and a second `restart` would start a
|
|
539
|
+
second fresh period. Pass the event's id as `idempotency_key`, 1–255
|
|
540
|
+
characters, and a retry repeats nothing:
|
|
541
|
+
|
|
542
|
+
```python
|
|
543
|
+
moved = meterbase.customers.plan.restart(
|
|
544
|
+
customer.id, plan="pro", idempotency_key=event.id
|
|
545
|
+
)
|
|
546
|
+
moved.replayed # True when this event had already moved them: nothing was written
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
`assign`, `change`, `change_now` and `change_at_next_cycle` take one too. The
|
|
550
|
+
same request under the key again answers with the assignment it made, as it is
|
|
551
|
+
now, and writes nothing — so a second delivery of an old event cannot cancel a
|
|
552
|
+
change scheduled since. Another request under the key raises
|
|
553
|
+
`IdempotencyKeyReusedError`. A key is kept for good, per workspace. With a key,
|
|
554
|
+
the SDK retries the call too.
|
|
555
|
+
|
|
497
556
|
#### Reading where they stand
|
|
498
557
|
|
|
499
558
|
```python
|
|
@@ -525,6 +584,11 @@ instruction, and writes no new row of its own — so this reads the plan in forc
|
|
|
525
584
|
and names it back. It returns the assignment still in force, or `None` for a
|
|
526
585
|
customer holding no plan, who can have nothing pending.
|
|
527
586
|
|
|
587
|
+
It takes no `idempotency_key`: it names whatever plan is in force when it runs,
|
|
588
|
+
so a retry could name a different one and be refused as a different request.
|
|
589
|
+
For a cancel a webhook orders, `assign` the plan the event says they keep, under
|
|
590
|
+
the event's id.
|
|
591
|
+
|
|
528
592
|
#### How much they have used
|
|
529
593
|
|
|
530
594
|
```python
|
|
@@ -628,6 +692,37 @@ Revoking withdraws what is left without erasing what was consumed, and is
|
|
|
628
692
|
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
629
693
|
appear here with `source="plan"`; they cannot be created through `grant`.
|
|
630
694
|
|
|
695
|
+
**A grant made from a payment's webhook** takes the payment's id as its
|
|
696
|
+
`idempotency_key`, 1–255 characters: the provider may deliver the webhook
|
|
697
|
+
twice, and a retry under the key cannot grant twice.
|
|
698
|
+
|
|
699
|
+
```python
|
|
700
|
+
grant = meterbase.customers.allowances.grant(
|
|
701
|
+
customer.id,
|
|
702
|
+
meter_id="ai_tokens",
|
|
703
|
+
amount=500_000,
|
|
704
|
+
source="purchased",
|
|
705
|
+
idempotency_key=payment.id,
|
|
706
|
+
)
|
|
707
|
+
grant.replayed # True when this payment had already granted: nothing was written
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
The same request under the key again answers with that grant as it is now,
|
|
711
|
+
perhaps spent or revoked since, and writes nothing. Another request under the
|
|
712
|
+
key raises `IdempotencyKeyReusedError`, so build the body the same way every
|
|
713
|
+
time: compute `expires_at` from the payment's own time, not from when the
|
|
714
|
+
handler ran. A key is kept for good, per workspace. With a key, the SDK retries
|
|
715
|
+
the call too.
|
|
716
|
+
|
|
717
|
+
To find what a payment granted — to take it back on a refund, say — list by its
|
|
718
|
+
key. Once the grant is revoked or has expired, add `include_closed=True`.
|
|
719
|
+
|
|
720
|
+
```python
|
|
721
|
+
paid = meterbase.customers.allowances.list(customer.id, idempotency_key=payment.id)
|
|
722
|
+
for grant in paid.data: # the grant the payment made, or none
|
|
723
|
+
meterbase.customers.allowances.revoke(customer.id, grant.id)
|
|
724
|
+
```
|
|
725
|
+
|
|
631
726
|
## Webhooks
|
|
632
727
|
|
|
633
728
|
Meterbase posts to your endpoints when a customer crosses a usage threshold,
|
|
@@ -798,6 +893,7 @@ except RateLimitError as error:
|
|
|
798
893
|
| `NotFoundError` | 404 |
|
|
799
894
|
| `ConflictError` | 409 — a key or external id is taken |
|
|
800
895
|
| `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
|
|
896
|
+
| `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant or plan change was already used for a different request. Nothing was written |
|
|
801
897
|
| `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
|
|
802
898
|
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
803
899
|
| `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
|
|
@@ -854,13 +950,15 @@ an attempt already on the wire may still land, but it is never replayed, so a
|
|
|
854
950
|
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
855
951
|
bound it with `timeout` and `max_retries` instead.
|
|
856
952
|
|
|
857
|
-
**Retries** apply to `GET`, `DELETE`, `track`, `reserve`
|
|
858
|
-
`
|
|
859
|
-
exponential backoff and full jitter.
|
|
860
|
-
sends it, up to eight seconds. Every
|
|
861
|
-
replayed, because a retried create could
|
|
862
|
-
be, because running it twice changes
|
|
863
|
-
answers `created` false.
|
|
953
|
+
**Retries** apply to `GET`, `DELETE`, `track`, `reserve`, `customers.ensure`,
|
|
954
|
+
and a grant or plan change given an `idempotency_key`, on connection failures,
|
|
955
|
+
timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
|
|
956
|
+
`Retry-After` is honoured when the engine sends it, up to eight seconds. Every
|
|
957
|
+
other `POST` and `PATCH` is never replayed, because a retried create could
|
|
958
|
+
produce a second row. An `ensure` can be, because running it twice changes
|
|
959
|
+
nothing: one whose first attempt landed answers `created` false. So can a keyed
|
|
960
|
+
grant or plan change, which the engine answers with what the first attempt
|
|
961
|
+
wrote: one whose first attempt landed answers `replayed` true.
|
|
864
962
|
|
|
865
963
|
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
866
964
|
and because the SDK fixes that key **before** the retry loop starts: every
|
|
@@ -889,6 +987,16 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
|
889
987
|
change that will not run against your code cannot arrive as a patch. The SDK
|
|
890
988
|
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
891
989
|
|
|
990
|
+
**0.4.0** pages the customer list, and adds `customers.list_all` and the
|
|
991
|
+
`limit`, `starting_after`, `plan` and `has_plan` parameters. One change can break
|
|
992
|
+
code: `customers.list()` answers one page of 100, newest first, where it used to
|
|
993
|
+
answer every customer. Iterate `list_all` to read them all. The engine pages the
|
|
994
|
+
same way, so 0.3.0 and earlier now read only the newest 100.
|
|
995
|
+
|
|
996
|
+
**0.3.0** adds an `idempotency_key` on grants and plan changes, which also makes
|
|
997
|
+
them safe to retry, `replayed` on their answers, and `IdempotencyKeyReusedError`.
|
|
998
|
+
Nothing in it breaks code written for 0.2.0.
|
|
999
|
+
|
|
892
1000
|
**0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
|
|
893
1001
|
behaviours:
|
|
894
1002
|
|
|
@@ -12,6 +12,7 @@ from .errors import (
|
|
|
12
12
|
AuthenticationError,
|
|
13
13
|
ConflictError,
|
|
14
14
|
CycleChangeRequiresResetError,
|
|
15
|
+
IdempotencyKeyReusedError,
|
|
15
16
|
InvalidIdempotencyKeyError,
|
|
16
17
|
InvalidRequestError,
|
|
17
18
|
MeterbaseError,
|
|
@@ -38,6 +39,7 @@ __all__ = [
|
|
|
38
39
|
"ConflictError",
|
|
39
40
|
"CycleChangeRequiresResetError",
|
|
40
41
|
"Hold",
|
|
42
|
+
"IdempotencyKeyReusedError",
|
|
41
43
|
"InvalidIdempotencyKeyError",
|
|
42
44
|
"InvalidRequestError",
|
|
43
45
|
"Meterbase",
|
|
@@ -3,10 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
from collections.abc import Mapping
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
from ..._http import segment
|
|
6
|
+
from ..._http import require_key_filter, segment
|
|
7
7
|
from ..._models import ListResult, list_of
|
|
8
8
|
from ..._not_given import NOT_GIVEN, NotGiven
|
|
9
|
-
from ...types import Allowance, AllowanceSource
|
|
9
|
+
from ...types import Allowance, AllowanceGrantResult, AllowanceSource
|
|
10
10
|
from .._transport import AsyncRequester
|
|
11
11
|
|
|
12
12
|
|
|
@@ -31,20 +31,30 @@ class AsyncCustomerAllowances:
|
|
|
31
31
|
metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
|
|
32
32
|
effective_at: str | NotGiven = NOT_GIVEN,
|
|
33
33
|
expires_at: str | NotGiven = NOT_GIVEN,
|
|
34
|
-
|
|
34
|
+
idempotency_key: str | NotGiven = NOT_GIVEN,
|
|
35
|
+
) -> AllowanceGrantResult:
|
|
35
36
|
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
state, so `amount` is at least 1.
|
|
37
|
+
`meter_id` is the meter's id or its key. Give exactly one of `amount`
|
|
38
|
+
or `no_cap`. Unlike a plan allowance, a grant of 0 is refused: a grant
|
|
39
|
+
that gives nothing is a no-op, not a state, so `amount` is at least 1.
|
|
39
40
|
|
|
40
41
|
`source` is required: the engine will not guess why capacity was handed
|
|
41
42
|
out. `effective_at` defaults to now; dated ahead, the grant is inert
|
|
42
43
|
until then.
|
|
44
|
+
|
|
45
|
+
`idempotency_key`, 1–255 characters, makes a grant safe to retry. From
|
|
46
|
+
a webhook the provider may deliver twice, pass your own id for the
|
|
47
|
+
payment, and a retry cannot grant twice: the same request again
|
|
48
|
+
answers with that grant and `replayed=True`, and another one under the
|
|
49
|
+
key raises `IdempotencyKeyReusedError`. Build the body the same way on
|
|
50
|
+
every retry, with an `expires_at` computed from the payment's own time
|
|
51
|
+
rather than from when the handler ran. With a key, the SDK retries the
|
|
52
|
+
call too.
|
|
43
53
|
"""
|
|
44
54
|
return await self._api.request(
|
|
45
55
|
"POST",
|
|
46
56
|
f"/v1/customers/{segment(customer_id)}/allowances",
|
|
47
|
-
|
|
57
|
+
AllowanceGrantResult,
|
|
48
58
|
body={
|
|
49
59
|
"meter_id": meter_id,
|
|
50
60
|
"amount": amount,
|
|
@@ -53,7 +63,10 @@ class AsyncCustomerAllowances:
|
|
|
53
63
|
"metadata": metadata,
|
|
54
64
|
"effective_at": effective_at,
|
|
55
65
|
"expires_at": expires_at,
|
|
66
|
+
"idempotency_key": idempotency_key,
|
|
56
67
|
},
|
|
68
|
+
# None goes out as null, which the engine reads as no key at all.
|
|
69
|
+
idempotent=isinstance(idempotency_key, str),
|
|
57
70
|
)
|
|
58
71
|
|
|
59
72
|
async def list(
|
|
@@ -61,15 +74,29 @@ class AsyncCustomerAllowances:
|
|
|
61
74
|
customer_id: str,
|
|
62
75
|
*,
|
|
63
76
|
meter_id: str | NotGiven = NOT_GIVEN,
|
|
77
|
+
idempotency_key: str | NotGiven = NOT_GIVEN,
|
|
64
78
|
include_closed: bool | NotGiven = NOT_GIVEN,
|
|
65
79
|
) -> ListResult[Allowance]:
|
|
66
|
-
"""
|
|
67
|
-
|
|
80
|
+
"""
|
|
81
|
+
Every grant but revoked and expired ones, unless `include_closed`.
|
|
82
|
+
Exhausted grants and grants not yet open are listed either way.
|
|
83
|
+
`meter_id` is the meter's id or its key.
|
|
84
|
+
|
|
85
|
+
`idempotency_key` narrows it to the grant made under that key, or none:
|
|
86
|
+
how a refund finds what a payment granted. Once that grant is revoked
|
|
87
|
+
or has expired, only `include_closed` finds it. `None` raises before
|
|
88
|
+
sending, rather than listing every grant.
|
|
89
|
+
"""
|
|
90
|
+
require_key_filter(idempotency_key)
|
|
68
91
|
return await self._api.request(
|
|
69
92
|
"GET",
|
|
70
93
|
f"/v1/customers/{segment(customer_id)}/allowances",
|
|
71
94
|
list_of(Allowance),
|
|
72
|
-
query={
|
|
95
|
+
query={
|
|
96
|
+
"meter_id": meter_id,
|
|
97
|
+
"idempotency_key": idempotency_key,
|
|
98
|
+
"include_closed": include_closed,
|
|
99
|
+
},
|
|
73
100
|
)
|
|
74
101
|
|
|
75
102
|
async def retrieve(self, customer_id: str, allowance_id: str) -> Allowance:
|