meterbase 0.1.4__tar.gz → 0.2.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.1.4 → meterbase-0.2.0}/PKG-INFO +108 -21
  2. {meterbase-0.1.4 → meterbase-0.2.0}/README.md +107 -20
  3. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/_transport.py +11 -6
  4. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/client.py +22 -5
  5. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/customers.py +51 -1
  6. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_http.py +16 -3
  7. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/_transport.py +11 -6
  8. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/client.py +22 -5
  9. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/customers.py +51 -1
  10. meterbase-0.2.0/src/meterbase/_version.py +1 -0
  11. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/errors.py +1 -1
  12. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/types.py +47 -0
  13. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/webhooks.py +11 -5
  14. meterbase-0.1.4/src/meterbase/_version.py +0 -1
  15. {meterbase-0.1.4 → meterbase-0.2.0}/.gitignore +0 -0
  16. {meterbase-0.1.4 → meterbase-0.2.0}/LICENSE +0 -0
  17. {meterbase-0.1.4 → meterbase-0.2.0}/pyproject.toml +0 -0
  18. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/__init__.py +0 -0
  19. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/__init__.py +0 -0
  20. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/_io.py +0 -0
  21. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/__init__.py +0 -0
  22. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_allowances.py +0 -0
  23. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
  24. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_plan.py +0 -0
  25. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/meters.py +0 -0
  26. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/plans.py +0 -0
  27. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_json.py +0 -0
  28. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_keys.py +0 -0
  29. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_models.py +0 -0
  30. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_not_given.py +0 -0
  31. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/__init__.py +0 -0
  32. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/_io.py +0 -0
  33. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/__init__.py +0 -0
  34. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_allowances.py +0 -0
  35. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
  36. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_plan.py +0 -0
  37. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/meters.py +0 -0
  38. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/plans.py +0 -0
  39. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_time.py +0 -0
  40. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/py.typed +0 -0
  41. {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/resources.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meterbase
3
- Version: 0.1.4
3
+ Version: 0.2.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
@@ -185,11 +185,26 @@ see the error. When **you** supply the key, you get `TooLateError` with
185
185
  that was already recorded.
186
186
 
187
187
  **The SDK generates a key when you omit one, and reuses it across that call's
188
- retries** — including its own, since `track` is a `POST` it will replay.
189
- Supply your own when the caller already has an id for the work — a job id, a
190
- request id — so that a retry from further out than this SDK replays too. Keys
191
- are retained 35 days; a retry after that records a second event, which is not a
192
- case this API sets out to serve.
188
+ retries.** For a retry from further out than this SDK — a queue, a restarted
189
+ worker — mint the key once the work is done, and store it with the job before
190
+ the first send:
191
+
192
+ ```python
193
+ if job.usage_key is None:
194
+ job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
195
+ save_job(job) # before sending, so a rerun reuses it
196
+
197
+ meterbase.track(
198
+ customer_id=job.customer_id,
199
+ meter_id="ai_tokens",
200
+ quantity=job.tokens,
201
+ idempotency_key=job.usage_key,
202
+ )
203
+ ```
204
+
205
+ It is dated by the engine's clock as this client knows it, and the hour it is
206
+ admitted for starts then: mint it when the work is queued and a slow queue can
207
+ outlast it.
193
208
 
194
209
  ### Work you cannot do twice
195
210
 
@@ -256,6 +271,30 @@ afresh rather than finding anything.
256
271
 
257
272
  ### Customers
258
273
 
274
+ **At sign-up, ensure the customer.** One call gets or creates them by your own
275
+ id and gives them a first plan, and running it again changes nothing:
276
+
277
+ ```python
278
+ ensured = meterbase.customers.ensure(
279
+ external_id="acct_1",
280
+ name="Acme", # applied only when it creates the customer
281
+ plan="free", # a plan key
282
+ )
283
+ ensured.created, ensured.plan_result, ensured.customer.plan
284
+ ```
285
+
286
+ `plan_result` says what it did with the plan: `assigned`, `already_held`,
287
+ `kept_other` when the customer holds another plan, or `none` when you named
288
+ none. It never changes a plan the customer holds — a checkout that must move
289
+ them calls [`customers.plan`](#a-customers-plan) — and never cancels a
290
+ scheduled change. `cycle_anchor` puts a first plan's periods on a date the
291
+ customer already has, and `mint_one_time=False` writes the plan's one-time
292
+ grants already spent, for a customer you import with their remaining bonus.
293
+
294
+ `ensure` and `retrieve_by_external_id` raise `MeterbaseError` before sending
295
+ when the id is missing, empty or whitespace: without one, the lookup would name
296
+ no customer.
297
+
259
298
  ```python
260
299
  customer = meterbase.customers.create(
261
300
  external_id="acct_1",
@@ -618,14 +657,16 @@ appear here with `source="plan"`; they cannot be created through `grant`.
618
657
 
619
658
  ## Webhooks
620
659
 
621
- Meterbase posts to your endpoints when a customer crosses a usage threshold.
622
- Add an endpoint in the dashboard under **Webhooks**; each has its own signing
623
- secret, shown once when you add it and revealable from its settings.
660
+ Meterbase posts to your endpoints when a customer crosses a usage threshold,
661
+ and when they run out of capacity on a meter. Add an endpoint in the dashboard
662
+ under **Webhooks**; each has its own signing secret, shown once when you add it
663
+ and revealable from its settings.
624
664
 
625
665
  | Event | When |
626
666
  | --------------------- | -------------------------------------------------------------------------------------- |
627
667
  | `threshold.crossed` | a customer's usage of a meter crosses one of their thresholds |
628
668
  | `entitlement.reached` | that crossing at 100% of their plan's entitlement, sent as well as `threshold.crossed` |
669
+ | `capacity.exhausted` | a `track` spends the last unit a hard cap allows, so `check` refuses them from then on |
629
670
 
630
671
  ```json
631
672
  {
@@ -653,6 +694,34 @@ grants, so ask `check` for capacity. `entitlement.reached` marks the plan's
653
694
  entitlement, not spent capacity: a grant can still have balance, and a soft cap
654
695
  admits past it.
655
696
 
697
+ `capacity.exhausted` carries no figures, since nothing is left:
698
+
699
+ ```json
700
+ {
701
+ "id": "0199e2c4-7b10-7c3a-9d41-6a2f0e1b5c88",
702
+ "type": "capacity.exhausted",
703
+ "timestamp": "2026-10-14T09:12:03Z",
704
+ "data": {
705
+ "customer_id": "acct_1",
706
+ "meter_id": "ai_tokens",
707
+ "plan": { "id": "01993c40-…", "key": "pro", "name": "Pro" },
708
+ "period": {
709
+ "start": "2026-10-01T00:00:00Z",
710
+ "end": "2026-11-01T00:00:00Z"
711
+ },
712
+ "usage_event_id": "0199e2c4-7aff-7d2a-8c55-2f6e1a3bc738"
713
+ }
714
+ }
715
+ ```
716
+
717
+ `plan` and `period` are `None` when grants were the customer's only capacity on
718
+ the meter. It counts the plan, a carry and every grant, so it is sent once each
719
+ time all of them run out, and again after a new grant or period is spent. A soft
720
+ cap or No Cap never sends it. A hold on the last unit does not send it; the
721
+ `track` that commits the hold does. A grant expiring, a revoke or a plan change
722
+ can leave a customer at 0 without it. Existing endpoints receive it only once
723
+ you add it to their events.
724
+
656
725
  ### Verifying a delivery
657
726
 
658
727
  Verify every request before you trust it. `verify_webhook` checks the signature
@@ -662,7 +731,7 @@ and the timestamp, then returns the event:
662
731
  from flask import Flask, request
663
732
 
664
733
  from meterbase import WebhookVerificationError, verify_webhook
665
- from meterbase.types import EntitlementReachedEvent
734
+ from meterbase.types import CapacityExhaustedEvent, EntitlementReachedEvent
666
735
 
667
736
  app = Flask(__name__)
668
737
 
@@ -680,6 +749,8 @@ def meterbase_webhook():
680
749
 
681
750
  if isinstance(event, EntitlementReachedEvent):
682
751
  ... # event.data.customer_id has used their plan's allowance for event.data.meter_id
752
+ elif isinstance(event, CapacityExhaustedEvent):
753
+ ... # check refuses event.data.customer_id on event.data.meter_id from now on
683
754
  return "", 204
684
755
  ```
685
756
 
@@ -687,9 +758,10 @@ In FastAPI or Starlette, pass `await request.body()` and `request.headers`; in
687
758
  Django, `request.body` and `request.headers`. Header names are matched without
688
759
  regard to case, so a plain `dict` works too.
689
760
 
690
- The event is a `ThresholdCrossedEvent` or an `EntitlementReachedEvent`, so
691
- `isinstance` tells them apart. New types can be added: one this release does not
692
- know reads as a plain `WebhookEvent`, so ignore any you do not handle.
761
+ The event is a `ThresholdCrossedEvent`, an `EntitlementReachedEvent` or a
762
+ `CapacityExhaustedEvent`, so `isinstance` tells them apart. New types can be
763
+ added: one this release does not know reads as a plain `WebhookEvent`, so ignore
764
+ any you do not handle.
693
765
  `verify_webhook` is synchronous, and fast enough to call from an async handler.
694
766
 
695
767
  Without the SDK, it is [Standard Webhooks](https://www.standardwebhooks.com):
@@ -709,7 +781,7 @@ included: Meterbase does not follow them.
709
781
 
710
782
  | Attempt | After the one before |
711
783
  | ------- | ------------------------------- |
712
- | 1 | within a minute of the crossing |
784
+ | 1 | within a minute of the event |
713
785
  | 2 | 1 minute |
714
786
  | 3 | 6 minutes |
715
787
  | 4 | 36 minutes |
@@ -778,14 +850,18 @@ SDK would raise for a response, for realistic errors in your own tests.
778
850
  Meterbase(
779
851
  api_key="mb_sk_live_…",
780
852
  base_url="https://api.meterbase.dev",
781
- timeout=35.0, # seconds, per attempt
853
+ timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
782
854
  max_retries=2,
783
855
  http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
784
856
  )
785
857
  ```
786
858
 
787
859
  `timeout` bounds each attempt — connecting, sending, and each wait for the
788
- engine to answer — not the whole call. Pass an `http_client` for proxies,
860
+ engine to answer — not the whole call. **`check` waits 3 s** unless the client
861
+ sets `timeout`: it gates a request someone is waiting on, and answers in
862
+ milliseconds when the engine is well. Decide what an `APITimeoutError` means
863
+ there — failing open or failing closed — rather than leaving the user waiting
864
+ out the 35 s the writes get. Pass an `http_client` for proxies,
789
865
  limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
790
866
  `httpx.AsyncClient` for `AsyncMeterbase`.
791
867
 
@@ -805,11 +881,13 @@ an attempt already on the wire may still land, but it is never replayed, so a
805
881
  cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
806
882
  bound it with `timeout` and `max_retries` instead.
807
883
 
808
- **Retries** apply to `GET`, `DELETE`, `track` and `reserve`, on connection
809
- failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
810
- `Retry-After` is honoured when the engine sends it, up to eight seconds. Every
811
- other `POST` and `PATCH` is never replayed, because a retried create could
812
- produce a second row.
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.
813
891
 
814
892
  `track` and `reserve` are the exception because they carry an idempotency key,
815
893
  and because the SDK fixes that key **before** the retry loop starts: every
@@ -838,6 +916,15 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
838
916
  change that will not run against your code cannot arrive as a patch. The SDK
839
917
  follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
840
918
 
919
+ **0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
920
+ behaviours:
921
+
922
+ - `check` waits 3 s by default. The other calls keep 35 s, and a `timeout` set
923
+ on the client, or through `with_options`, still wins.
924
+ - `retrieve_by_external_id` raises before sending when the id is missing, empty
925
+ or whitespace. Before, a `None` was dropped from the query, and the engine
926
+ answered with every customer, the newest first, which this returned.
927
+
841
928
  ## Development
842
929
 
843
930
  ```bash
@@ -158,11 +158,26 @@ see the error. When **you** supply the key, you get `TooLateError` with
158
158
  that was already recorded.
159
159
 
160
160
  **The SDK generates a key when you omit one, and reuses it across that call's
161
- retries** — including its own, since `track` is a `POST` it will replay.
162
- Supply your own when the caller already has an id for the work — a job id, a
163
- request id — so that a retry from further out than this SDK replays too. Keys
164
- are retained 35 days; a retry after that records a second event, which is not a
165
- case this API sets out to serve.
161
+ retries.** For a retry from further out than this SDK — a queue, a restarted
162
+ worker — mint the key once the work is done, and store it with the job before
163
+ the first send:
164
+
165
+ ```python
166
+ if job.usage_key is None:
167
+ job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
168
+ save_job(job) # before sending, so a rerun reuses it
169
+
170
+ meterbase.track(
171
+ customer_id=job.customer_id,
172
+ meter_id="ai_tokens",
173
+ quantity=job.tokens,
174
+ idempotency_key=job.usage_key,
175
+ )
176
+ ```
177
+
178
+ It is dated by the engine's clock as this client knows it, and the hour it is
179
+ admitted for starts then: mint it when the work is queued and a slow queue can
180
+ outlast it.
166
181
 
167
182
  ### Work you cannot do twice
168
183
 
@@ -229,6 +244,30 @@ afresh rather than finding anything.
229
244
 
230
245
  ### Customers
231
246
 
247
+ **At sign-up, ensure the customer.** One call gets or creates them by your own
248
+ id and gives them a first plan, and running it again changes nothing:
249
+
250
+ ```python
251
+ ensured = meterbase.customers.ensure(
252
+ external_id="acct_1",
253
+ name="Acme", # applied only when it creates the customer
254
+ plan="free", # a plan key
255
+ )
256
+ ensured.created, ensured.plan_result, ensured.customer.plan
257
+ ```
258
+
259
+ `plan_result` says what it did with the plan: `assigned`, `already_held`,
260
+ `kept_other` when the customer holds another plan, or `none` when you named
261
+ none. It never changes a plan the customer holds — a checkout that must move
262
+ them calls [`customers.plan`](#a-customers-plan) — and never cancels a
263
+ scheduled change. `cycle_anchor` puts a first plan's periods on a date the
264
+ customer already has, and `mint_one_time=False` writes the plan's one-time
265
+ grants already spent, for a customer you import with their remaining bonus.
266
+
267
+ `ensure` and `retrieve_by_external_id` raise `MeterbaseError` before sending
268
+ when the id is missing, empty or whitespace: without one, the lookup would name
269
+ no customer.
270
+
232
271
  ```python
233
272
  customer = meterbase.customers.create(
234
273
  external_id="acct_1",
@@ -591,14 +630,16 @@ appear here with `source="plan"`; they cannot be created through `grant`.
591
630
 
592
631
  ## Webhooks
593
632
 
594
- Meterbase posts to your endpoints when a customer crosses a usage threshold.
595
- Add an endpoint in the dashboard under **Webhooks**; each has its own signing
596
- secret, shown once when you add it and revealable from its settings.
633
+ Meterbase posts to your endpoints when a customer crosses a usage threshold,
634
+ and when they run out of capacity on a meter. Add an endpoint in the dashboard
635
+ under **Webhooks**; each has its own signing secret, shown once when you add it
636
+ and revealable from its settings.
597
637
 
598
638
  | Event | When |
599
639
  | --------------------- | -------------------------------------------------------------------------------------- |
600
640
  | `threshold.crossed` | a customer's usage of a meter crosses one of their thresholds |
601
641
  | `entitlement.reached` | that crossing at 100% of their plan's entitlement, sent as well as `threshold.crossed` |
642
+ | `capacity.exhausted` | a `track` spends the last unit a hard cap allows, so `check` refuses them from then on |
602
643
 
603
644
  ```json
604
645
  {
@@ -626,6 +667,34 @@ grants, so ask `check` for capacity. `entitlement.reached` marks the plan's
626
667
  entitlement, not spent capacity: a grant can still have balance, and a soft cap
627
668
  admits past it.
628
669
 
670
+ `capacity.exhausted` carries no figures, since nothing is left:
671
+
672
+ ```json
673
+ {
674
+ "id": "0199e2c4-7b10-7c3a-9d41-6a2f0e1b5c88",
675
+ "type": "capacity.exhausted",
676
+ "timestamp": "2026-10-14T09:12:03Z",
677
+ "data": {
678
+ "customer_id": "acct_1",
679
+ "meter_id": "ai_tokens",
680
+ "plan": { "id": "01993c40-…", "key": "pro", "name": "Pro" },
681
+ "period": {
682
+ "start": "2026-10-01T00:00:00Z",
683
+ "end": "2026-11-01T00:00:00Z"
684
+ },
685
+ "usage_event_id": "0199e2c4-7aff-7d2a-8c55-2f6e1a3bc738"
686
+ }
687
+ }
688
+ ```
689
+
690
+ `plan` and `period` are `None` when grants were the customer's only capacity on
691
+ the meter. It counts the plan, a carry and every grant, so it is sent once each
692
+ time all of them run out, and again after a new grant or period is spent. A soft
693
+ cap or No Cap never sends it. A hold on the last unit does not send it; the
694
+ `track` that commits the hold does. A grant expiring, a revoke or a plan change
695
+ can leave a customer at 0 without it. Existing endpoints receive it only once
696
+ you add it to their events.
697
+
629
698
  ### Verifying a delivery
630
699
 
631
700
  Verify every request before you trust it. `verify_webhook` checks the signature
@@ -635,7 +704,7 @@ and the timestamp, then returns the event:
635
704
  from flask import Flask, request
636
705
 
637
706
  from meterbase import WebhookVerificationError, verify_webhook
638
- from meterbase.types import EntitlementReachedEvent
707
+ from meterbase.types import CapacityExhaustedEvent, EntitlementReachedEvent
639
708
 
640
709
  app = Flask(__name__)
641
710
 
@@ -653,6 +722,8 @@ def meterbase_webhook():
653
722
 
654
723
  if isinstance(event, EntitlementReachedEvent):
655
724
  ... # event.data.customer_id has used their plan's allowance for event.data.meter_id
725
+ elif isinstance(event, CapacityExhaustedEvent):
726
+ ... # check refuses event.data.customer_id on event.data.meter_id from now on
656
727
  return "", 204
657
728
  ```
658
729
 
@@ -660,9 +731,10 @@ In FastAPI or Starlette, pass `await request.body()` and `request.headers`; in
660
731
  Django, `request.body` and `request.headers`. Header names are matched without
661
732
  regard to case, so a plain `dict` works too.
662
733
 
663
- The event is a `ThresholdCrossedEvent` or an `EntitlementReachedEvent`, so
664
- `isinstance` tells them apart. New types can be added: one this release does not
665
- know reads as a plain `WebhookEvent`, so ignore any you do not handle.
734
+ The event is a `ThresholdCrossedEvent`, an `EntitlementReachedEvent` or a
735
+ `CapacityExhaustedEvent`, so `isinstance` tells them apart. New types can be
736
+ added: one this release does not know reads as a plain `WebhookEvent`, so ignore
737
+ any you do not handle.
666
738
  `verify_webhook` is synchronous, and fast enough to call from an async handler.
667
739
 
668
740
  Without the SDK, it is [Standard Webhooks](https://www.standardwebhooks.com):
@@ -682,7 +754,7 @@ included: Meterbase does not follow them.
682
754
 
683
755
  | Attempt | After the one before |
684
756
  | ------- | ------------------------------- |
685
- | 1 | within a minute of the crossing |
757
+ | 1 | within a minute of the event |
686
758
  | 2 | 1 minute |
687
759
  | 3 | 6 minutes |
688
760
  | 4 | 36 minutes |
@@ -751,14 +823,18 @@ SDK would raise for a response, for realistic errors in your own tests.
751
823
  Meterbase(
752
824
  api_key="mb_sk_live_…",
753
825
  base_url="https://api.meterbase.dev",
754
- timeout=35.0, # seconds, per attempt
826
+ timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
755
827
  max_retries=2,
756
828
  http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
757
829
  )
758
830
  ```
759
831
 
760
832
  `timeout` bounds each attempt — connecting, sending, and each wait for the
761
- engine to answer — not the whole call. Pass an `http_client` for proxies,
833
+ engine to answer — not the whole call. **`check` waits 3 s** unless the client
834
+ sets `timeout`: it gates a request someone is waiting on, and answers in
835
+ milliseconds when the engine is well. Decide what an `APITimeoutError` means
836
+ there — failing open or failing closed — rather than leaving the user waiting
837
+ out the 35 s the writes get. Pass an `http_client` for proxies,
762
838
  limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
763
839
  `httpx.AsyncClient` for `AsyncMeterbase`.
764
840
 
@@ -778,11 +854,13 @@ an attempt already on the wire may still land, but it is never replayed, so a
778
854
  cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
779
855
  bound it with `timeout` and `max_retries` instead.
780
856
 
781
- **Retries** apply to `GET`, `DELETE`, `track` and `reserve`, on connection
782
- failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
783
- `Retry-After` is honoured when the engine sends it, up to eight seconds. Every
784
- other `POST` and `PATCH` is never replayed, because a retried create could
785
- produce a second row.
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.
786
864
 
787
865
  `track` and `reserve` are the exception because they carry an idempotency key,
788
866
  and because the SDK fixes that key **before** the retry loop starts: every
@@ -811,6 +889,15 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
811
889
  change that will not run against your code cannot arrive as a patch. The SDK
812
890
  follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
813
891
 
892
+ **0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
893
+ behaviours:
894
+
895
+ - `check` waits 3 s by default. The other calls keep 35 s, and a `timeout` set
896
+ on the client, or through `with_options`, still wins.
897
+ - `retrieve_by_external_id` raises before sending when the id is missing, empty
898
+ or whitespace. Before, a `None` was dropped from the query, and the engine
899
+ answered with every customer, the newest first, which this returned.
900
+
814
901
  ## Development
815
902
 
816
903
  ```bash
@@ -13,6 +13,7 @@ from typing import Any, TypeVar
13
13
  import httpx
14
14
 
15
15
  from .._http import (
16
+ DEFAULT_TIMEOUT,
16
17
  Body,
17
18
  Query,
18
19
  RequestOptions,
@@ -70,17 +71,21 @@ class AsyncTransport:
70
71
  query: Query | None = None,
71
72
  body: Body | None = None,
72
73
  idempotent: bool = False,
74
+ default_timeout: float = DEFAULT_TIMEOUT,
73
75
  ) -> Any:
74
76
  """
75
77
  `idempotent` replays a call even though its method is not safe. Only a
76
78
  route that carries an idempotency key may set it, and the key has to be
77
79
  in `body` already: the loop re-sends one body, so a key generated per
78
80
  attempt would count the same usage twice.
81
+
82
+ `default_timeout` applies when the options set none.
79
83
  """
80
84
  # Safe methods, plus the routes that carry an idempotency key and say
81
85
  # so. Any other POST or PATCH could create a second row on a replay.
82
86
  retryable = method in ("GET", "DELETE") or idempotent
83
87
 
88
+ timeout = default_timeout if options.timeout is None else options.timeout
84
89
  url = build_url(self._base_url, path, query)
85
90
  content = None if body is None else encode_body(body)
86
91
  headers = request_headers(self._api_key, has_body=content is not None)
@@ -91,7 +96,7 @@ class AsyncTransport:
91
96
  await sleep(backoff(attempt, last_error))
92
97
 
93
98
  try:
94
- response = await self._send(method, url, headers, content, options)
99
+ response = await self._send(method, url, headers, content, timeout)
95
100
  except APIConnectionError as unanswered:
96
101
  # A connection failure proves nothing about whether the server
97
102
  # acted, so it is replayed under the same idempotency rule.
@@ -122,7 +127,7 @@ class AsyncTransport:
122
127
  url: str,
123
128
  headers: dict[str, str],
124
129
  content: bytes | None,
125
- options: RequestOptions,
130
+ timeout: float,
126
131
  ) -> httpx.Response:
127
132
  # Neither this nor the loop catches a BaseException — a cancelled task,
128
133
  # a Ctrl-C — so one ends the call where it stands and nothing is sent
@@ -134,12 +139,10 @@ class AsyncTransport:
134
139
  url,
135
140
  headers=headers,
136
141
  content=content,
137
- timeout=options.timeout,
142
+ timeout=timeout,
138
143
  )
139
144
  except httpx.TimeoutException as error:
140
- raise APITimeoutError(
141
- f"Request timed out after {options.timeout:g}s."
142
- ) from error
145
+ raise APITimeoutError(f"Request timed out after {timeout:g}s.") from error
143
146
  except httpx.TransportError as error:
144
147
  raise APIConnectionError(f"Could not reach {self._origin}.") from error
145
148
 
@@ -169,6 +172,7 @@ class AsyncRequester:
169
172
  query: Query | None = None,
170
173
  body: Body | None = None,
171
174
  idempotent: bool = False,
175
+ default_timeout: float = DEFAULT_TIMEOUT,
172
176
  ) -> _T:
173
177
  data = await self.transport.request(
174
178
  method,
@@ -177,5 +181,6 @@ class AsyncRequester:
177
181
  query=query,
178
182
  body=body,
179
183
  idempotent=idempotent,
184
+ default_timeout=default_timeout,
180
185
  )
181
186
  return construct(cast_to, data)
@@ -8,8 +8,8 @@ from typing import Any, TypeVar
8
8
  import httpx
9
9
 
10
10
  from .._http import (
11
+ CHECK_TIMEOUT,
11
12
  DEFAULT_MAX_RETRIES,
12
- DEFAULT_TIMEOUT,
13
13
  RequestOptions,
14
14
  normalize_base_url,
15
15
  )
@@ -34,9 +34,9 @@ class AsyncMeterbase:
34
34
  there is no workspace to pass.
35
35
 
36
36
  `timeout` is in seconds and applies to each attempt rather than to the
37
- whole call. `max_retries` counts the attempts after the first. Pass an
38
- `http_client` to configure proxies, limits or transports yourself; it
39
- stays yours to close.
37
+ whole call. Unset, it is 35 s, and 3 s for `check`. `max_retries` counts
38
+ the attempts after the first. Pass an `http_client` to configure proxies,
39
+ limits or transports yourself; it stays yours to close.
40
40
  """
41
41
 
42
42
  customers: AsyncCustomers
@@ -48,7 +48,7 @@ class AsyncMeterbase:
48
48
  *,
49
49
  api_key: str,
50
50
  base_url: str | None = None,
51
- timeout: float = DEFAULT_TIMEOUT,
51
+ timeout: float | None = None,
52
52
  max_retries: int = DEFAULT_MAX_RETRIES,
53
53
  http_client: httpx.AsyncClient | None = None,
54
54
  ) -> None:
@@ -121,6 +121,11 @@ class AsyncMeterbase:
121
121
 
122
122
  `quantity` is what is about to be consumed, `1 … 100000000000`.
123
123
  Omitted, it is 1, which asks whether there is any capacity at all.
124
+
125
+ Waits 3 s unless the client sets `timeout`: it gates a request someone
126
+ is waiting on, and answers in milliseconds when the engine is well.
127
+ Decide what an `APITimeoutError` means here, failing open or closed,
128
+ rather than waiting out the 35 s the writes are given.
124
129
  """
125
130
  return await self._api.request(
126
131
  "POST",
@@ -131,6 +136,7 @@ class AsyncMeterbase:
131
136
  "meter_id": meter_id,
132
137
  "quantity": quantity,
133
138
  },
139
+ default_timeout=CHECK_TIMEOUT,
134
140
  )
135
141
 
136
142
  async def track(
@@ -246,6 +252,17 @@ class AsyncMeterbase:
246
252
  """Reports which workspace this key acts for."""
247
253
  return await self._api.request("GET", "/v1/whoami", WhoAmI)
248
254
 
255
+ def idempotency_key(self) -> str:
256
+ """
257
+ A UUIDv7 dated by the engine's clock as this client knows it: an
258
+ `idempotency_key` for `track`, or a `reservation_id`. For a retry from
259
+ further out than this SDK, mint one once the work is done, store it
260
+ with the job before the first send, and send it on every retry. The
261
+ engine admits a key for an hour from its date, and a reservation id
262
+ for 30 minutes.
263
+ """
264
+ return mint(self._api.transport.now_ms())
265
+
249
266
  async def aclose(self) -> None:
250
267
  """Closes the connections this client opened. An `http_client` passed
251
268
  in stays open: it is yours."""
@@ -3,12 +3,13 @@ from __future__ import annotations
3
3
  from collections.abc import Mapping, Sequence
4
4
  from typing import Any, overload
5
5
 
6
- from ..._http import segment
6
+ from ..._http import require_external_id, segment
7
7
  from ..._models import ListResult, list_of
8
8
  from ..._not_given import NOT_GIVEN, NotGiven
9
9
  from ...errors import NotFoundError
10
10
  from ...types import (
11
11
  Customer,
12
+ CustomerEnsureResult,
12
13
  CustomerEntitlement,
13
14
  CustomerUsage,
14
15
  CustomerWithPlan,
@@ -59,6 +60,50 @@ class AsyncCustomers:
59
60
  },
60
61
  )
61
62
 
63
+ async def ensure(
64
+ self,
65
+ *,
66
+ external_id: str,
67
+ name: str | NotGiven = NOT_GIVEN,
68
+ metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
69
+ plan: str | NotGiven = NOT_GIVEN,
70
+ cycle_anchor: str | NotGiven = NOT_GIVEN,
71
+ mint_one_time: bool | NotGiven = NOT_GIVEN,
72
+ ) -> CustomerEnsureResult:
73
+ """
74
+ Gets or creates the customer by `external_id`, and gives one who holds
75
+ no plan the `plan` named, in one transaction. Safe on every sign-up and
76
+ safe to retry: rerunning it changes nothing, and it never changes a
77
+ plan the customer already holds. `plan_result` says which of those
78
+ happened.
79
+
80
+ `name` and `metadata` apply only to a customer this creates. `plan` is
81
+ a plan key; omit it to leave the plan alone. `cycle_anchor`, RFC 3339,
82
+ only with `plan` and never in the future, is used only when this call
83
+ assigns the plan. `mint_one_time=False` writes the plan's one-time
84
+ grants already spent, for an imported customer whose remaining bonus
85
+ you grant yourself.
86
+
87
+ Raises `MeterbaseError` before sending when `external_id` is missing,
88
+ empty or whitespace.
89
+ """
90
+ require_external_id("ensure", external_id)
91
+
92
+ return await self._api.request(
93
+ "POST",
94
+ "/v1/customers/ensure",
95
+ CustomerEnsureResult,
96
+ body={
97
+ "external_id": external_id,
98
+ "name": name,
99
+ "metadata": metadata,
100
+ "plan": plan,
101
+ "cycle_anchor": cycle_anchor,
102
+ "mint_one_time": mint_one_time,
103
+ },
104
+ idempotent=True,
105
+ )
106
+
62
107
  async def list(
63
108
  self, *, include_deleted: bool | NotGiven = NOT_GIVEN
64
109
  ) -> ListResult[Customer]:
@@ -88,7 +133,12 @@ class AsyncCustomers:
88
133
  Resolves the tenant's own identifier, or `None`, embedding the plan the
89
134
  way `retrieve` does. The engine answers this as a filtered list, so a
90
135
  miss is an empty collection rather than a 404.
136
+
137
+ Raises `MeterbaseError` before sending when `external_id` is missing,
138
+ empty or whitespace.
91
139
  """
140
+ require_external_id("retrieve_by_external_id", external_id)
141
+
92
142
  found = await self._api.request(
93
143
  "GET",
94
144
  "/v1/customers",
@@ -24,6 +24,7 @@ from .errors import APIError, MeterbaseError, error_from_response
24
24
 
25
25
  DEFAULT_BASE_URL = "https://api.meterbase.tech"
26
26
  DEFAULT_TIMEOUT = 35.0
27
+ CHECK_TIMEOUT = 3.0
27
28
  DEFAULT_MAX_RETRIES = 2
28
29
  MAX_BACKOFF = 8.0
29
30
 
@@ -35,8 +36,9 @@ Body = Mapping[str, object]
35
36
 
36
37
  @dataclass(frozen=True)
37
38
  class RequestOptions:
38
- timeout: float
39
- """Seconds, per attempt rather than per call."""
39
+ timeout: float | None
40
+ """Seconds, per attempt rather than per call. `None` leaves each call its
41
+ own default."""
40
42
  max_retries: int
41
43
 
42
44
  def __post_init__(self) -> None:
@@ -44,7 +46,7 @@ class RequestOptions:
44
46
  # makes no attempt has nothing to answer with.
45
47
  if self.max_retries < 0:
46
48
  raise MeterbaseError("max_retries should be 0 or more.")
47
- if not self.timeout > 0:
49
+ if self.timeout is not None and not self.timeout > 0:
48
50
  raise MeterbaseError("timeout should be a number of seconds above 0.")
49
51
 
50
52
 
@@ -73,6 +75,17 @@ def segment(value: str) -> str:
73
75
  return quote(value, safe="")
74
76
 
75
77
 
78
+ def require_external_id(method: str, external_id: object) -> None:
79
+ """
80
+ Without an id the lookup becomes an unfiltered list, which older engines
81
+ answered newest first: the caller got another customer back.
82
+ """
83
+ if external_id is None or str(external_id).strip() == "":
84
+ raise MeterbaseError(
85
+ f"customers.{method} needs an external id, and got {external_id!r}."
86
+ )
87
+
88
+
76
89
  def build_url(base_url: str, path: str, query: Query | None) -> str:
77
90
  pairs = [
78
91
  (key, _query_value(value))
@@ -17,6 +17,7 @@ from typing import Any, TypeVar
17
17
  import httpx
18
18
 
19
19
  from .._http import (
20
+ DEFAULT_TIMEOUT,
20
21
  Body,
21
22
  Query,
22
23
  RequestOptions,
@@ -74,17 +75,21 @@ class Transport:
74
75
  query: Query | None = None,
75
76
  body: Body | None = None,
76
77
  idempotent: bool = False,
78
+ default_timeout: float = DEFAULT_TIMEOUT,
77
79
  ) -> Any:
78
80
  """
79
81
  `idempotent` replays a call even though its method is not safe. Only a
80
82
  route that carries an idempotency key may set it, and the key has to be
81
83
  in `body` already: the loop re-sends one body, so a key generated per
82
84
  attempt would count the same usage twice.
85
+
86
+ `default_timeout` applies when the options set none.
83
87
  """
84
88
  # Safe methods, plus the routes that carry an idempotency key and say
85
89
  # so. Any other POST or PATCH could create a second row on a replay.
86
90
  retryable = method in ("GET", "DELETE") or idempotent
87
91
 
92
+ timeout = default_timeout if options.timeout is None else options.timeout
88
93
  url = build_url(self._base_url, path, query)
89
94
  content = None if body is None else encode_body(body)
90
95
  headers = request_headers(self._api_key, has_body=content is not None)
@@ -95,7 +100,7 @@ class Transport:
95
100
  sleep(backoff(attempt, last_error))
96
101
 
97
102
  try:
98
- response = self._send(method, url, headers, content, options)
103
+ response = self._send(method, url, headers, content, timeout)
99
104
  except APIConnectionError as unanswered:
100
105
  # A connection failure proves nothing about whether the server
101
106
  # acted, so it is replayed under the same idempotency rule.
@@ -126,7 +131,7 @@ class Transport:
126
131
  url: str,
127
132
  headers: dict[str, str],
128
133
  content: bytes | None,
129
- options: RequestOptions,
134
+ timeout: float,
130
135
  ) -> httpx.Response:
131
136
  # Neither this nor the loop catches a BaseException — a cancelled task,
132
137
  # a Ctrl-C — so one ends the call where it stands and nothing is sent
@@ -138,12 +143,10 @@ class Transport:
138
143
  url,
139
144
  headers=headers,
140
145
  content=content,
141
- timeout=options.timeout,
146
+ timeout=timeout,
142
147
  )
143
148
  except httpx.TimeoutException as error:
144
- raise APITimeoutError(
145
- f"Request timed out after {options.timeout:g}s."
146
- ) from error
149
+ raise APITimeoutError(f"Request timed out after {timeout:g}s.") from error
147
150
  except httpx.TransportError as error:
148
151
  raise APIConnectionError(f"Could not reach {self._origin}.") from error
149
152
 
@@ -173,6 +176,7 @@ class Requester:
173
176
  query: Query | None = None,
174
177
  body: Body | None = None,
175
178
  idempotent: bool = False,
179
+ default_timeout: float = DEFAULT_TIMEOUT,
176
180
  ) -> _T:
177
181
  data = self.transport.request(
178
182
  method,
@@ -181,5 +185,6 @@ class Requester:
181
185
  query=query,
182
186
  body=body,
183
187
  idempotent=idempotent,
188
+ default_timeout=default_timeout,
184
189
  )
185
190
  return construct(cast_to, data)
@@ -12,8 +12,8 @@ from typing import Any, TypeVar
12
12
  import httpx
13
13
 
14
14
  from .._http import (
15
+ CHECK_TIMEOUT,
15
16
  DEFAULT_MAX_RETRIES,
16
- DEFAULT_TIMEOUT,
17
17
  RequestOptions,
18
18
  normalize_base_url,
19
19
  )
@@ -38,9 +38,9 @@ class Meterbase:
38
38
  there is no workspace to pass.
39
39
 
40
40
  `timeout` is in seconds and applies to each attempt rather than to the
41
- whole call. `max_retries` counts the attempts after the first. Pass an
42
- `http_client` to configure proxies, limits or transports yourself; it
43
- stays yours to close.
41
+ whole call. Unset, it is 35 s, and 3 s for `check`. `max_retries` counts
42
+ the attempts after the first. Pass an `http_client` to configure proxies,
43
+ limits or transports yourself; it stays yours to close.
44
44
  """
45
45
 
46
46
  customers: Customers
@@ -52,7 +52,7 @@ class Meterbase:
52
52
  *,
53
53
  api_key: str,
54
54
  base_url: str | None = None,
55
- timeout: float = DEFAULT_TIMEOUT,
55
+ timeout: float | None = None,
56
56
  max_retries: int = DEFAULT_MAX_RETRIES,
57
57
  http_client: httpx.Client | None = None,
58
58
  ) -> None:
