meterbase 0.1.5__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meterbase-0.1.5 → meterbase-0.3.0}/PKG-INFO +147 -11
- {meterbase-0.1.5 → meterbase-0.3.0}/README.md +146 -10
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/__init__.py +2 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/_transport.py +11 -6
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/client.py +22 -5
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_allowances.py +37 -10
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_plan.py +59 -15
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/customers.py +51 -1
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/meters.py +11 -5
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/plans.py +22 -13
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_http.py +28 -3
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/_transport.py +11 -6
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/client.py +22 -5
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_allowances.py +37 -10
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_plan.py +59 -15
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/customers.py +51 -1
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/meters.py +11 -5
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/plans.py +22 -13
- meterbase-0.3.0/src/meterbase/_version.py +1 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/errors.py +13 -1
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/types.py +48 -0
- meterbase-0.1.5/src/meterbase/_version.py +0 -1
- {meterbase-0.1.5 → meterbase-0.3.0}/.gitignore +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/LICENSE +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/pyproject.toml +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/_io.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_json.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_keys.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_models.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_not_given.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/_io.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_time.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/py.typed +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/resources.py +0 -0
- {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: meterbase
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for the Meterbase engine
|
|
5
5
|
Project-URL: Homepage, https://meterbase.tech
|
|
6
6
|
Project-URL: Documentation, https://meterbase.tech/docs
|
|
@@ -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",
|
|
@@ -324,9 +363,17 @@ Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
|
|
|
324
363
|
```python
|
|
325
364
|
meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
|
|
326
365
|
meters = meterbase.meters.list().data
|
|
366
|
+
meterbase.meters.retrieve("api_calls") # by key, or by id
|
|
327
367
|
meterbase.meters.archive(meter_id) # soft delete, idempotent
|
|
328
368
|
```
|
|
329
369
|
|
|
370
|
+
A meter's key works wherever its id does: `retrieve`, `update`, `usage` and
|
|
371
|
+
`archive`, `meter_id` on a grant or a plan allowance, and the `meter_id` filter
|
|
372
|
+
on their lists. Code written against a sandbox then runs unchanged in live,
|
|
373
|
+
where the ids differ. A key that names no meter raises `NotFoundError`. A
|
|
374
|
+
canonical UUID is always read as an id, so creating or renaming a meter to a
|
|
375
|
+
key shaped like one raises `InvalidRequestError`.
|
|
376
|
+
|
|
330
377
|
### Plans
|
|
331
378
|
|
|
332
379
|
A plan has one billing period, fixed when the plan is created. Everything it
|
|
@@ -348,6 +395,11 @@ meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
|
|
|
348
395
|
customers were already measured against, so a different cadence is a different
|
|
349
396
|
plan — move a customer with `customers.plan.assign`.
|
|
350
397
|
|
|
398
|
+
A plan's key works wherever its id does, on the same terms as a meter's:
|
|
399
|
+
`plans.retrieve("pro")`, `plans.allowances.set("pro", ...)`, `plan_id` on
|
|
400
|
+
`customers.plan.assign` and `plan` on each change. A key that names no plan
|
|
401
|
+
raises `NotFoundError`, and a key shaped like a UUID is refused.
|
|
402
|
+
|
|
351
403
|
### Plan allowances
|
|
352
404
|
|
|
353
405
|
What a plan grants, one meter at a time. An allowance says _how much_, never
|
|
@@ -377,6 +429,12 @@ versions = meterbase.plans.allowances.list(plan.id).data
|
|
|
377
429
|
Each `(plan, meter)` pair carries up to two lineages, `recurring` and
|
|
378
430
|
`one_time`, numbering themselves separately. `kind` defaults to `recurring`.
|
|
379
431
|
|
|
432
|
+
`apply_mode` says when a version reaches customers already on the plan:
|
|
433
|
+
`immediate`, for the whole period they are in, or `next_cycle`, at each one's
|
|
434
|
+
next boundary. Omitted, a meter's first `recurring` version on the plan is
|
|
435
|
+
`immediate`, since there is nothing to phase out, and every later one is
|
|
436
|
+
`next_cycle`. `one_time` is always `immediate`.
|
|
437
|
+
|
|
380
438
|
**A soft cap** keeps the amount and drops the refusal: included usage, then
|
|
381
439
|
overage.
|
|
382
440
|
|
|
@@ -482,6 +540,26 @@ except CycleChangeRequiresResetError:
|
|
|
482
540
|
meterbase.customers.plan.restart(customer.id, plan=weekly.id)
|
|
483
541
|
```
|
|
484
542
|
|
|
543
|
+
#### From a webhook
|
|
544
|
+
|
|
545
|
+
A provider may deliver a webhook twice, and a second `restart` would start a
|
|
546
|
+
second fresh period. Pass the event's id as `idempotency_key`, 1–255
|
|
547
|
+
characters, and a retry repeats nothing:
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
moved = meterbase.customers.plan.restart(
|
|
551
|
+
customer.id, plan="pro", idempotency_key=event.id
|
|
552
|
+
)
|
|
553
|
+
moved.replayed # True when this event had already moved them: nothing was written
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
`assign`, `change`, `change_now` and `change_at_next_cycle` take one too. The
|
|
557
|
+
same request under the key again answers with the assignment it made, as it is
|
|
558
|
+
now, and writes nothing — so a second delivery of an old event cannot cancel a
|
|
559
|
+
change scheduled since. Another request under the key raises
|
|
560
|
+
`IdempotencyKeyReusedError`. A key is kept for good, per workspace. With a key,
|
|
561
|
+
the SDK retries the call too.
|
|
562
|
+
|
|
485
563
|
#### Reading where they stand
|
|
486
564
|
|
|
487
565
|
```python
|
|
@@ -513,6 +591,11 @@ instruction, and writes no new row of its own — so this reads the plan in forc
|
|
|
513
591
|
and names it back. It returns the assignment still in force, or `None` for a
|
|
514
592
|
customer holding no plan, who can have nothing pending.
|
|
515
593
|
|
|
594
|
+
It takes no `idempotency_key`: it names whatever plan is in force when it runs,
|
|
595
|
+
so a retry could name a different one and be refused as a different request.
|
|
596
|
+
For a cancel a webhook orders, `assign` the plan the event says they keep, under
|
|
597
|
+
the event's id.
|
|
598
|
+
|
|
516
599
|
#### How much they have used
|
|
517
600
|
|
|
518
601
|
```python
|
|
@@ -616,6 +699,37 @@ Revoking withdraws what is left without erasing what was consumed, and is
|
|
|
616
699
|
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
617
700
|
appear here with `source="plan"`; they cannot be created through `grant`.
|
|
618
701
|
|
|
702
|
+
**A grant made from a payment's webhook** takes the payment's id as its
|
|
703
|
+
`idempotency_key`, 1–255 characters: the provider may deliver the webhook
|
|
704
|
+
twice, and a retry under the key cannot grant twice.
|
|
705
|
+
|
|
706
|
+
```python
|
|
707
|
+
grant = meterbase.customers.allowances.grant(
|
|
708
|
+
customer.id,
|
|
709
|
+
meter_id="ai_tokens",
|
|
710
|
+
amount=500_000,
|
|
711
|
+
source="purchased",
|
|
712
|
+
idempotency_key=payment.id,
|
|
713
|
+
)
|
|
714
|
+
grant.replayed # True when this payment had already granted: nothing was written
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
The same request under the key again answers with that grant as it is now,
|
|
718
|
+
perhaps spent or revoked since, and writes nothing. Another request under the
|
|
719
|
+
key raises `IdempotencyKeyReusedError`, so build the body the same way every
|
|
720
|
+
time: compute `expires_at` from the payment's own time, not from when the
|
|
721
|
+
handler ran. A key is kept for good, per workspace. With a key, the SDK retries
|
|
722
|
+
the call too.
|
|
723
|
+
|
|
724
|
+
To find what a payment granted — to take it back on a refund, say — list by its
|
|
725
|
+
key. Once the grant is revoked or has expired, add `include_closed=True`.
|
|
726
|
+
|
|
727
|
+
```python
|
|
728
|
+
paid = meterbase.customers.allowances.list(customer.id, idempotency_key=payment.id)
|
|
729
|
+
for grant in paid.data: # the grant the payment made, or none
|
|
730
|
+
meterbase.customers.allowances.revoke(customer.id, grant.id)
|
|
731
|
+
```
|
|
732
|
+
|
|
619
733
|
## Webhooks
|
|
620
734
|
|
|
621
735
|
Meterbase posts to your endpoints when a customer crosses a usage threshold,
|
|
@@ -786,6 +900,7 @@ except RateLimitError as error:
|
|
|
786
900
|
| `NotFoundError` | 404 |
|
|
787
901
|
| `ConflictError` | 409 — a key or external id is taken |
|
|
788
902
|
| `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
|
|
903
|
+
| `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant or plan change was already used for a different request. Nothing was written |
|
|
789
904
|
| `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
|
|
790
905
|
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
791
906
|
| `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
|
|
@@ -811,14 +926,18 @@ SDK would raise for a response, for realistic errors in your own tests.
|
|
|
811
926
|
Meterbase(
|
|
812
927
|
api_key="mb_sk_live_…",
|
|
813
928
|
base_url="https://api.meterbase.dev",
|
|
814
|
-
timeout=
|
|
929
|
+
timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
|
|
815
930
|
max_retries=2,
|
|
816
931
|
http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
|
|
817
932
|
)
|
|
818
933
|
```
|
|
819
934
|
|
|
820
935
|
`timeout` bounds each attempt — connecting, sending, and each wait for the
|
|
821
|
-
engine to answer — not the whole call.
|
|
936
|
+
engine to answer — not the whole call. **`check` waits 3 s** unless the client
|
|
937
|
+
sets `timeout`: it gates a request someone is waiting on, and answers in
|
|
938
|
+
milliseconds when the engine is well. Decide what an `APITimeoutError` means
|
|
939
|
+
there — failing open or failing closed — rather than leaving the user waiting
|
|
940
|
+
out the 35 s the writes get. Pass an `http_client` for proxies,
|
|
822
941
|
limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
|
|
823
942
|
`httpx.AsyncClient` for `AsyncMeterbase`.
|
|
824
943
|
|
|
@@ -838,11 +957,15 @@ an attempt already on the wire may still land, but it is never replayed, so a
|
|
|
838
957
|
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
839
958
|
bound it with `timeout` and `max_retries` instead.
|
|
840
959
|
|
|
841
|
-
**Retries** apply to `GET`, `DELETE`, `track
|
|
842
|
-
|
|
960
|
+
**Retries** apply to `GET`, `DELETE`, `track`, `reserve`, `customers.ensure`,
|
|
961
|
+
and a grant or plan change given an `idempotency_key`, on connection failures,
|
|
962
|
+
timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
|
|
843
963
|
`Retry-After` is honoured when the engine sends it, up to eight seconds. Every
|
|
844
964
|
other `POST` and `PATCH` is never replayed, because a retried create could
|
|
845
|
-
produce a second row.
|
|
965
|
+
produce a second row. An `ensure` can be, because running it twice changes
|
|
966
|
+
nothing: one whose first attempt landed answers `created` false. So can a keyed
|
|
967
|
+
grant or plan change, which the engine answers with what the first attempt
|
|
968
|
+
wrote: one whose first attempt landed answers `replayed` true.
|
|
846
969
|
|
|
847
970
|
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
848
971
|
and because the SDK fixes that key **before** the retry loop starts: every
|
|
@@ -871,6 +994,19 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
|
871
994
|
change that will not run against your code cannot arrive as a patch. The SDK
|
|
872
995
|
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
873
996
|
|
|
997
|
+
**0.3.0** adds an `idempotency_key` on grants and plan changes, which also makes
|
|
998
|
+
them safe to retry, `replayed` on their answers, and `IdempotencyKeyReusedError`.
|
|
999
|
+
Nothing in it breaks code written for 0.2.0.
|
|
1000
|
+
|
|
1001
|
+
**0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
|
|
1002
|
+
behaviours:
|
|
1003
|
+
|
|
1004
|
+
- `check` waits 3 s by default. The other calls keep 35 s, and a `timeout` set
|
|
1005
|
+
on the client, or through `with_options`, still wins.
|
|
1006
|
+
- `retrieve_by_external_id` raises before sending when the id is missing, empty
|
|
1007
|
+
or whitespace. Before, a `None` was dropped from the query, and the engine
|
|
1008
|
+
answered with every customer, the newest first, which this returned.
|
|
1009
|
+
|
|
874
1010
|
## Development
|
|
875
1011
|
|
|
876
1012
|
```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",
|
|
@@ -297,9 +336,17 @@ Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
|
|
|
297
336
|
```python
|
|
298
337
|
meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
|
|
299
338
|
meters = meterbase.meters.list().data
|
|
339
|
+
meterbase.meters.retrieve("api_calls") # by key, or by id
|
|
300
340
|
meterbase.meters.archive(meter_id) # soft delete, idempotent
|
|
301
341
|
```
|
|
302
342
|
|
|
343
|
+
A meter's key works wherever its id does: `retrieve`, `update`, `usage` and
|
|
344
|
+
`archive`, `meter_id` on a grant or a plan allowance, and the `meter_id` filter
|
|
345
|
+
on their lists. Code written against a sandbox then runs unchanged in live,
|
|
346
|
+
where the ids differ. A key that names no meter raises `NotFoundError`. A
|
|
347
|
+
canonical UUID is always read as an id, so creating or renaming a meter to a
|
|
348
|
+
key shaped like one raises `InvalidRequestError`.
|
|
349
|
+
|
|
303
350
|
### Plans
|
|
304
351
|
|
|
305
352
|
A plan has one billing period, fixed when the plan is created. Everything it
|
|
@@ -321,6 +368,11 @@ meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
|
|
|
321
368
|
customers were already measured against, so a different cadence is a different
|
|
322
369
|
plan — move a customer with `customers.plan.assign`.
|
|
323
370
|
|
|
371
|
+
A plan's key works wherever its id does, on the same terms as a meter's:
|
|
372
|
+
`plans.retrieve("pro")`, `plans.allowances.set("pro", ...)`, `plan_id` on
|
|
373
|
+
`customers.plan.assign` and `plan` on each change. A key that names no plan
|
|
374
|
+
raises `NotFoundError`, and a key shaped like a UUID is refused.
|
|
375
|
+
|
|
324
376
|
### Plan allowances
|
|
325
377
|
|
|
326
378
|
What a plan grants, one meter at a time. An allowance says _how much_, never
|
|
@@ -350,6 +402,12 @@ versions = meterbase.plans.allowances.list(plan.id).data
|
|
|
350
402
|
Each `(plan, meter)` pair carries up to two lineages, `recurring` and
|
|
351
403
|
`one_time`, numbering themselves separately. `kind` defaults to `recurring`.
|
|
352
404
|
|
|
405
|
+
`apply_mode` says when a version reaches customers already on the plan:
|
|
406
|
+
`immediate`, for the whole period they are in, or `next_cycle`, at each one's
|
|
407
|
+
next boundary. Omitted, a meter's first `recurring` version on the plan is
|
|
408
|
+
`immediate`, since there is nothing to phase out, and every later one is
|
|
409
|
+
`next_cycle`. `one_time` is always `immediate`.
|
|
410
|
+
|
|
353
411
|
**A soft cap** keeps the amount and drops the refusal: included usage, then
|
|
354
412
|
overage.
|
|
355
413
|
|
|
@@ -455,6 +513,26 @@ except CycleChangeRequiresResetError:
|
|
|
455
513
|
meterbase.customers.plan.restart(customer.id, plan=weekly.id)
|
|
456
514
|
```
|
|
457
515
|
|
|
516
|
+
#### From a webhook
|
|
517
|
+
|
|
518
|
+
A provider may deliver a webhook twice, and a second `restart` would start a
|
|
519
|
+
second fresh period. Pass the event's id as `idempotency_key`, 1–255
|
|
520
|
+
characters, and a retry repeats nothing:
|
|
521
|
+
|
|
522
|
+
```python
|
|
523
|
+
moved = meterbase.customers.plan.restart(
|
|
524
|
+
customer.id, plan="pro", idempotency_key=event.id
|
|
525
|
+
)
|
|
526
|
+
moved.replayed # True when this event had already moved them: nothing was written
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
`assign`, `change`, `change_now` and `change_at_next_cycle` take one too. The
|
|
530
|
+
same request under the key again answers with the assignment it made, as it is
|
|
531
|
+
now, and writes nothing — so a second delivery of an old event cannot cancel a
|
|
532
|
+
change scheduled since. Another request under the key raises
|
|
533
|
+
`IdempotencyKeyReusedError`. A key is kept for good, per workspace. With a key,
|
|
534
|
+
the SDK retries the call too.
|
|
535
|
+
|
|
458
536
|
#### Reading where they stand
|
|
459
537
|
|
|
460
538
|
```python
|
|
@@ -486,6 +564,11 @@ instruction, and writes no new row of its own — so this reads the plan in forc
|
|
|
486
564
|
and names it back. It returns the assignment still in force, or `None` for a
|
|
487
565
|
customer holding no plan, who can have nothing pending.
|
|
488
566
|
|
|
567
|
+
It takes no `idempotency_key`: it names whatever plan is in force when it runs,
|
|
568
|
+
so a retry could name a different one and be refused as a different request.
|
|
569
|
+
For a cancel a webhook orders, `assign` the plan the event says they keep, under
|
|
570
|
+
the event's id.
|
|
571
|
+
|
|
489
572
|
#### How much they have used
|
|
490
573
|
|
|
491
574
|
```python
|
|
@@ -589,6 +672,37 @@ Revoking withdraws what is left without erasing what was consumed, and is
|
|
|
589
672
|
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
590
673
|
appear here with `source="plan"`; they cannot be created through `grant`.
|
|
591
674
|
|
|
675
|
+
**A grant made from a payment's webhook** takes the payment's id as its
|
|
676
|
+
`idempotency_key`, 1–255 characters: the provider may deliver the webhook
|
|
677
|
+
twice, and a retry under the key cannot grant twice.
|
|
678
|
+
|
|
679
|
+
```python
|
|
680
|
+
grant = meterbase.customers.allowances.grant(
|
|
681
|
+
customer.id,
|
|
682
|
+
meter_id="ai_tokens",
|
|
683
|
+
amount=500_000,
|
|
684
|
+
source="purchased",
|
|
685
|
+
idempotency_key=payment.id,
|
|
686
|
+
)
|
|
687
|
+
grant.replayed # True when this payment had already granted: nothing was written
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
The same request under the key again answers with that grant as it is now,
|
|
691
|
+
perhaps spent or revoked since, and writes nothing. Another request under the
|
|
692
|
+
key raises `IdempotencyKeyReusedError`, so build the body the same way every
|
|
693
|
+
time: compute `expires_at` from the payment's own time, not from when the
|
|
694
|
+
handler ran. A key is kept for good, per workspace. With a key, the SDK retries
|
|
695
|
+
the call too.
|
|
696
|
+
|
|
697
|
+
To find what a payment granted — to take it back on a refund, say — list by its
|
|
698
|
+
key. Once the grant is revoked or has expired, add `include_closed=True`.
|
|
699
|
+
|
|
700
|
+
```python
|
|
701
|
+
paid = meterbase.customers.allowances.list(customer.id, idempotency_key=payment.id)
|
|
702
|
+
for grant in paid.data: # the grant the payment made, or none
|
|
703
|
+
meterbase.customers.allowances.revoke(customer.id, grant.id)
|
|
704
|
+
```
|
|
705
|
+
|
|
592
706
|
## Webhooks
|
|
593
707
|
|
|
594
708
|
Meterbase posts to your endpoints when a customer crosses a usage threshold,
|
|
@@ -759,6 +873,7 @@ except RateLimitError as error:
|
|
|
759
873
|
| `NotFoundError` | 404 |
|
|
760
874
|
| `ConflictError` | 409 — a key or external id is taken |
|
|
761
875
|
| `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
|
|
876
|
+
| `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant or plan change was already used for a different request. Nothing was written |
|
|
762
877
|
| `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
|
|
763
878
|
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
764
879
|
| `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
|
|
@@ -784,14 +899,18 @@ SDK would raise for a response, for realistic errors in your own tests.
|
|
|
784
899
|
Meterbase(
|
|
785
900
|
api_key="mb_sk_live_…",
|
|
786
901
|
base_url="https://api.meterbase.dev",
|
|
787
|
-
timeout=
|
|
902
|
+
timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
|
|
788
903
|
max_retries=2,
|
|
789
904
|
http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
|
|
790
905
|
)
|
|
791
906
|
```
|
|
792
907
|
|
|
793
908
|
`timeout` bounds each attempt — connecting, sending, and each wait for the
|
|
794
|
-
engine to answer — not the whole call.
|
|
909
|
+
engine to answer — not the whole call. **`check` waits 3 s** unless the client
|
|
910
|
+
sets `timeout`: it gates a request someone is waiting on, and answers in
|
|
911
|
+
milliseconds when the engine is well. Decide what an `APITimeoutError` means
|
|
912
|
+
there — failing open or failing closed — rather than leaving the user waiting
|
|
913
|
+
out the 35 s the writes get. Pass an `http_client` for proxies,
|
|
795
914
|
limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
|
|
796
915
|
`httpx.AsyncClient` for `AsyncMeterbase`.
|
|
797
916
|
|
|
@@ -811,11 +930,15 @@ an attempt already on the wire may still land, but it is never replayed, so a
|
|
|
811
930
|
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
812
931
|
bound it with `timeout` and `max_retries` instead.
|
|
813
932
|
|
|
814
|
-
**Retries** apply to `GET`, `DELETE`, `track
|
|
815
|
-
|
|
933
|
+
**Retries** apply to `GET`, `DELETE`, `track`, `reserve`, `customers.ensure`,
|
|
934
|
+
and a grant or plan change given an `idempotency_key`, on connection failures,
|
|
935
|
+
timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
|
|
816
936
|
`Retry-After` is honoured when the engine sends it, up to eight seconds. Every
|
|
817
937
|
other `POST` and `PATCH` is never replayed, because a retried create could
|
|
818
|
-
produce a second row.
|
|
938
|
+
produce a second row. An `ensure` can be, because running it twice changes
|
|
939
|
+
nothing: one whose first attempt landed answers `created` false. So can a keyed
|
|
940
|
+
grant or plan change, which the engine answers with what the first attempt
|
|
941
|
+
wrote: one whose first attempt landed answers `replayed` true.
|
|
819
942
|
|
|
820
943
|
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
821
944
|
and because the SDK fixes that key **before** the retry loop starts: every
|
|
@@ -844,6 +967,19 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
|
844
967
|
change that will not run against your code cannot arrive as a patch. The SDK
|
|
845
968
|
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
846
969
|
|
|
970
|
+
**0.3.0** adds an `idempotency_key` on grants and plan changes, which also makes
|
|
971
|
+
them safe to retry, `replayed` on their answers, and `IdempotencyKeyReusedError`.
|
|
972
|
+
Nothing in it breaks code written for 0.2.0.
|
|
973
|
+
|
|
974
|
+
**0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
|
|
975
|
+
behaviours:
|
|
976
|
+
|
|
977
|
+
- `check` waits 3 s by default. The other calls keep 35 s, and a `timeout` set
|
|
978
|
+
on the client, or through `with_options`, still wins.
|
|
979
|
+
- `retrieve_by_external_id` raises before sending when the id is missing, empty
|
|
980
|
+
or whitespace. Before, a `None` was dropped from the query, and the engine
|
|
981
|
+
answered with every customer, the newest first, which this returned.
|
|
982
|
+
|
|
847
983
|
## Development
|
|
848
984
|
|
|
849
985
|
```bash
|
|
@@ -12,6 +12,7 @@ from .errors import (
|
|
|
12
12
|
AuthenticationError,
|
|
13
13
|
ConflictError,
|
|
14
14
|
CycleChangeRequiresResetError,
|
|
15
|
+
IdempotencyKeyReusedError,
|
|
15
16
|
InvalidIdempotencyKeyError,
|
|
16
17
|
InvalidRequestError,
|
|
17
18
|
MeterbaseError,
|
|
@@ -38,6 +39,7 @@ __all__ = [
|
|
|
38
39
|
"ConflictError",
|
|
39
40
|
"CycleChangeRequiresResetError",
|
|
40
41
|
"Hold",
|
|
42
|
+
"IdempotencyKeyReusedError",
|
|
41
43
|
"InvalidIdempotencyKeyError",
|
|
42
44
|
"InvalidRequestError",
|
|
43
45
|
"Meterbase",
|
|
@@ -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."""
|