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.
Files changed (41) hide show
  1. {meterbase-0.1.5 → meterbase-0.3.0}/PKG-INFO +147 -11
  2. {meterbase-0.1.5 → meterbase-0.3.0}/README.md +146 -10
  3. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/__init__.py +2 -0
  4. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/_transport.py +11 -6
  5. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/client.py +22 -5
  6. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_allowances.py +37 -10
  7. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_plan.py +59 -15
  8. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/customers.py +51 -1
  9. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/meters.py +11 -5
  10. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/plans.py +22 -13
  11. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_http.py +28 -3
  12. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/_transport.py +11 -6
  13. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/client.py +22 -5
  14. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_allowances.py +37 -10
  15. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_plan.py +59 -15
  16. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/customers.py +51 -1
  17. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/meters.py +11 -5
  18. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/plans.py +22 -13
  19. meterbase-0.3.0/src/meterbase/_version.py +1 -0
  20. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/errors.py +13 -1
  21. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/types.py +48 -0
  22. meterbase-0.1.5/src/meterbase/_version.py +0 -1
  23. {meterbase-0.1.5 → meterbase-0.3.0}/.gitignore +0 -0
  24. {meterbase-0.1.5 → meterbase-0.3.0}/LICENSE +0 -0
  25. {meterbase-0.1.5 → meterbase-0.3.0}/pyproject.toml +0 -0
  26. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/__init__.py +0 -0
  27. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/_io.py +0 -0
  28. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/__init__.py +0 -0
  29. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
  30. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_json.py +0 -0
  31. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_keys.py +0 -0
  32. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_models.py +0 -0
  33. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_not_given.py +0 -0
  34. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/__init__.py +0 -0
  35. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/_io.py +0 -0
  36. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/__init__.py +0 -0
  37. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
  38. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/_time.py +0 -0
  39. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/py.typed +0 -0
  40. {meterbase-0.1.5 → meterbase-0.3.0}/src/meterbase/resources.py +0 -0
  41. {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.1.5
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** — including its own, since `track` is a `POST` it will replay.
189
- Supply your own when the caller already has an id for the work — a job id, a
190
- request id — so that a retry from further out than this SDK replays too. Keys
191
- are retained 35 days; a retry after that records a second event, which is not a
192
- case this API sets out to serve.
188
+ retries.** For a retry from further out than this SDK — a queue, a restarted
189
+ worker — mint the key once the work is done, and store it with the job before
190
+ the first send:
191
+
192
+ ```python
193
+ if job.usage_key is None:
194
+ job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
195
+ save_job(job) # before sending, so a rerun reuses it
196
+
197
+ meterbase.track(
198
+ customer_id=job.customer_id,
199
+ meter_id="ai_tokens",
200
+ quantity=job.tokens,
201
+ idempotency_key=job.usage_key,
202
+ )
203
+ ```
204
+
205
+ It is dated by the engine's clock as this client knows it, and the hour it is
206
+ admitted for starts then: mint it when the work is queued and a slow queue can
207
+ outlast it.
193
208
 
194
209
  ### Work you cannot do twice
195
210
 
@@ -256,6 +271,30 @@ afresh rather than finding anything.
256
271
 
257
272
  ### Customers
258
273
 
274
+ **At sign-up, ensure the customer.** One call gets or creates them by your own
275
+ id and gives them a first plan, and running it again changes nothing:
276
+
277
+ ```python
278
+ ensured = meterbase.customers.ensure(
279
+ external_id="acct_1",
280
+ name="Acme", # applied only when it creates the customer
281
+ plan="free", # a plan key
282
+ )
283
+ ensured.created, ensured.plan_result, ensured.customer.plan
284
+ ```
285
+
286
+ `plan_result` says what it did with the plan: `assigned`, `already_held`,
287
+ `kept_other` when the customer holds another plan, or `none` when you named
288
+ none. It never changes a plan the customer holds — a checkout that must move
289
+ them calls [`customers.plan`](#a-customers-plan) — and never cancels a
290
+ scheduled change. `cycle_anchor` puts a first plan's periods on a date the
291
+ customer already has, and `mint_one_time=False` writes the plan's one-time
292
+ grants already spent, for a customer you import with their remaining bonus.
293
+
294
+ `ensure` and `retrieve_by_external_id` raise `MeterbaseError` before sending
295
+ when the id is missing, empty or whitespace: without one, the lookup would name
296
+ no customer.
297
+
259
298
  ```python
260
299
  customer = meterbase.customers.create(
261
300
  external_id="acct_1",
@@ -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=35.0, # seconds, per attempt
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. Pass an `http_client` for proxies,
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` and `reserve`, on connection
842
- failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
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** — including its own, since `track` is a `POST` it will replay.
162
- Supply your own when the caller already has an id for the work — a job id, a
163
- request id — so that a retry from further out than this SDK replays too. Keys
164
- are retained 35 days; a retry after that records a second event, which is not a
165
- case this API sets out to serve.
161
+ retries.** For a retry from further out than this SDK — a queue, a restarted
162
+ worker — mint the key once the work is done, and store it with the job before
163
+ the first send:
164
+
165
+ ```python
166
+ if job.usage_key is None:
167
+ job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
168
+ save_job(job) # before sending, so a rerun reuses it
169
+
170
+ meterbase.track(
171
+ customer_id=job.customer_id,
172
+ meter_id="ai_tokens",
173
+ quantity=job.tokens,
174
+ idempotency_key=job.usage_key,
175
+ )
176
+ ```
177
+
178
+ It is dated by the engine's clock as this client knows it, and the hour it is
179
+ admitted for starts then: mint it when the work is queued and a slow queue can
180
+ outlast it.
166
181
 
167
182
  ### Work you cannot do twice
168
183
 
@@ -229,6 +244,30 @@ afresh rather than finding anything.
229
244
 
230
245
  ### Customers
231
246
 
247
+ **At sign-up, ensure the customer.** One call gets or creates them by your own
248
+ id and gives them a first plan, and running it again changes nothing:
249
+
250
+ ```python
251
+ ensured = meterbase.customers.ensure(
252
+ external_id="acct_1",
253
+ name="Acme", # applied only when it creates the customer
254
+ plan="free", # a plan key
255
+ )
256
+ ensured.created, ensured.plan_result, ensured.customer.plan
257
+ ```
258
+
259
+ `plan_result` says what it did with the plan: `assigned`, `already_held`,
260
+ `kept_other` when the customer holds another plan, or `none` when you named
261
+ none. It never changes a plan the customer holds — a checkout that must move
262
+ them calls [`customers.plan`](#a-customers-plan) — and never cancels a
263
+ scheduled change. `cycle_anchor` puts a first plan's periods on a date the
264
+ customer already has, and `mint_one_time=False` writes the plan's one-time
265
+ grants already spent, for a customer you import with their remaining bonus.
266
+
267
+ `ensure` and `retrieve_by_external_id` raise `MeterbaseError` before sending
268
+ when the id is missing, empty or whitespace: without one, the lookup would name
269
+ no customer.
270
+
232
271
  ```python
233
272
  customer = meterbase.customers.create(
234
273
  external_id="acct_1",
@@ -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=35.0, # seconds, per attempt
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. Pass an `http_client` for proxies,
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` and `reserve`, on connection
815
- failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
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, options)
99
+ response = await self._send(method, url, headers, content, timeout)
95
100
  except APIConnectionError as unanswered:
96
101
  # A connection failure proves nothing about whether the server
97
102
  # acted, so it is replayed under the same idempotency rule.
@@ -122,7 +127,7 @@ class AsyncTransport:
122
127
  url: str,
123
128
  headers: dict[str, str],
124
129
  content: bytes | None,
125
- options: RequestOptions,
130
+ timeout: float,
126
131
  ) -> httpx.Response:
127
132
  # Neither this nor the loop catches a BaseException — a cancelled task,
128
133
  # a Ctrl-C — so one ends the call where it stands and nothing is sent
@@ -134,12 +139,10 @@ class AsyncTransport:
134
139
  url,
135
140
  headers=headers,
136
141
  content=content,
137
- timeout=options.timeout,
142
+ timeout=timeout,
138
143
  )