@@ -125,6 +125,11 @@ class Meterbase:
125
125
 
126
126
  `quantity` is what is about to be consumed, `1 … 100000000000`.
127
127
  Omitted, it is 1, which asks whether there is any capacity at all.
128
+
129
+ Waits 3 s unless the client sets `timeout`: it gates a request someone
130
+ is waiting on, and answers in milliseconds when the engine is well.
131
+ Decide what an `APITimeoutError` means here, failing open or closed,
132
+ rather than waiting out the 35 s the writes are given.
128
133
  """
129
134
  return self._api.request(
130
135
  "POST",
@@ -135,6 +140,7 @@ class Meterbase:
135
140
  "meter_id": meter_id,
136
141
  "quantity": quantity,
137
142
  },
143
+ default_timeout=CHECK_TIMEOUT,
138
144
  )
139
145
 
140
146
  def track(
@@ -250,6 +256,17 @@ class Meterbase:
250
256
  """Reports which workspace this key acts for."""
251
257
  return self._api.request("GET", "/v1/whoami", WhoAmI)
252
258
 
259
+ def idempotency_key(self) -> str:
260
+ """
261
+ A UUIDv7 dated by the engine's clock as this client knows it: an
262
+ `idempotency_key` for `track`, or a `reservation_id`. For a retry from
263
+ further out than this SDK, mint one once the work is done, store it
264
+ with the job before the first send, and send it on every retry. The
265
+ engine admits a key for an hour from its date, and a reservation id
266
+ for 30 minutes.
267
+ """
268
+ return mint(self._api.transport.now_ms())
269
+
253
270
  def close(self) -> None:
254
271
  """Closes the connections this client opened. An `http_client` passed
