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.
- {meterbase-0.1.4 → meterbase-0.2.0}/PKG-INFO +108 -21
- {meterbase-0.1.4 → meterbase-0.2.0}/README.md +107 -20
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/_transport.py +11 -6
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/client.py +22 -5
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/customers.py +51 -1
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_http.py +16 -3
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/_transport.py +11 -6
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/client.py +22 -5
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/customers.py +51 -1
- meterbase-0.2.0/src/meterbase/_version.py +1 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/errors.py +1 -1
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/types.py +47 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/webhooks.py +11 -5
- meterbase-0.1.4/src/meterbase/_version.py +0 -1
- {meterbase-0.1.4 → meterbase-0.2.0}/.gitignore +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/LICENSE +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/pyproject.toml +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/__init__.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/__init__.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/_io.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/__init__.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_allowances.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_plan.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/meters.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_async/resources/plans.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_json.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_keys.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_models.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_not_given.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/__init__.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/_io.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/__init__.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_allowances.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_plan.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/meters.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_sync/resources/plans.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/_time.py +0 -0
- {meterbase-0.1.4 → meterbase-0.2.0}/src/meterbase/py.typed +0 -0
- {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.
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
623
|
-
secret, shown once when you add it
|
|
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
|
|
691
|
-
`isinstance` tells them apart. New types can be
|
|
692
|
-
know reads as a plain `WebhookEvent`, so ignore
|
|
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
|
|
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=
|
|
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.
|
|
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
|
|
809
|
-
failures, timeouts, 408, 429 and 5xx, with
|
|
810
|
-
`Retry-After` is honoured when the engine
|
|
811
|
-
other `POST` and `PATCH` is never
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
596
|
-
secret, shown once when you add it
|
|
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
|
|
664
|
-
`isinstance` tells them apart. New types can be
|
|
665
|
-
know reads as a plain `WebhookEvent`, so ignore
|
|
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
|
|
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=
|
|
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.
|
|
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
|
|
782
|
-
failures, timeouts, 408, 429 and 5xx, with
|
|
783
|
-
`Retry-After` is honoured when the engine
|
|
784
|
-
other `POST` and `PATCH` is never
|
|
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,
|
|
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
|
-
|
|
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=
|
|
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.
|
|
38
|
-
`http_client` to configure proxies,
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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=
|
|
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.
|
|
42
|
-
`http_client` to configure proxies,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
58
|
-
`isinstance` tells them apart. A type this
|
|
59
|
-
a plain `WebhookEvent`: new types can be
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|