139
144
  except httpx.TimeoutException as error:
140
- raise APITimeoutError(
141
- f"Request timed out after {options.timeout:g}s."
142
- ) from error
145
+ raise APITimeoutError(f"Request timed out after {timeout:g}s.") from error
143
146
  except httpx.TransportError as error:
144
147
  raise APIConnectionError(f"Could not reach {self._origin}.") from error
145
148
 
@@ -169,6 +172,7 @@ class AsyncRequester:
169
172
  query: Query | None = None,
170
173
  body: Body | None = None,
171
174
  idempotent: bool = False,
175
+ default_timeout: float = DEFAULT_TIMEOUT,
172
176
  ) -> _T:
173
177
  data = await self.transport.request(
174
178
  method,
@@ -177,5 +181,6 @@ class AsyncRequester:
177
181
  query=query,
178
182
  body=body,
179
183
  idempotent=idempotent,
184
+ default_timeout=default_timeout,
180
185
  )
181
186
  return construct(cast_to, data)
@@ -8,8 +8,8 @@ from typing import Any, TypeVar
8
8
  import httpx
9
9
 
10
10
  from .._http import (
11
+ CHECK_TIMEOUT,
11
12
  DEFAULT_MAX_RETRIES,
12
- DEFAULT_TIMEOUT,
13
13
  RequestOptions,
14
14
  normalize_base_url,
15
15
  )