255
272
  in stays open: it is yours."""
@@ -7,12 +7,13 @@ from __future__ import annotations
7
7
  from collections.abc import Mapping, Sequence
8
8
  from typing import Any, overload
9
9
 
10
- from ..._http import segment
10
+ from ..._http import require_external_id, segment
11
11
  from ..._models import ListResult, list_of
12
12
  from ..._not_given import NOT_GIVEN, NotGiven
13
13
  from ...errors import NotFoundError
14
14
  from ...types import (
15
15
  Customer,
16
+ CustomerEnsureResult,
16
17
  CustomerEntitlement,
17
18
  CustomerUsage,
18
19
  CustomerWithPlan,
@@ -63,6 +64,50 @@ class Customers:
63
64
  },
64
65
  )
65
66
 
67
+ def ensure(
68
+ self,
69
+ *,
70
+ external_id: str,
71
+ name: str | NotGiven = NOT_GIVEN,
72
+ metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
73
+ plan: str | NotGiven = NOT_GIVEN,
74
+ cycle_anchor: str | NotGiven = NOT_GIVEN,
75
+ mint_one_time: bool | NotGiven = NOT_GIVEN,
76
+ ) -> CustomerEnsureResult:
77
+ """
78
+ Gets or creates the customer by `external_id`, and gives one who holds
79
+ no plan the `plan` named, in one transaction. Safe on every sign-up and
80
+ safe to retry: rerunning it changes nothing, and it never changes a
81
+ plan the customer already holds. `plan_result` says which of those
82
+ happened.
83
+
84
+ `name` and `metadata` apply only to a customer this creates. `plan` is
85
+ a plan key; omit it to leave the plan alone. `cycle_anchor`, RFC 3339,
86
+ only with `plan` and never in the future, is used only when this call
87
+ assigns the plan. `mint_one_time=False` writes the plan's one-time
88
+ grants already spent, for an imported customer whose remaining bonus
89
+ you grant yourself.
90
+
91
+ Raises `MeterbaseError` before sending when `external_id` is missing,
92
+ empty or whitespace.
93
+ """
94
+ require_external_id("ensure", external_id)
95
+
96
+ return self._api.request(
97
+ "POST",
98
+ "/v1/customers/ensure",
99
+ CustomerEnsureResult,
100
+ body={
101
+ "external_id": external_id,
102
+ "name": name,
103
+ "metadata": metadata,
104
+ "plan": plan,
105
+ "cycle_anchor": cycle_anchor,
106
+ "mint_one_time": mint_one_time,
107
+ },
108
+ idempotent=True,
109
+ )
110
+
66
111
  def list(
67
112
  self, *, include_deleted: bool | NotGiven = NOT_GIVEN
68
113
  ) -> ListResult[Customer]:
@@ -90,7 +135,12 @@ class Customers:
90
135
  Resolves the tenant's own identifier, or `None`, embedding the plan the
91
136
  way `retrieve` does. The engine answers this as a filtered list, so a
92
137
  miss is an empty collection rather than a 404.
138
+
139
+ Raises `MeterbaseError` before sending when `external_id` is missing,
140
+ empty or whitespace.
93
141
  """
