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.
Files changed (41) hide show
  1. {meterbase-0.2.0 → meterbase-0.3.0}/PKG-INFO +90 -8
  2. {meterbase-0.2.0 → meterbase-0.3.0}/README.md +89 -7
  3. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/__init__.py +2 -0
  4. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_allowances.py +37 -10
  5. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_plan.py +59 -15
  6. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/meters.py +11 -5
  7. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/plans.py +22 -13
  8. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_http.py +12 -0
  9. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_allowances.py +37 -10
  10. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_plan.py +59 -15
  11. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/meters.py +11 -5
  12. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/plans.py +22 -13
  13. meterbase-0.3.0/src/meterbase/_version.py +1 -0
  14. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/errors.py +12 -0
  15. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/types.py +28 -0
  16. meterbase-0.2.0/src/meterbase/_version.py +0 -1
  17. {meterbase-0.2.0 → meterbase-0.3.0}/.gitignore +0 -0
  18. {meterbase-0.2.0 → meterbase-0.3.0}/LICENSE +0 -0
  19. {meterbase-0.2.0 → meterbase-0.3.0}/pyproject.toml +0 -0
  20. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/__init__.py +0 -0
  21. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/_io.py +0 -0
  22. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/_transport.py +0 -0
  23. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/client.py +0 -0
  24. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/__init__.py +0 -0
  25. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
  26. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_async/resources/customers.py +0 -0
  27. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_json.py +0 -0
  28. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_keys.py +0 -0
  29. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_models.py +0 -0
  30. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_not_given.py +0 -0
  31. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/__init__.py +0 -0
  32. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/_io.py +0 -0
  33. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/_transport.py +0 -0
  34. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/client.py +0 -0
  35. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/__init__.py +0 -0
  36. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
  37. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_sync/resources/customers.py +0 -0
  38. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/_time.py +0 -0
  39. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/py.typed +0 -0
  40. {meterbase-0.2.0 → meterbase-0.3.0}/src/meterbase/resources.py +0 -0
  41. {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.2.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` and
885
- `customers.ensure`, on connection failures, timeouts, 408, 429 and 5xx, with
886
- exponential backoff and full jitter. `Retry-After` is honoured when the engine
887
- sends it, up to eight seconds. Every other `POST` and `PATCH` is never
888
- replayed, because a retried create could produce a second row. An `ensure` can
889
- be, because running it twice changes nothing: one whose first attempt landed
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` and
858
- `customers.ensure`, on connection failures, timeouts, 408, 429 and 5xx, with
859
- exponential backoff and full jitter. `Retry-After` is honoured when the engine
860
- sends it, up to eight seconds. Every other `POST` and `PATCH` is never
861
- replayed, because a retried create could produce a second row. An `ensure` can
862
- be, because running it twice changes nothing: one whose first attempt landed
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
- ) -> Allowance:
34
+ idempotency_key: str | NotGiven = NOT_GIVEN,
35
+ ) -> AllowanceGrantResult:
35
36
  """
36
- Give exactly one of `amount` or `no_cap`. Unlike a plan allowance, a
37
- grant of 0 is refused: a grant that gives nothing is a no-op, not a
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
- Allowance,
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
- """Open grants only, unless `include_closed`: revoked, expired and
67
- exhausted grants as well."""
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={"meter_id": meter_id, "include_closed": include_closed},
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
- ) -> Assignment:
43
+ idempotency_key: str | NotGiven = NOT_GIVEN,
44
+ ) -> AssignPlanResult:
41
45
  """
42
- Put a customer on a plan. This is the wire call, and the only one that
43
- takes `cycle_anchor` — a first assignment's one chance to put their
44
- periods on a date they already have.
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
- Assignment,
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
- ) -> Assignment:
90
+ idempotency_key: str | NotGiven = NOT_GIVEN,
91
+ ) -> AssignPlanResult:
76
92
  """
77
- Move a customer to a different plan, named by its Meterbase id, with
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(self, customer_id: str, *, plan: str) -> Assignment:
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(customer_id, plan=plan, effective="next_cycle")
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
- ) -> Assignment:
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(self, customer_id: str, *, plan: str) -> Assignment:
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
- An omitted field is left alone; `""` clears an optional one.
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 per UTC day: `days` is 1–90 of
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 meter.
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)