@@ -34,9 +34,9 @@ class AsyncMeterbase:
34
34
  there is no workspace to pass.
35
35
 
36
36
  `timeout` is in seconds and applies to each attempt rather than to the
37
- whole call. `max_retries` counts the attempts after the first. Pass an
38
- `http_client` to configure proxies, limits or transports yourself; it
39
- stays yours to close.
37
+ whole call. Unset, it is 35 s, and 3 s for `check`. `max_retries` counts
38
+ the attempts after the first. Pass an `http_client` to configure proxies,
39
+ limits or transports yourself; it stays yours to close.
40
40
  """
41
41
 
42
42
  customers: AsyncCustomers
@@ -48,7 +48,7 @@ class AsyncMeterbase:
48
48
  *,
49
49
  api_key: str,
50
50
  base_url: str | None = None,
51
- timeout: float = DEFAULT_TIMEOUT,
51
+ timeout: float | None = None,
52
52
  max_retries: int = DEFAULT_MAX_RETRIES,
53
53
  http_client: httpx.AsyncClient | None = None,
54
54
  ) -> None:
@@ -121,6 +121,11 @@ class AsyncMeterbase:
121
121
 
122
122
  `quantity` is what is about to be consumed, `1 … 100000000000`.
123
123
  Omitted, it is 1, which asks whether there is any capacity at all.
124
+
125
+ Waits 3 s unless the client sets `timeout`: it gates a request someone
126
+ is waiting on, and answers in milliseconds when the engine is well.
127
+ Decide what an `APITimeoutError` means here, failing open or closed,
128
+ rather than waiting out the 35 s the writes are given.
124
129
  """
125
130
  return await self._api.request(
126
131
  "POST",
@@ -131,6 +136,7 @@ class AsyncMeterbase:
131
136
  "meter_id": meter_id,
132
137
  "quantity": quantity,
133
138
  },
139
+ default_timeout=CHECK_TIMEOUT,
134
140
  )
135
141
 
136
142
  async def track(
@@ -246,6 +252,17 @@ class AsyncMeterbase:
246
252
  """Reports which workspace this key acts for."""
247
253
  return await self._api.request("GET", "/v1/whoami", WhoAmI)
248
254
 
255
+ def idempotency_key(self) -> str:
256
+ """
257
+ A UUIDv7 dated by the engine's clock as this client knows it: an
258
+ `idempotency_key` for `track`, or a `reservation_id`. For a retry from
259
+ further out than this SDK, mint one once the work is done, store it
260
+ with the job before the first send, and send it on every retry. The
261
+ engine admits a key for an hour from its date, and a reservation id
262
+ for 30 minutes.
263
+ """
264
+ return mint(self._api.transport.now_ms())
265
+
249
266
  async def aclose(self) -> None:
250
267
  """Closes the connections this client opened. An `http_client` passed
251
268
  in stays open: it is yours."""