142
+ require_external_id("retrieve_by_external_id", external_id)
143
+
94
144
  found = self._api.request(
95
145
  "GET",
96
146
  "/v1/customers",
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -269,7 +269,7 @@ def error_from_response(
269
269
 
270
270
  if status == 400:
271
271
  # A parameter that failed validation, as a 422 is a field that did.
272
- if code == "invalid_query":
272
+ if code in ("invalid_query", "invalid_external_id"):
273
273
  return InvalidRequestError(**fields)
274
274
  return APIError(**fields)
275
275
  if status == 401:
@@ -19,17 +19,21 @@ __all__ = [
19
19
  "AllowanceSource",
20
20
  "ApplyMode",
21
21
  "Assignment",
22
+ "CapacityExhaustedEvent",
23
+ "CapacityExhaustion",
22
24
  "CheckRateLimit",
23
25
  "CheckReason",
24
26
  "CheckResult",
25
27
  "ClosedPeriod",
26
28
  "ClosedPeriodMeter",
27
29
  "Customer",
30
+ "CustomerEnsureResult",
28
31
  "CustomerEntitlement",
29
32
  "CustomerUsage",
30
33
  "CustomerWithPlan",
31
34
  "DayUsage",
32
35
  "EmbeddedPlan",
36
+ "EnsurePlanResult",
33
37
  "EntitlementReachedEvent",
34
38
  "EntitlementState",
35
39
  "IncomingVersion",
@@ -138,6 +142,24 @@ class CustomerWithPlan(Customer):
138
142
  pending_plan: EmbeddedPlan | None
139
143
 
140
144
 
145
+ EnsurePlanResult = Literal["assigned", "already_held", "kept_other", "none"]
146
+ """
147
+ What `ensure` did with the plan it named. It never changes a plan: a customer
148
+ holding another one is `kept_other`, and moving them stays `customers.plan`'s
149
+ job. `none` when no plan was named.
150
+ """
151
+
152
+
153
+ class CustomerEnsureResult(Model):
154
+ customer: CustomerWithPlan
155
+ """As the call left it, with the plan embedded as `retrieve` embeds it."""
156
+ created: bool
157
+ plan_result: EnsurePlanResult
158
+ cycle_anchor_differs: bool
159
+ """`cycle_anchor` was sent, and the plan the customer already held runs on
160
+ another anchor. Nothing changed: `customer.plan.anchor_at` is in force."""
161
+
162
+
141
163
  # Check ----------------------------------------------------------------------
142
164
 
143
165
  CheckReason = Literal["entitlement", "rate_limit"] | str
@@ -614,3 +636,28 @@ class EntitlementReachedEvent(WebhookEvent):
614
636
  id."""
615
637
 
616
638
  data: ThresholdCrossing
639
+
640
+
641
+ class CapacityExhaustion(Model):
642
+ """What `capacity.exhausted` reports. No figures: available is 0."""
643
+
644
+ customer_id: str
645
+ """The tenant's own customer id."""
646
+ meter_id: str
647
+ """The meter's key."""
648
+ plan: ThresholdPlan | None
649
+ """The plan in force when the last unit was spent; `None` when grants were
650
+ the only capacity on the meter."""
651
+ period: Period | None
652
+ """That plan's period, so `end` is when it refills; `None` when grants
653
+ were the only capacity on the meter."""
654
+ usage_event_id: str
655
+ """The track that spent the last unit."""
656
+
657
+
658
+ class CapacityExhaustedEvent(WebhookEvent):
659
+ """`capacity.exhausted`: sent when a track spends the last unit a hard cap
660
+ allows, so `check` refuses from then on. Once each time capacity runs
661
+ out."""
662
+
663
+ data: CapacityExhaustion
@@ -12,7 +12,12 @@ from typing import Any, Protocol, cast
12
12
 
13
13
  from ._json import is_object
14
14
  from .errors import MeterbaseError, WebhookVerificationError
15
- from .types import EntitlementReachedEvent, ThresholdCrossedEvent, WebhookEvent
15
+ from .types import (
16
+ CapacityExhaustedEvent,
17
+ EntitlementReachedEvent,
18
+ ThresholdCrossedEvent,
19
+ WebhookEvent,
20
+ )
16
21
 
17
22
  __all__ = ["WebhookHeaders", "verify_webhook"]
18
23
 
@@ -22,6 +27,7 @@ DEFAULT_TOLERANCE_SECONDS = 5 * 60
22
27
  _EVENTS: dict[str, type[WebhookEvent]] = {
23
28
  "threshold.crossed": ThresholdCrossedEvent,
24
29
  "entitlement.reached": EntitlementReachedEvent,
30
+ "capacity.exhausted": CapacityExhaustedEvent,
25
31
  }
26
32
 
27
33
 
@@ -54,10 +60,10 @@ def verify_webhook(
54
60
  rotating. `tolerance_seconds` is how far `webhook-timestamp` may be from
55
61
  now.
56
62
 
57
- The event is a `ThresholdCrossedEvent` or an `EntitlementReachedEvent`, so
58
- `isinstance` tells them apart. A type this release does not know reads as
59
- a plain `WebhookEvent`: new types can be added, so ignore any you do not
60
- handle.
63
+ The event is a `ThresholdCrossedEvent`, an `EntitlementReachedEvent` or a
64
+ `CapacityExhaustedEvent`, so `isinstance` tells them apart. A type this
65
+ release does not know reads as a plain `WebhookEvent`: new types can be
66
+ added, so ignore any you do not handle.
61
67
  """
62
68
  msg_id = _header(headers, "webhook-id")
63
69
  timestamp = _header(headers, "webhook-timestamp")
@@ -1 +0,0 @@
1
- __version__ = "0.1.4"
File without changes
File without changes
File without changes