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.
Files changed (41) hide show
  1. {meterbase-0.2.0 → meterbase-0.4.0}/PKG-INFO +118 -10
  2. {meterbase-0.2.0 → meterbase-0.4.0}/README.md +117 -9
  3. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/__init__.py +2 -0
  4. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/customer_allowances.py +37 -10
  5. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/customer_plan.py +59 -15
  6. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/customers.py +68 -10
  7. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/meters.py +11 -5
  8. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/plans.py +22 -13
  9. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_http.py +27 -0
  10. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_models.py +12 -0
  11. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/customer_allowances.py +37 -10
  12. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/customer_plan.py +59 -15
  13. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/customers.py +68 -10
  14. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/meters.py +11 -5
  15. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/plans.py +22 -13
  16. meterbase-0.4.0/src/meterbase/_version.py +1 -0
  17. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/errors.py +12 -0
  18. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/types.py +30 -1
  19. meterbase-0.2.0/src/meterbase/_version.py +0 -1
  20. {meterbase-0.2.0 → meterbase-0.4.0}/.gitignore +0 -0
  21. {meterbase-0.2.0 → meterbase-0.4.0}/LICENSE +0 -0
  22. {meterbase-0.2.0 → meterbase-0.4.0}/pyproject.toml +0 -0
  23. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/__init__.py +0 -0
  24. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/_io.py +0 -0
  25. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/_transport.py +0 -0
  26. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/client.py +0 -0
  27. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/__init__.py +0 -0
  28. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
  29. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_json.py +0 -0
  30. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_keys.py +0 -0
  31. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_not_given.py +0 -0
  32. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/__init__.py +0 -0
  33. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/_io.py +0 -0
  34. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/_transport.py +0 -0
  35. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/client.py +0 -0
  36. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/__init__.py +0 -0
  37. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
  38. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/_time.py +0 -0
  39. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/py.typed +0 -0
  40. {meterbase-0.2.0 → meterbase-0.4.0}/src/meterbase/resources.py +0 -0
  41. {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.2.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` 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.
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` 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.
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
- ) -> 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: