meterbase 0.2.0__tar.gz → 0.3.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.3.0}/PKG-INFO +90 -8
- {meterbase-0.2.0 → meterbase-0.3.0}/README.md +89 -7
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/__init__.py +2 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_allowances.py +37 -10
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_plan.py +59 -15
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/meters.py +11 -5
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/plans.py +22 -13
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_http.py +12 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_allowances.py +37 -10
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_plan.py +59 -15
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/meters.py +11 -5
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/plans.py +22 -13
- meterbase-0.3.0/src/meterbase/_version.py +1 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/errors.py +12 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/types.py +28 -0
- meterbase-0.2.0/src/meterbase/_version.py +0 -1
- {meterbase-0.2.0 → meterbase-0.3.0}/.gitignore +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/LICENSE +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/pyproject.toml +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/__init__.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/_io.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/_transport.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/client.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/__init__.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/customers.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_json.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_keys.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_models.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_not_given.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/__init__.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/_io.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/_transport.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/client.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/__init__.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/customers.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_time.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/py.typed +0 -0
- {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/resources.py +0 -0
- {meterbase-0.2.0 → meterbase-0.3.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.3.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
|
|
@@ -363,9 +363,17 @@ Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
|
|
|
363
363
|
```python
|
|
364
364
|
meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
|
|
365
365
|
meters = meterbase.meters.list().data
|
|
366
|
+
meterbase.meters.retrieve("api_calls") # by key, or by id
|
|
366
367
|
meterbase.meters.archive(meter_id) # soft delete, idempotent
|
|
367
368
|
```
|
|
368
369
|
|
|
370
|
+
A meter's key works wherever its id does: `retrieve`, `update`, `usage` and
|
|
371
|
+
`archive`, `meter_id` on a grant or a plan allowance, and the `meter_id` filter
|
|
372
|
+
on their lists. Code written against a sandbox then runs unchanged in live,
|
|
373
|
+
where the ids differ. A key that names no meter raises `NotFoundError`. A
|
|
374
|
+
canonical UUID is always read as an id, so creating or renaming a meter to a
|
|
375
|
+
key shaped like one raises `InvalidRequestError`.
|
|
376
|
+
|
|
369
377
|
### Plans
|
|
370
378
|
|
|
371
379
|
A plan has one billing period, fixed when the plan is created. Everything it
|
|
@@ -387,6 +395,11 @@ meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
|
|
|
387
395
|
customers were already measured against, so a different cadence is a different
|
|
388
396
|
plan — move a customer with `customers.plan.assign`.
|
|
389
397
|
|
|
398
|
+
A plan's key works wherever its id does, on the same terms as a meter's:
|
|
399
|
+
`plans.retrieve("pro")`, `plans.allowances.set("pro", ...)`, `plan_id` on
|
|
400
|
+
`customers.plan.assign` and `plan` on each change. A key that names no plan
|
|
401
|
+
raises `NotFoundError`, and a key shaped like a UUID is refused.
|
|
402
|
+
|
|
390
403
|
### Plan allowances
|
|
391
404
|
|
|
392
405
|
What a plan grants, one meter at a time. An allowance says _how much_, never
|
|
@@ -416,6 +429,12 @@ versions = meterbase.plans.allowances.list(plan.id).data
|
|
|
416
429
|
Each `(plan, meter)` pair carries up to two lineages, `recurring` and
|
|
417
430
|
`one_time`, numbering themselves separately. `kind` defaults to `recurring`.
|
|
418
431
|
|
|
432
|
+
`apply_mode` says when a version reaches customers already on the plan:
|
|
433
|
+
`immediate`, for the whole period they are in, or `next_cycle`, at each one's
|
|
434
|
+
next boundary. Omitted, a meter's first `recurring` version on the plan is
|
|
435
|
+
`immediate`, since there is nothing to phase out, and every later one is
|
|
436
|
+
`next_cycle`. `one_time` is always `immediate`.
|
|
437
|
+
|
|
419
438
|
**A soft cap** keeps the amount and drops the refusal: included usage, then
|
|
420
439
|
overage.
|
|
421
440
|
|
|
@@ -521,6 +540,26 @@ except CycleChangeRequiresResetError:
|
|
|
521
540
|
meterbase.customers.plan.restart(customer.id, plan=weekly.id)
|
|
522
541
|
```
|
|
523
542
|
|
|
543
|
+
#### From a webhook
|
|
544
|
+
|
|
545
|
+
A provider may deliver a webhook twice, and a second `restart` would start a
|
|
546
|
+
second fresh period. Pass the event's id as `idempotency_key`, 1–255
|
|
547
|
+
characters, and a retry repeats nothing:
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
moved = meterbase.customers.plan.restart(
|
|
551
|
+
customer.id, plan="pro", idempotency_key=event.id
|
|
552
|
+
)
|
|
553
|
+
moved.replayed # True when this event had already moved them: nothing was written
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
`assign`, `change`, `change_now` and `change_at_next_cycle` take one too. The
|
|
557
|
+
same request under the key again answers with the assignment it made, as it is
|
|
558
|
+
now, and writes nothing — so a second delivery of an old event cannot cancel a
|
|
559
|
+
change scheduled since. Another request under the key raises
|
|
560
|
+
`IdempotencyKeyReusedError`. A key is kept for good, per workspace. With a key,
|
|
561
|
+
the SDK retries the call too.
|
|
562
|
+
|
|
524
563
|
#### Reading where they stand
|
|
525
564
|
|
|
526
565
|
```python
|
|
@@ -552,6 +591,11 @@ instruction, and writes no new row of its own — so this reads the plan in forc
|
|
|
552
591
|
and names it back. It returns the assignment still in force, or `None` for a
|
|
553
592
|
customer holding no plan, who can have nothing pending.
|
|
554
593
|
|
|
594
|
+
It takes no `idempotency_key`: it names whatever plan is in force when it runs,
|
|
595
|
+
so a retry could name a different one and be refused as a different request.
|
|
596
|
+
For a cancel a webhook orders, `assign` the plan the event says they keep, under
|
|
597
|
+
the event's id.
|
|
598
|
+
|
|
555
599
|
#### How much they have used
|
|
556
600
|
|
|
557
601
|
```python
|
|
@@ -655,6 +699,37 @@ Revoking withdraws what is left without erasing what was consumed, and is
|
|
|
655
699
|
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
656
700
|
appear here with `source="plan"`; they cannot be created through `grant`.
|
|
657
701
|
|
|
702
|
+
**A grant made from a payment's webhook** takes the payment's id as its
|
|
703
|
+
`idempotency_key`, 1–255 characters: the provider may deliver the webhook
|
|
704
|
+
twice, and a retry under the key cannot grant twice.
|
|
705
|
+
|
|
706
|
+
```python
|
|
707
|
+
grant = meterbase.customers.allowances.grant(
|
|
708
|
+
customer.id,
|
|
709
|
+
meter_id="ai_tokens",
|
|
710
|
+
amount=500_000,
|
|
711
|
+
source="purchased",
|
|
712
|
+
idempotency_key=payment.id,
|
|
713
|
+
)
|
|
714
|
+
grant.replayed # True when this payment had already granted: nothing was written
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
The same request under the key again answers with that grant as it is now,
|
|
718
|
+
perhaps spent or revoked since, and writes nothing. Another request under the
|
|
719
|
+
key raises `IdempotencyKeyReusedError`, so build the body the same way every
|
|
720
|
+
time: compute `expires_at` from the payment's own time, not from when the
|
|
721
|
+
handler ran. A key is kept for good, per workspace. With a key, the SDK retries
|
|
722
|
+
the call too.
|
|
723
|
+
|
|
724
|
+
To find what a payment granted — to take it back on a refund, say — list by its
|
|
725
|
+
key. Once the grant is revoked or has expired, add `include_closed=True`.
|
|
726
|
+
|
|
727
|
+
```python
|
|
728
|
+
paid = meterbase.customers.allowances.list(customer.id, idempotency_key=payment.id)
|
|
729
|
+
for grant in paid.data: # the grant the payment made, or none
|
|
730
|
+
meterbase.customers.allowances.revoke(customer.id, grant.id)
|
|
731
|
+
```
|
|
732
|
+
|
|
658
733
|
## Webhooks
|
|
659
734
|
|
|
660
735
|
Meterbase posts to your endpoints when a customer crosses a usage threshold,
|
|
@@ -825,6 +900,7 @@ except RateLimitError as error:
|
|
|
825
900
|
| `NotFoundError` | 404 |
|
|
826
901
|
| `ConflictError` | 409 — a key or external id is taken |
|
|
827
902
|
| `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
|
|
903
|
+
| `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant or plan change was already used for a different request. Nothing was written |
|
|
828
904
|
| `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
|
|
829
905
|
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
830
906
|
| `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
|
|
@@ -881,13 +957,15 @@ an attempt already on the wire may still land, but it is never replayed, so a
|
|
|
881
957
|
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
882
958
|
bound it with `timeout` and `max_retries` instead.
|
|
883
959
|
|
|
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.
|
|
960
|
+
**Retries** apply to `GET`, `DELETE`, `track`, `reserve`, `customers.ensure`,
|
|
961
|
+
and a grant or plan change given an `idempotency_key`, on connection failures,
|
|
962
|
+
timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
|
|
963
|
+
`Retry-After` is honoured when the engine sends it, up to eight seconds. Every
|
|
964
|
+
other `POST` and `PATCH` is never replayed, because a retried create could
|
|
965
|
+
produce a second row. An `ensure` can be, because running it twice changes
|
|
966
|
+
nothing: one whose first attempt landed answers `created` false. So can a keyed
|
|
967
|
+
grant or plan change, which the engine answers with what the first attempt
|
|
968
|
+
wrote: one whose first attempt landed answers `replayed` true.
|
|
891
969
|
|
|
892
970
|
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
893
971
|
and because the SDK fixes that key **before** the retry loop starts: every
|
|
@@ -916,6 +994,10 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
|
916
994
|
change that will not run against your code cannot arrive as a patch. The SDK
|
|
917
995
|
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
918
996
|
|
|
997
|
+
**0.3.0** adds an `idempotency_key` on grants and plan changes, which also makes
|
|
998
|
+
them safe to retry, `replayed` on their answers, and `IdempotencyKeyReusedError`.
|
|
999
|
+
Nothing in it breaks code written for 0.2.0.
|
|
1000
|
+
|
|
919
1001
|
**0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
|
|
920
1002
|
behaviours:
|
|
921
1003
|
|
|
@@ -336,9 +336,17 @@ Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
|
|
|
336
336
|
```python
|
|
337
337
|
meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
|
|
338
338
|
meters = meterbase.meters.list().data
|
|
339
|
+
meterbase.meters.retrieve("api_calls") # by key, or by id
|
|
339
340
|
meterbase.meters.archive(meter_id) # soft delete, idempotent
|
|
340
341
|
```
|
|
341
342
|
|
|
343
|
+
A meter's key works wherever its id does: `retrieve`, `update`, `usage` and
|
|
344
|
+
`archive`, `meter_id` on a grant or a plan allowance, and the `meter_id` filter
|
|
345
|
+
on their lists. Code written against a sandbox then runs unchanged in live,
|
|
346
|
+
where the ids differ. A key that names no meter raises `NotFoundError`. A
|
|
347
|
+
canonical UUID is always read as an id, so creating or renaming a meter to a
|
|
348
|
+
key shaped like one raises `InvalidRequestError`.
|
|
349
|
+
|
|
342
350
|
### Plans
|
|
343
351
|
|
|
344
352
|
A plan has one billing period, fixed when the plan is created. Everything it
|
|
@@ -360,6 +368,11 @@ meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
|
|
|
360
368
|
customers were already measured against, so a different cadence is a different
|
|
361
369
|
plan — move a customer with `customers.plan.assign`.
|
|
362
370
|
|
|
371
|
+
A plan's key works wherever its id does, on the same terms as a meter's:
|
|
372
|
+
`plans.retrieve("pro")`, `plans.allowances.set("pro", ...)`, `plan_id` on
|
|
373
|
+
`customers.plan.assign` and `plan` on each change. A key that names no plan
|
|
374
|
+
raises `NotFoundError`, and a key shaped like a UUID is refused.
|
|
375
|
+
|
|
363
376
|
### Plan allowances
|
|
364
377
|
|
|
365
378
|
What a plan grants, one meter at a time. An allowance says _how much_, never
|
|
@@ -389,6 +402,12 @@ versions = meterbase.plans.allowances.list(plan.id).data
|
|
|
389
402
|
Each `(plan, meter)` pair carries up to two lineages, `recurring` and
|
|
390
403
|
`one_time`, numbering themselves separately. `kind` defaults to `recurring`.
|
|
391
404
|
|
|
405
|
+
`apply_mode` says when a version reaches customers already on the plan:
|
|
406
|
+
`immediate`, for the whole period they are in, or `next_cycle`, at each one's
|
|
407
|
+
next boundary. Omitted, a meter's first `recurring` version on the plan is
|
|
408
|
+
`immediate`, since there is nothing to phase out, and every later one is
|
|
409
|
+
`next_cycle`. `one_time` is always `immediate`.
|
|
410
|
+
|
|
392
411
|
**A soft cap** keeps the amount and drops the refusal: included usage, then
|
|
393
412
|
overage.
|
|
394
413
|
|
|
@@ -494,6 +513,26 @@ except CycleChangeRequiresResetError:
|
|
|
494
513
|
meterbase.customers.plan.restart(customer.id, plan=weekly.id)
|
|
495
514
|
```
|
|
496
515
|
|
|
516
|
+
#### From a webhook
|
|
517
|
+
|
|
518
|
+
A provider may deliver a webhook twice, and a second `restart` would start a
|
|
519
|
+
second fresh period. Pass the event's id as `idempotency_key`, 1–255
|
|
520
|
+
characters, and a retry repeats nothing:
|
|
521
|
+
|
|
522
|
+
```python
|
|
523
|
+
moved = meterbase.customers.plan.restart(
|
|
524
|
+
customer.id, plan="pro", idempotency_key=event.id
|
|
525
|
+
)
|
|
526
|
+
moved.replayed # True when this event had already moved them: nothing was written
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
`assign`, `change`, `change_now` and `change_at_next_cycle` take one too. The
|
|
530
|
+
same request under the key again answers with the assignment it made, as it is
|
|
531
|
+
now, and writes nothing — so a second delivery of an old event cannot cancel a
|
|
532
|
+
change scheduled since. Another request under the key raises
|
|
533
|
+
`IdempotencyKeyReusedError`. A key is kept for good, per workspace. With a key,
|
|
534
|
+
the SDK retries the call too.
|
|
535
|
+
|
|
497
536
|
#### Reading where they stand
|
|
498
537
|
|
|
499
538
|
```python
|
|
@@ -525,6 +564,11 @@ instruction, and writes no new row of its own — so this reads the plan in forc
|
|
|
525
564
|
and names it back. It returns the assignment still in force, or `None` for a
|
|
526
565
|
customer holding no plan, who can have nothing pending.
|
|
527
566
|
|
|
567
|
+
It takes no `idempotency_key`: it names whatever plan is in force when it runs,
|
|
568
|
+
so a retry could name a different one and be refused as a different request.
|
|
569
|
+
For a cancel a webhook orders, `assign` the plan the event says they keep, under
|
|
570
|
+
the event's id.
|
|
571
|
+
|
|
528
572
|
#### How much they have used
|
|
529
573
|
|
|
530
574
|
```python
|
|
@@ -628,6 +672,37 @@ Revoking withdraws what is left without erasing what was consumed, and is
|
|
|
628
672
|
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
629
673
|
appear here with `source="plan"`; they cannot be created through `grant`.
|
|
630
674
|
|
|
675
|
+
**A grant made from a payment's webhook** takes the payment's id as its
|
|
676
|
+
`idempotency_key`, 1–255 characters: the provider may deliver the webhook
|
|
677
|
+
twice, and a retry under the key cannot grant twice.
|
|
678
|
+
|
|
679
|
+
```python
|
|
680
|
+
grant = meterbase.customers.allowances.grant(
|
|
681
|
+
customer.id,
|
|
682
|
+
meter_id="ai_tokens",
|
|
683
|
+
amount=500_000,
|
|
684
|
+
source="purchased",
|
|
685
|
+
idempotency_key=payment.id,
|
|
686
|
+
)
|
|
687
|
+
grant.replayed # True when this payment had already granted: nothing was written
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
The same request under the key again answers with that grant as it is now,
|
|
691
|
+
perhaps spent or revoked since, and writes nothing. Another request under the
|
|
692
|
+
key raises `IdempotencyKeyReusedError`, so build the body the same way every
|
|
693
|
+
time: compute `expires_at` from the payment's own time, not from when the
|
|
694
|
+
handler ran. A key is kept for good, per workspace. With a key, the SDK retries
|
|
695
|
+
the call too.
|
|
696
|
+
|
|
697
|
+
To find what a payment granted — to take it back on a refund, say — list by its
|
|
698
|
+
key. Once the grant is revoked or has expired, add `include_closed=True`.
|
|
699
|
+
|
|
700
|
+
```python
|
|
701
|
+
paid = meterbase.customers.allowances.list(customer.id, idempotency_key=payment.id)
|
|
702
|
+
for grant in paid.data: # the grant the payment made, or none
|
|
703
|
+
meterbase.customers.allowances.revoke(customer.id, grant.id)
|
|
704
|
+
```
|
|
705
|
+
|
|
631
706
|
## Webhooks
|
|
632
707
|
|
|
633
708
|
Meterbase posts to your endpoints when a customer crosses a usage threshold,
|
|
@@ -798,6 +873,7 @@ except RateLimitError as error:
|
|
|
798
873
|
| `NotFoundError` | 404 |
|
|
799
874
|
| `ConflictError` | 409 — a key or external id is taken |
|
|
800
875
|
| `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
|
|
876
|
+
| `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant or plan change was already used for a different request. Nothing was written |
|
|
801
877
|
| `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
|
|
802
878
|
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
803
879
|
| `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
|
|
@@ -854,13 +930,15 @@ an attempt already on the wire may still land, but it is never replayed, so a
|
|
|
854
930
|
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
855
931
|
bound it with `timeout` and `max_retries` instead.
|
|
856
932
|
|
|
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.
|
|
933
|
+
**Retries** apply to `GET`, `DELETE`, `track`, `reserve`, `customers.ensure`,
|
|
934
|
+
and a grant or plan change given an `idempotency_key`, on connection failures,
|
|
935
|
+
timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
|
|
936
|
+
`Retry-After` is honoured when the engine sends it, up to eight seconds. Every
|
|
937
|
+
other `POST` and `PATCH` is never replayed, because a retried create could
|
|
938
|
+
produce a second row. An `ensure` can be, because running it twice changes
|
|
939
|
+
nothing: one whose first attempt landed answers `created` false. So can a keyed
|
|
940
|
+
grant or plan change, which the engine answers with what the first attempt
|
|
941
|
+
wrote: one whose first attempt landed answers `replayed` true.
|
|
864
942
|
|
|
865
943
|
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
866
944
|
and because the SDK fixes that key **before** the retry loop starts: every
|
|
@@ -889,6 +967,10 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
|
889
967
|
change that will not run against your code cannot arrive as a patch. The SDK
|
|
890
968
|
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
891
969
|
|
|
970
|
+
**0.3.0** adds an `idempotency_key` on grants and plan changes, which also makes
|
|
971
|
+
them safe to retry, `replayed` on their answers, and `IdempotencyKeyReusedError`.
|
|
972
|
+
Nothing in it breaks code written for 0.2.0.
|
|
973
|
+
|
|
892
974
|
**0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
|
|
893
975
|
behaviours:
|
|
894
976
|
|
|
@@ -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:
|
|
@@ -7,7 +7,7 @@ from ..._models import ListResult, list_of
|
|
|
7
7
|
from ..._not_given import NOT_GIVEN, NotGiven
|
|
8
8
|
from ..._time import parse_instant
|
|
9
9
|
from ...errors import NotFoundError
|
|
10
|
-
from ...types import ApplyMode, Assignment, Reconciliation
|
|
10
|
+
from ...types import ApplyMode, Assignment, AssignPlanResult, Reconciliation
|
|
11
11
|
from .._io import both
|
|
12
12
|
from .._transport import AsyncRequester
|
|
13
13
|
|
|
@@ -23,7 +23,10 @@ class AsyncCustomerPlan:
|
|
|
23
23
|
that picking one does not mean knowing what `effective` and
|
|
24
24
|
`reconciliation` do to a half-used period. They name the plan `plan`
|
|
25
25
|
rather than `plan_id`: they sit a layer above the wire, where `assign`
|
|
26
|
-
still mirrors it field for field.
|
|
26
|
+
still mirrors it field for field. Either takes the plan's id or its key.
|
|
27
|
+
|
|
28
|
+
Every move takes an `idempotency_key`, as `assign` describes, except
|
|
29
|
+
`cancel_scheduled_change`.
|
|
27
30
|
"""
|
|
28
31
|
|
|
29
32
|
def __init__(self, api: AsyncRequester) -> None:
|
|
@@ -37,11 +40,13 @@ class AsyncCustomerPlan:
|
|
|
37
40
|
effective: ApplyMode | NotGiven = NOT_GIVEN,
|
|
38
41
|
reconciliation: Reconciliation | NotGiven = NOT_GIVEN,
|
|
39
42
|
cycle_anchor: str | NotGiven = NOT_GIVEN,
|
|
40
|
-
|
|
43
|
+
idempotency_key: str | NotGiven = NOT_GIVEN,
|
|
44
|
+
) -> AssignPlanResult:
|
|
41
45
|
"""
|
|
42
|
-
Put a customer on a plan
|
|
43
|
-
takes `cycle_anchor` — a first
|
|
44
|
-
periods on a date they already
|
|
46
|
+
Put a customer on a plan, named by its id or its key. This is the wire
|
|
47
|
+
call, and the only one that takes `cycle_anchor` — a first
|
|
48
|
+
assignment's one chance to put their periods on a date they already
|
|
49
|
+
have.
|
|
45
50
|
|
|
46
51
|
Returns the instruction as recorded, which is not always as asked: a
|
|
47
52
|
first plan is recorded `immediate` whatever `effective` said.
|
|
@@ -52,17 +57,27 @@ class AsyncCustomerPlan:
|
|
|
52
57
|
`next_cycle`. `cycle_anchor` defaults to the instant the plan lands,
|
|
53
58
|
and is refused beside `reconciliation="reset"`, which anchors at the
|
|
54
59
|
change instant itself.
|
|
60
|
+
|
|
61
|
+
`idempotency_key`, 1–255 characters, makes a change safe to retry.
|
|
62
|
+
From a provider's webhook, which may arrive twice, pass the event's
|
|
63
|
+
id: a retry then cannot restart a period twice, or cancel a change
|
|
64
|
+
scheduled after it. The same request again answers with that
|
|
65
|
+
assignment and `replayed=True`, and another one under the key raises
|
|
66
|
+
`IdempotencyKeyReusedError`. With a key, the SDK retries the call too.
|
|
55
67
|
"""
|
|
56
68
|
return await self._api.request(
|
|
57
69
|
"POST",
|
|
58
70
|
f"/v1/customers/{segment(customer_id)}/plan",
|
|
59
|
-
|
|
71
|
+
AssignPlanResult,
|
|
60
72
|
body={
|
|
61
73
|
"plan_id": plan_id,
|
|
62
74
|
"effective": effective,
|
|
63
75
|
"reconciliation": reconciliation,
|
|
64
76
|
"cycle_anchor": cycle_anchor,
|
|
77
|
+
"idempotency_key": idempotency_key,
|
|
65
78
|
},
|
|
79
|
+
# None goes out as null, which the engine reads as no key at all.
|
|
80
|
+
idempotent=isinstance(idempotency_key, str),
|
|
66
81
|
)
|
|
67
82
|
|
|
68
83
|
async def change(
|
|
@@ -72,12 +87,13 @@ class AsyncCustomerPlan:
|
|
|
72
87
|
plan: str,
|
|
73
88
|
effective: ApplyMode | NotGiven = NOT_GIVEN,
|
|
74
89
|
reconciliation: Reconciliation | NotGiven = NOT_GIVEN,
|
|
75
|
-
|
|
90
|
+
idempotency_key: str | NotGiven = NOT_GIVEN,
|
|
91
|
+
) -> AssignPlanResult:
|
|
76
92
|
"""
|
|
77
|
-
Move a customer to a different plan, named by its
|
|
93
|
+
Move a customer to a different plan, named by its id or its key, with
|
|
78
94
|
both knobs in the open. `effective` defaults to `next_cycle`;
|
|
79
95
|
`reconciliation` applies only to an `immediate` change and defaults to
|
|
80
|
-
`none`.
|
|
96
|
+
`none`. `idempotency_key` is as on `assign`.
|
|
81
97
|
|
|
82
98
|
Reach for `change_at_next_cycle`, `change_now` or `restart` when one of
|
|
83
99
|
them says what you mean — this is the form to fall back to when the two
|
|
@@ -88,9 +104,16 @@ class AsyncCustomerPlan:
|
|
|
88
104
|
plan_id=plan,
|
|
89
105
|
effective=effective,
|
|
90
106
|
reconciliation=reconciliation,
|
|
107
|
+
idempotency_key=idempotency_key,
|
|
91
108
|
)
|
|
92
109
|
|
|
93
|
-
async def change_at_next_cycle(
|
|
110
|
+
async def change_at_next_cycle(
|
|
111
|
+
self,
|
|
112
|
+
customer_id: str,
|
|
113
|
+
*,
|
|
114
|
+
plan: str,
|
|
115
|
+
idempotency_key: str | NotGiven = NOT_GIVEN,
|
|
116
|
+
) -> AssignPlanResult:
|
|
94
117
|
"""
|
|
95
118
|
Schedule the move for the end of the period they are in: they keep the
|
|
96
119
|
plan they are paying for until it runs out, and the new one starts at
|
|
@@ -100,7 +123,12 @@ class AsyncCustomerPlan:
|
|
|
100
123
|
them this raises `CycleChangeRequiresResetError`: use `restart` for
|
|
101
124
|
that move. Other cycles land on the coarser plan's boundary.
|
|
102
125
|
"""
|
|
103
|
-
return await self.change(
|
|
126
|
+
return await self.change(
|
|
127
|
+
customer_id,
|
|
128
|
+
plan=plan,
|
|
129
|
+
effective="next_cycle",
|
|
130
|
+
idempotency_key=idempotency_key,
|
|
131
|
+
)
|
|
104
132
|
|
|
105
133
|
async def change_now(
|
|
106
134
|
self,
|
|
@@ -108,7 +136,8 @@ class AsyncCustomerPlan:
|
|
|
108
136
|
*,
|
|
109
137
|
plan: str,
|
|
110
138
|
reconciliation: Reconciliation | NotGiven = NOT_GIVEN,
|
|
111
|
-
|
|
139
|
+
idempotency_key: str | NotGiven = NOT_GIVEN,
|
|
140
|
+
) -> AssignPlanResult:
|
|
112
141
|
"""
|
|
113
142
|
Move them now, inside the period already running. Their renewal day
|
|
114
143
|
does not move; what changes is this period's cap, and `reconciliation`
|
|
@@ -129,9 +158,16 @@ class AsyncCustomerPlan:
|
|
|
129
158
|
plan=plan,
|
|
130
159
|
effective="immediate",
|
|
131
160
|
reconciliation=reconciliation,
|
|
161
|
+
idempotency_key=idempotency_key,
|
|
132
162
|
)
|
|
133
163
|
|
|
134
|
-
async def restart(
|
|
164
|
+
async def restart(
|
|
165
|
+
self,
|
|
166
|
+
customer_id: str,
|
|
167
|
+
*,
|
|
168
|
+
plan: str,
|
|
169
|
+
idempotency_key: str | NotGiven = NOT_GIVEN,
|
|
170
|
+
) -> AssignPlanResult:
|
|
135
171
|
"""
|
|
136
172
|
Move them now and start a fresh period today: the period they were in
|
|
137
173
|
closes where the change lands, keeping the usage it had, and the new
|
|
@@ -140,13 +176,16 @@ class AsyncCustomerPlan:
|
|
|
140
176
|
This moves the customer's anchor, so their renewal day becomes today.
|
|
141
177
|
It is the move for a customer starting over — a new contract, a
|
|
142
178
|
re-signup — and the only one that can take a customer between plans
|
|
143
|
-
whose cycles differ.
|
|
179
|
+
whose cycles differ. When a provider's webhook orders it, pass the
|
|
180
|
+
event's id as `idempotency_key`: a second delivery then replays this
|
|
181
|
+
rather than starting another period.
|
|
144
182
|
"""
|
|
145
183
|
return await self.change(
|
|
146
184
|
customer_id,
|
|
147
185
|
plan=plan,
|
|
148
186
|
effective="immediate",
|
|
149
187
|
reconciliation="reset",
|
|
188
|
+
idempotency_key=idempotency_key,
|
|
150
189
|
)
|
|
151
190
|
|
|
152
191
|
async def cancel_scheduled_change(self, customer_id: str) -> Assignment | None:
|
|
@@ -158,6 +197,11 @@ class AsyncCustomerPlan:
|
|
|
158
197
|
Returns the assignment still in force, or `None` for a customer holding
|
|
159
198
|
no plan — who can have nothing pending, since a first assignment
|
|
160
199
|
always lands at once.
|
|
200
|
+
|
|
201
|
+
It takes no `idempotency_key`: it names whatever plan is in force when
|
|
202
|
+
it runs, so a retry could name a different one and be refused as a
|
|
203
|
+
different request. For a cancel a webhook orders, `assign` the plan
|
|
204
|
+
the event says they keep, under the event's id.
|
|
161
205
|
"""
|
|
162
206
|
current = await self.retrieve(customer_id)
|
|
163
207
|
if current is None:
|
|
@@ -24,6 +24,9 @@ class AsyncMeters:
|
|
|
24
24
|
thresholds: Sequence[int] | NotGiven = NOT_GIVEN,
|
|
25
25
|
) -> Meter:
|
|
26
26
|
"""
|
|
27
|
+
`key` names the meter wherever its id does, so it must not be shaped
|
|
28
|
+
like a UUID: the API reads one as an id.
|
|
29
|
+
|
|
27
30
|
`thresholds` is whole percents, 1–1000, at most 32 of them; a mark over
|
|
28
31
|
100 is an overage alert. Omitted, the meter inherits the workspace's.
|
|
29
32
|
"""
|
|
@@ -52,6 +55,7 @@ class AsyncMeters:
|
|
|
52
55
|
)
|
|
53
56
|
|
|
54
57
|
async def retrieve(self, id: str) -> Meter:
|
|
58
|
+
"""`id` is the meter's id or its key."""
|
|
55
59
|
return await self._api.request("GET", f"/v1/meters/{segment(id)}", Meter)
|
|
56
60
|
|
|
57
61
|
async def update(
|
|
@@ -66,7 +70,9 @@ class AsyncMeters:
|
|
|
66
70
|
thresholds: Sequence[int] | NotGiven | None = NOT_GIVEN,
|
|
67
71
|
) -> Meter:
|
|
68
72
|
"""
|
|
69
|
-
|
|
73
|
+
`id` is the meter's id or its key, and a new `key` must not be shaped
|
|
74
|
+
like a UUID. An omitted field is left alone; `""` clears an optional
|
|
75
|
+
one.
|
|
70
76
|
|
|
71
77
|
`thresholds` is the exception on both counts: `None` is a value there,
|
|
72
78
|
not silence. Omit it to keep the stored list, pass `None` to inherit
|
|
@@ -87,8 +93,8 @@ class AsyncMeters:
|
|
|
87
93
|
)
|
|
88
94
|
|
|
89
95
|
async def usage(self, id: str, *, days: int) -> MeterUsageHistory:
|
|
90
|
-
"""Every customer's usage of the meter
|
|
91
|
-
them, ending today."""
|
|
96
|
+
"""Every customer's usage of the meter, named by its id or its key, per
|
|
97
|
+
UTC day: `days` is 1–90 of them, ending today."""
|
|
92
98
|
return await self._api.request(
|
|
93
99
|
"GET",
|
|
94
100
|
f"/v1/meters/{segment(id)}/usage",
|
|
@@ -97,6 +103,6 @@ class AsyncMeters:
|
|
|
97
103
|
)
|
|
98
104
|
|
|
99
105
|
async def archive(self, id: str) -> Meter:
|
|
100
|
-
"""Soft delete: plans and usage keep referencing the
|
|
101
|
-
Idempotent."""
|
|
106
|
+
"""Soft delete, by id or key: plans and usage keep referencing the
|
|
107
|
+
meter. Idempotent."""
|
|
102
108
|
return await self._api.request("DELETE", f"/v1/meters/{segment(id)}", Meter)
|