meterbase 0.1.0__py3-none-any.whl
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/__init__.py +59 -0
- meterbase/_async/__init__.py +0 -0
- meterbase/_async/_io.py +22 -0
- meterbase/_async/_transport.py +181 -0
- meterbase/_async/client.py +427 -0
- meterbase/_async/resources/__init__.py +16 -0
- meterbase/_async/resources/customer_allowances.py +89 -0
- meterbase/_async/resources/customer_periods.py +41 -0
- meterbase/_async/resources/customer_plan.py +228 -0
- meterbase/_async/resources/customers.py +176 -0
- meterbase/_async/resources/meters.py +102 -0
- meterbase/_async/resources/plans.py +140 -0
- meterbase/_http.py +205 -0
- meterbase/_json.py +18 -0
- meterbase/_keys.py +39 -0
- meterbase/_models.py +177 -0
- meterbase/_not_given.py +32 -0
- meterbase/_sync/__init__.py +4 -0
- meterbase/_sync/_io.py +21 -0
- meterbase/_sync/_transport.py +185 -0
- meterbase/_sync/client.py +431 -0
- meterbase/_sync/resources/__init__.py +20 -0
- meterbase/_sync/resources/customer_allowances.py +93 -0
- meterbase/_sync/resources/customer_periods.py +45 -0
- meterbase/_sync/resources/customer_plan.py +230 -0
- meterbase/_sync/resources/customers.py +176 -0
- meterbase/_sync/resources/meters.py +106 -0
- meterbase/_sync/resources/plans.py +144 -0
- meterbase/_time.py +49 -0
- meterbase/_version.py +1 -0
- meterbase/errors.py +309 -0
- meterbase/py.typed +0 -0
- meterbase/resources.py +41 -0
- meterbase/types.py +600 -0
- meterbase/webhooks.py +154 -0
- meterbase-0.1.0.dist-info/METADATA +867 -0
- meterbase-0.1.0.dist-info/RECORD +39 -0
- meterbase-0.1.0.dist-info/WHEEL +4 -0
- meterbase-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: meterbase
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Meterbase engine
|
|
5
|
+
Project-URL: Homepage, https://meterbase.tech
|
|
6
|
+
Project-URL: Documentation, https://meterbase.tech/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/usemeterbase/py-sdk
|
|
8
|
+
Author: Meterbase
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: billing,entitlements,meterbase,metering,sdk,usage
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: httpx<1,>=0.27
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# meterbase
|
|
29
|
+
|
|
30
|
+
Python SDK for the [Meterbase](https://github.com/usemeterbase/engine) engine.
|
|
31
|
+
A sync client and an async one with the same calls, for Python 3.10+. Its one
|
|
32
|
+
dependency is [httpx](https://www.python-httpx.org).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install meterbase
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Two calls, in this order: **`check` → do the work → `track`** — or, for work
|
|
43
|
+
that must not be done twice, [`reserve`](#work-you-cannot-do-twice).
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
from meterbase import Meterbase
|
|
49
|
+
|
|
50
|
+
meterbase = Meterbase(api_key=os.environ["METERBASE_API_KEY"])
|
|
51
|
+
|
|
52
|
+
result = meterbase.check(
|
|
53
|
+
customer_id="acct_1", # the tenant's own id, not ours
|
|
54
|
+
meter_id="ai_tokens", # the meter's key, not ours
|
|
55
|
+
quantity=50_000, # what you are about to spend; defaults to 1
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if not result.allowed:
|
|
59
|
+
raise RuntimeError(f"Refused: {result.reason}")
|
|
60
|
+
|
|
61
|
+
# Allowed, and still worth reading: every window comes back on every call.
|
|
62
|
+
tight = next((w for w in result.rate_limits if w.remaining < w.max / 10), None)
|
|
63
|
+
if tight:
|
|
64
|
+
print(f"Slowing down until {tight.resets_at}")
|
|
65
|
+
|
|
66
|
+
completion = generate(prompt)
|
|
67
|
+
|
|
68
|
+
meterbase.track(
|
|
69
|
+
customer_id="acct_1",
|
|
70
|
+
meter_id="ai_tokens",
|
|
71
|
+
quantity=completion.usage.total_tokens,
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
An API key names its own workspace, so there is no workspace to pass. Create
|
|
76
|
+
one client and reuse it: it keeps a pool of connections open, and
|
|
77
|
+
`meterbase.close()` — or a `with` block — closes them.
|
|
78
|
+
|
|
79
|
+
`check` is the gate, and the only call that ever refuses. It never raises for a
|
|
80
|
+
denial — that is an answer, not a failure. Absence of entitlement is not
|
|
81
|
+
permission: a customer with no plan and no grant is denied.
|
|
82
|
+
|
|
83
|
+
`available` is the whole capacity — the plan's unused entitlement for the
|
|
84
|
+
current period plus every open grant — and is `None` under `no_cap`, where
|
|
85
|
+
capacity is not a number. It is one figure and not a breakdown: `check` answers
|
|
86
|
+
from a single cached integer, and itemising where the capacity came from would
|
|
87
|
+
cost it that. For the per-grant detail, read a customer's allowances.
|
|
88
|
+
|
|
89
|
+
A check answers **two gates**, and `allowed` needs both: the capacity has to
|
|
90
|
+
cover the quantity, and every rate limit in force — _at most so much of this
|
|
91
|
+
meter per minute, hour or day_ — has to admit it. `reason` names the one that
|
|
92
|
+
said no, and is `None` when the answer is yes. `rate_limits` reports every
|
|
93
|
+
window whether or not it refused, so a caller can ease off as its headroom
|
|
94
|
+
closes instead of finding the ceiling by hitting it; it is `[]` when no rule
|
|
95
|
+
applies. A limit is protective and not commercial, so it is never folded into
|
|
96
|
+
`available`: a customer well inside their plan can still be limited, and one on
|
|
97
|
+
`no_cap` is precisely the customer a runaway loop costs most.
|
|
98
|
+
|
|
99
|
+
Under a **soft cap** — included usage, then overage; see
|
|
100
|
+
[Plan allowances](#plan-allowances) — the capacity admits any quantity, so only
|
|
101
|
+
a rate limit can refuse. `available` is what is left of the included amount and
|
|
102
|
+
stops at 0, `allow_overage=True` says why a quantity past it was still allowed,
|
|
103
|
+
and the overage in a request is `max(quantity - available, 0)`.
|
|
104
|
+
`allow_overage` is `False` everywhere else, `no_cap` included.
|
|
105
|
+
|
|
106
|
+
### Async
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from meterbase import AsyncMeterbase
|
|
110
|
+
|
|
111
|
+
async with AsyncMeterbase(api_key=os.environ["METERBASE_API_KEY"]) as meterbase:
|
|
112
|
+
result = await meterbase.check(customer_id="acct_1", meter_id="ai_tokens")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Every call on `Meterbase` is on `AsyncMeterbase`, with the same parameters and
|
|
116
|
+
the same answers: the sync client is generated from the async one, so the two
|
|
117
|
+
cannot drift. Everything below is written for the sync client; add `await` for
|
|
118
|
+
the async one.
|
|
119
|
+
|
|
120
|
+
### Recording usage
|
|
121
|
+
|
|
122
|
+
`track` counts work that already happened — the tokens were spent, the image
|
|
123
|
+
was generated — so it **never refuses on capacity**. A quantity beyond the
|
|
124
|
+
customer's capacity is recorded, drives `available` to 0, and the next `check`
|
|
125
|
+
says no, unless the cap is [soft](#plan-allowances). The answer is a receipt,
|
|
126
|
+
with no verdict and nothing to branch on:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
receipt = meterbase.track(
|
|
130
|
+
customer_id="acct_1",
|
|
131
|
+
meter_id="ai_tokens",
|
|
132
|
+
quantity=250_000,
|
|
133
|
+
idempotency_key="0199e0c0-8f3a-7c21-9d44-6b2e91a0f5c3", # a UUIDv7; generated when you omit it
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
receipt.event.replayed # False the first time, True for a retry of the same call
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**The engine dates the event, not you.** There is no `occurred_at` to send: an
|
|
140
|
+
event is stamped by the database clock the write runs under, which is what
|
|
141
|
+
chooses the period it counts against. `occurred_at` and `recorded_at` come back
|
|
142
|
+
on the receipt as the same instant, both on the wire so a reader of either
|
|
143
|
+
keeps working. Nothing can be dated into a period that has already closed, so a
|
|
144
|
+
bill drawn from a period is never moved by a report that arrives after it.
|
|
145
|
+
|
|
146
|
+
#### Idempotency
|
|
147
|
+
|
|
148
|
+
`idempotency_key` identifies one logical event, and **is** that event's id.
|
|
149
|
+
It has to be a **UUIDv7**, whose first 48 bits are the millisecond it was
|
|
150
|
+
minted. That timestamp is not decoration: the engine admits a key only while
|
|
151
|
+
it is within **one hour** of the engine's clock, and keeps the claim behind it
|
|
152
|
+
until exactly that hour is up. A retry is therefore recognised for as long as
|
|
153
|
+
it is accepted at all.
|
|
154
|
+
|
|
155
|
+
| The call sends | The engine answers |
|
|
156
|
+
| ------------------------------------- | ------------------------------------------------- |
|
|
157
|
+
| a fresh key | the recorded event, `replayed: false` |
|
|
158
|
+
| a key it has already seen | that event with `replayed: true`, writing nothing |
|
|
159
|
+
| a key more than an hour old, or ahead | `422 too_late`, writing nothing |
|
|
160
|
+
| anything that is not a UUIDv7 | `422 invalid_idempotency_key`, writing nothing |
|
|
161
|
+
|
|
162
|
+
`uuid.uuid4()` mints a v4, which the engine refuses. `uuid.uuid7()`, on Python
|
|
163
|
+
3.14, mints the right version from this machine's clock — but the simplest key
|
|
164
|
+
is none: omit it and the SDK mints one.
|
|
165
|
+
|
|
166
|
+
A key it has already seen replays **whatever the rest of the body says**:
|
|
167
|
+
there is one claim behind the key and nothing to compare a retry against, so
|
|
168
|
+
a changed quantity is a replay rather than an error. The upside is that a
|
|
169
|
+
replay costs one lookup and stores nothing.
|
|
170
|
+
|
|
171
|
+
**The hour is a retry budget.** Retry inside it and your call is recognised.
|
|
172
|
+
Past it, `too_late` — and do not then resend under a fresh key, because the
|
|
173
|
+
original may have been recorded. Nothing is written on either refusal: both
|
|
174
|
+
are checked before the engine touches anything.
|
|
175
|
+
|
|
176
|
+
**Clocks matter now.** The key is minted here, from this machine's clock, so
|
|
177
|
+
a device running more than an hour off would have every call refused. When
|
|
178
|
+
this SDK mints the key it handles that itself: `too_late` carries the engine's
|
|
179
|
+
`server_time`, the client corrects its offset and retries once, and you never
|
|
180
|
+
see the error. When **you** supply the key, you get `TooLateError` with
|
|
181
|
+
`server_time` on it, because only you can know whether that key names work
|
|
182
|
+
that was already recorded.
|
|
183
|
+
|
|
184
|
+
**The SDK generates a key when you omit one, and reuses it across that call's
|
|
185
|
+
retries** — including its own, since `track` is a `POST` it will replay.
|
|
186
|
+
Supply your own when the caller already has an id for the work — a job id, a
|
|
187
|
+
request id — so that a retry from further out than this SDK replays too. Keys
|
|
188
|
+
are retained 35 days; a retry after that records a second event, which is not a
|
|
189
|
+
case this API sets out to serve.
|
|
190
|
+
|
|
191
|
+
### Work you cannot do twice
|
|
192
|
+
|
|
193
|
+
`check` → work → `track` has a hole: two concurrent callers both pass the gate,
|
|
194
|
+
both do the work, and both record it. For an API call that costs nothing that
|
|
195
|
+
is an accepted trade. For an LLM completion, an image, a transcode, it is the
|
|
196
|
+
whole problem — the customer is over their cap and the money is already spent.
|
|
197
|
+
|
|
198
|
+
`reserve` closes it. It makes the same decision `check` makes and then **holds**
|
|
199
|
+
the capacity, so two reserves for the same last units cannot both be granted:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
hold = meterbase.reserve(
|
|
203
|
+
customer_id="acct_1",
|
|
204
|
+
meter_id="ai_tokens",
|
|
205
|
+
quantity=50_000, # what you are about to spend; defaults to 1
|
|
206
|
+
expires_in_seconds=60, # how long to hold it; defaults to 60, at most 900
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if not hold.allowed:
|
|
210
|
+
raise RuntimeError(f"Refused: {hold.reason}")
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
completion = generate(prompt)
|
|
214
|
+
hold.commit(completion.usage.total_tokens) # records what it cost
|
|
215
|
+
except Exception:
|
|
216
|
+
hold.release() # nothing happened, so nothing is recorded
|
|
217
|
+
raise
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**The rule for choosing: can you afford to do the work twice? `check`. No?
|
|
221
|
+
`reserve`.** A reserve costs a database transaction where a check costs a cached
|
|
222
|
+
read — refusals included — so it is not the call to put in front of cheap work.
|
|
223
|
+
|
|
224
|
+
A reserve answers everything `check` answers, plus the hold: `reservation_id`,
|
|
225
|
+
the `quantity` it set aside, and `expires_at`. `available` is already net of it.
|
|
226
|
+
Past a soft cap it holds rather than refuses, and `available` stops at 0.
|
|
227
|
+
A refusal holds nothing, so its `reservation_id` and `expires_at` are `None`.
|
|
228
|
+
|
|
229
|
+
`commit` is `track` under the hold's own id, which is what makes it a commit
|
|
230
|
+
rather than a second event beside it. Pass what the work actually cost and the
|
|
231
|
+
difference comes back; omit it to record what was held. Going over is allowed —
|
|
232
|
+
the hold only ever guaranteed what it reserved. On a refusal there is nothing
|
|
233
|
+
to commit, and `commit` raises `MeterbaseError`.
|
|
234
|
+
|
|
235
|
+
`release` gives the capacity back and records nothing. It is built for the
|
|
236
|
+
`except` block, so it answers `None` instead of raising when there is nothing
|
|
237
|
+
left to release — already committed, already released, or refused and so never
|
|
238
|
+
held — because raising there would mask the error you are already handling.
|
|
239
|
+
`meterbase.release(reservation_id=...)` raises that 404, as every other call
|
|
240
|
+
does.
|
|
241
|
+
|
|
242
|
+
**Nothing is lost if your process dies.** A hold expires on its own, and the
|
|
243
|
+
capacity comes back with nothing having run. A commit that arrives after
|
|
244
|
+
`expires_at` is still recorded — you lose the guarantee, not the event — and
|
|
245
|
+
the SDK logs a warning on the `meterbase` logger when that happens, which is
|
|
246
|
+
the only feedback there is on an `expires_in_seconds` guessed too short.
|
|
247
|
+
|
|
248
|
+
`reservation_id` is a UUIDv7 on the same terms as `idempotency_key`, generated
|
|
249
|
+
when you omit it, and it becomes the event's id once the hold is committed. One
|
|
250
|
+
id names the hold, the claim and the event. An id is for one reserve and its
|
|
251
|
+
retries: once its hold is committed or released, sending it again reserves
|
|
252
|
+
afresh rather than finding anything.
|
|
253
|
+
|
|
254
|
+
### Customers
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
customer = meterbase.customers.create(
|
|
258
|
+
external_id="acct_1",
|
|
259
|
+
name="Acme",
|
|
260
|
+
metadata={"tier": "pro"},
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
found = meterbase.customers.retrieve_by_external_id("acct_1") # or None
|
|
264
|
+
everyone = meterbase.customers.list(include_deleted=True).data
|
|
265
|
+
|
|
266
|
+
meterbase.customers.update(customer.id, name="Acme Inc")
|
|
267
|
+
meterbase.customers.delete(customer.id) # soft delete, idempotent
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Reading one customer tells you their plan.** `retrieve` and
|
|
271
|
+
`retrieve_by_external_id` carry `plan` — the one in force now — and
|
|
272
|
+
`pending_plan`, the change waiting to land, each `None` when there is none:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
acme = meterbase.customers.retrieve(customer.id)
|
|
276
|
+
|
|
277
|
+
if acme.plan:
|
|
278
|
+
acme.plan.key # "pro"
|
|
279
|
+
acme.plan.cycle # "monthly" — what their period is measured in
|
|
280
|
+
acme.plan.anchor_at # what it is measured from
|
|
281
|
+
acme.pending_plan # None, or the plan they move to and when
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`plan.id` is the **plan's** id, not the assignment's: what you want from an
|
|
285
|
+
embed is which plan, and `customers.plan.retrieve` is where instructions are
|
|
286
|
+
identified. `None` means they hold no plan, which is a default deny — a
|
|
287
|
+
customer with no plan and no grant is denied.
|
|
288
|
+
|
|
289
|
+
`customers.list` stays lean and does not embed, along with `create`, `update`
|
|
290
|
+
and `delete`; the same read would run per row for an answer most callers of a
|
|
291
|
+
list do not want.
|
|
292
|
+
|
|
293
|
+
### Alert thresholds
|
|
294
|
+
|
|
295
|
+
`thresholds` is a list of whole percents — 1–1000, at most 32, and a mark over
|
|
296
|
+
100 is an overage alert — and it hangs off both meters and customers. A
|
|
297
|
+
customer's list beats the meter's, which beats the workspace's defaults.
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
meterbase.meters.create(key="ai_tokens", name="Tokens", thresholds=[80, 100])
|
|
301
|
+
meterbase.customers.update(customer.id, thresholds=[50, 90]) # this customer only
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
It is the one field where `None` is a value rather than silence:
|
|
305
|
+
|
|
306
|
+
| You pass | It means |
|
|
307
|
+
| ------------------- | ------------------------------------------------- |
|
|
308
|
+
| nothing | keep the stored list |
|
|
309
|
+
| `thresholds=None` | inherit again — the meter's, then the workspace's |
|
|
310
|
+
| `thresholds=[]` | alerts off for this scope alone |
|
|
311
|
+
| `thresholds=[80]` | these marks |
|
|
312
|
+
|
|
313
|
+
That is why every optional parameter defaults to `NOT_GIVEN` rather than to
|
|
314
|
+
`None`: a parameter you leave out is not sent at all, and `None` is sent as
|
|
315
|
+
JSON `null`.
|
|
316
|
+
|
|
317
|
+
Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
|
|
318
|
+
|
|
319
|
+
### Meters
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
|
|
323
|
+
meters = meterbase.meters.list().data
|
|
324
|
+
meterbase.meters.archive(meter_id) # soft delete, idempotent
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Plans
|
|
328
|
+
|
|
329
|
+
A plan has one billing period, fixed when the plan is created. Everything it
|
|
330
|
+
meters resets on that period, measured from each customer's own anchor — which
|
|
331
|
+
is why two customers on the same monthly plan renew on different days.
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
plan = meterbase.plans.create(
|
|
335
|
+
key="pro",
|
|
336
|
+
name="Pro",
|
|
337
|
+
cycle="monthly", # daily · weekly · monthly · quarterly · half_yearly · yearly
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
meterbase.plans.update(plan.id, name="Pro Plus")
|
|
341
|
+
meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`cycle` is not patchable. Changing it would re-date every period the plan's
|
|
345
|
+
customers were already measured against, so a different cadence is a different
|
|
346
|
+
plan — move a customer with `customers.plan.assign`.
|
|
347
|
+
|
|
348
|
+
### Plan allowances
|
|
349
|
+
|
|
350
|
+
What a plan grants, one meter at a time. An allowance says _how much_, never
|
|
351
|
+
_how often_: the period is the plan's cycle. There is no update and no delete —
|
|
352
|
+
an edit appends the next version, so which amount applied when stays derivable.
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
# The recurring cap, refreshed every period.
|
|
356
|
+
meterbase.plans.allowances.set(
|
|
357
|
+
plan.id,
|
|
358
|
+
meter_id=meter.id,
|
|
359
|
+
amount=1_000_000, # or no_cap=True
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# A signup bonus beside it, minted once when the plan is assigned.
|
|
363
|
+
meterbase.plans.allowances.set(
|
|
364
|
+
plan.id,
|
|
365
|
+
meter_id=meter.id,
|
|
366
|
+
amount=500,
|
|
367
|
+
kind="one_time",
|
|
368
|
+
valid_for={"months": 3, "days": 0},
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
versions = meterbase.plans.allowances.list(plan.id).data
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Each `(plan, meter)` pair carries up to two lineages, `recurring` and
|
|
375
|
+
`one_time`, numbering themselves separately. `kind` defaults to `recurring`.
|
|
376
|
+
|
|
377
|
+
**A soft cap** keeps the amount and drops the refusal: included usage, then
|
|
378
|
+
overage.
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
meterbase.plans.allowances.set(
|
|
382
|
+
plan.id, meter_id=meter.id, amount=5_000, allow_overage=True
|
|
383
|
+
)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Once `amount` is used up, `check` and `reserve` admit rather than refuse, with
|
|
387
|
+
`allow_overage=True` on the answer, and the usage past it is overage: read it
|
|
388
|
+
as `overage_used` on [the entitlement](#how-much-they-have-used). Rate limits
|
|
389
|
+
still refuse, thresholds still fire — a mark over 100 is an overage alert — and
|
|
390
|
+
a grant is spent before any overage is. `allow_overage` defaults to `False` and
|
|
391
|
+
is refused beside `no_cap` and on `one_time`. Like the amount, it is a term of
|
|
392
|
+
the version, so turning it on or off follows `apply_mode`: at each customer's
|
|
393
|
+
next cycle by default.
|
|
394
|
+
|
|
395
|
+
### A customer's plan
|
|
396
|
+
|
|
397
|
+
A customer's plan is an append-only history, not a field: assigning is the only
|
|
398
|
+
write, and it serves both a first plan and every later change. What differs
|
|
399
|
+
between the changes is **when** the new plan starts and **what happens to the
|
|
400
|
+
period the change lands in** — so each of those is its own call.
|
|
401
|
+
|
|
402
|
+
#### Putting them on a plan
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
meterbase.customers.plan.assign(customer.id, plan_id=plan.id)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
A first assignment is always recorded as `immediate`: there is no period to
|
|
409
|
+
wait out. It is the one call that takes `cycle_anchor`, which puts their
|
|
410
|
+
billing periods on a date they already have — migrating them from another
|
|
411
|
+
system, say. Their renewal day is fixed from here and does not move again
|
|
412
|
+
unless you `restart` them.
|
|
413
|
+
|
|
414
|
+
#### Moving them to another plan
|
|
415
|
+
|
|
416
|
+
| Call | The new plan starts | This period's cap |
|
|
417
|
+
| ----------------------------- | ------------------- | ------------------------------------------------ |
|
|
418
|
+
| `change_at_next_cycle` | at their renewal | untouched — they keep what they paid for |
|
|
419
|
+
| `change_now` | now | the new plan's amount, for the whole period |
|
|
420
|
+
| `change_now` + `"prorate"` | now | the two plans weighted by how long each was held |
|
|
421
|
+
| `restart` | now | a **fresh period** starts today |
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
# Upgrade them at their renewal. Nothing is split, so nothing is pro-rated.
|
|
425
|
+
meterbase.customers.plan.change_at_next_cycle(customer.id, plan=pro.id)
|
|
426
|
+
|
|
427
|
+
# Move them now. Usage already spent still counts, so a downgrade can deny
|
|
428
|
+
# until the period ends.
|
|
429
|
+
meterbase.customers.plan.change_now(customer.id, plan=starter.id)
|
|
430
|
+
|
|
431
|
+
# Move them now and split the period fairly: 1,000/mo held for half a month
|
|
432
|
+
# then 400/mo for the other half is a cap of 700.
|
|
433
|
+
meterbase.customers.plan.change_now(
|
|
434
|
+
customer.id, plan=starter.id, reconciliation="prorate"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Start over today: the running period closes where the change lands and
|
|
438
|
+
# keeps its usage, and the new plan's full amount opens at once.
|
|
439
|
+
meterbase.customers.plan.restart(customer.id, plan=pro.id)
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
**`restart` is the one that moves their renewal day**, because it re-anchors
|
|
443
|
+
them to today. The other three leave it exactly where it was.
|
|
444
|
+
|
|
445
|
+
`change(customer_id, plan=..., effective=..., reconciliation=...)` is the same
|
|
446
|
+
call with both knobs in the open, for when they are chosen at runtime. `assign`
|
|
447
|
+
is the wire shape underneath all four, and still takes `plan_id`.
|
|
448
|
+
|
|
449
|
+
#### Two plans on different cycles
|
|
450
|
+
|
|
451
|
+
A weekly plan and a monthly one share no boundary to wait for and no period to
|
|
452
|
+
weight, so `change_at_next_cycle` and `change_now` + `"prorate"` both raise
|
|
453
|
+
`CycleChangeRequiresResetError`. `restart` is the move that works, and plain
|
|
454
|
+
`change_now` is the other way out — it keeps the period that is running.
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
from meterbase import CycleChangeRequiresResetError
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
meterbase.customers.plan.change_at_next_cycle(customer.id, plan=weekly.id)
|
|
461
|
+
except CycleChangeRequiresResetError:
|
|
462
|
+
meterbase.customers.plan.restart(customer.id, plan=weekly.id)
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
#### Reading where they stand
|
|
466
|
+
|
|
467
|
+
```python
|
|
468
|
+
current = meterbase.customers.plan.retrieve(customer.id) # or None
|
|
469
|
+
scheduled = meterbase.customers.plan.pending(customer.id) # or None
|
|
470
|
+
trail = meterbase.customers.plan.history(customer.id).data
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`retrieve` is the plan **in force now**, which is not always the newest
|
|
474
|
+
instruction — a change dated ahead does not govern yet — and is `None` for a
|
|
475
|
+
customer holding no plan. That is a default deny rather than a failure, so it
|
|
476
|
+
is an answer here rather than a raised `NotFoundError`; a customer who does not
|
|
477
|
+
exist at all still raises one.
|
|
478
|
+
|
|
479
|
+
`pending` is the change waiting to land, and at most one ever is: a newer
|
|
480
|
+
instruction supersedes the one before it. `history` is the whole trail,
|
|
481
|
+
superseded rows included, newest first — it is the audit log of what was asked
|
|
482
|
+
for, not only of what happened.
|
|
483
|
+
|
|
484
|
+
To call off a change that has not landed:
|
|
485
|
+
|
|
486
|
+
```python
|
|
487
|
+
meterbase.customers.plan.cancel_scheduled_change(customer.id)
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Naming the plan a customer already holds is what supersedes a pending
|
|
491
|
+
instruction, and writes no new row of its own — so this reads the plan in force
|
|
492
|
+
and names it back. It returns the assignment still in force, or `None` for a
|
|
493
|
+
customer holding no plan, who can have nothing pending.
|
|
494
|
+
|
|
495
|
+
#### How much they have used
|
|
496
|
+
|
|
497
|
+
```python
|
|
498
|
+
entitlement = meterbase.customers.entitlement(customer.id) # or None
|
|
499
|
+
|
|
500
|
+
for meter in entitlement.meters if entitlement else []:
|
|
501
|
+
cap = "unlimited" if meter.amount is None else meter.amount
|
|
502
|
+
print(meter.meter_id, f"{meter.used} of {cap}")
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
`entitlement` is the plan's side of the current period, one entry per meter the
|
|
506
|
+
plan meters: the read behind a usage bar in your own app. `used` is what
|
|
507
|
+
`amount` is measured against, `total` adds what grants paid for, and
|
|
508
|
+
`available` is what `check` would answer. `overage_used` is how far `used` has
|
|
509
|
+
run past `amount` — what a [soft cap](#plan-allowances) bills for, and reported
|
|
510
|
+
under a hard cap too — and `None` where there is no amount; `allow_overage`
|
|
511
|
+
says whether the cap in effect is soft. Meters are named by id, and like
|
|
512
|
+
`retrieve` it is `None` for a customer holding no plan.
|
|
513
|
+
|
|
514
|
+
Unlike `check`, it is not answered from a cache, so read it where usage is
|
|
515
|
+
shown rather than on every request.
|
|
516
|
+
|
|
517
|
+
#### Closed periods
|
|
518
|
+
|
|
519
|
+
`customers.periods.last` is the newest period that has ended, with the figures
|
|
520
|
+
to bill from: per meter, the cap `check` answered at its last instant, `used`
|
|
521
|
+
against it, and `overage_used`, which is what a soft cap bills; `total - used`
|
|
522
|
+
is what grants absorbed. They come from the counters behind `check`, which
|
|
523
|
+
count each event exactly once. [Usage by day](#usage-by-day) is for where
|
|
524
|
+
usage went, not for billing.
|
|
525
|
+
|
|
526
|
+
A billing job reads the newest, then walks back by passing each `from_` as
|
|
527
|
+
`before`, until it reaches the `to` it last billed. `None` ends the walk:
|
|
528
|
+
nothing closed before that, or no plan at all.
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
from meterbase.types import ClosedPeriod
|
|
532
|
+
|
|
533
|
+
unbilled: list[ClosedPeriod] = []
|
|
534
|
+
period = meterbase.customers.periods.last(customer.id)
|
|
535
|
+
|
|
536
|
+
while period and period.to != last_billed_to:
|
|
537
|
+
unbilled.insert(0, period) # oldest first
|
|
538
|
+
period = meterbase.customers.periods.last(customer.id, before=period.from_)
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
The period's `from` is a Python keyword, so it reads as `period.from_` — or as
|
|
542
|
+
`period["from"]`, which reads any field exactly as the engine sent it.
|
|
543
|
+
|
|
544
|
+
**Pass `from_` back verbatim.** It carries microseconds, and a `before` short
|
|
545
|
+
of one names the period before it. That is why `before` is a string, and why
|
|
546
|
+
the timestamps on every model stay strings: re-formatting a `datetime` is one
|
|
547
|
+
way to lose the precision, or the offset, that makes them exact. Keep `to` as it
|
|
548
|
+
came too: it is what the loop compares.
|
|
549
|
+
|
|
550
|
+
For a minute after a period ends, while a track dated inside it may still be
|
|
551
|
+
committing, `last` raises `PeriodSettlingError`. Read again after its
|
|
552
|
+
`retry_after` seconds; from then on the answer never changes. It is not retried
|
|
553
|
+
for you: the wait runs to a minute, and whether to sit it out is the job's
|
|
554
|
+
call.
|
|
555
|
+
|
|
556
|
+
#### Usage by day
|
|
557
|
+
|
|
558
|
+
```python
|
|
559
|
+
week = meterbase.customers.usage(customer.id, days=7)
|
|
560
|
+
period = meterbase.customers.usage(customer.id, period="current") # or None
|
|
561
|
+
calls = meterbase.meters.usage(meter_id, days=30)
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Usage per UTC day: `days` is 1–90 days ending today, and a customer can also
|
|
565
|
+
be read by `period`, `"current"` or `"last"`. Each meter lists every day that
|
|
566
|
+
has begun, zeros included; a customer's meters with no usage in the range are
|
|
567
|
+
left out. A period the customer never had is `None`.
|
|
568
|
+
|
|
569
|
+
Figures can be up to five minutes old. `check` and `entitlement` are live.
|
|
570
|
+
|
|
571
|
+
### A customer's allowances
|
|
572
|
+
|
|
573
|
+
Capacity on top of whatever the plan gives.
|
|
574
|
+
|
|
575
|
+
```python
|
|
576
|
+
grant = meterbase.customers.allowances.grant(
|
|
577
|
+
customer.id,
|
|
578
|
+
meter_id=meter.id,
|
|
579
|
+
amount=500,
|
|
580
|
+
source="purchased", # or "bonus" · "manual"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
grants = meterbase.customers.allowances.list(customer.id, include_closed=True).data
|
|
584
|
+
|
|
585
|
+
meterbase.customers.allowances.revoke(customer.id, grant.id)
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Revoking withdraws what is left without erasing what was consumed, and is
|
|
589
|
+
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
590
|
+
appear here with `source="plan"`; they cannot be created through `grant`.
|
|
591
|
+
|
|
592
|
+
## Webhooks
|
|
593
|
+
|
|
594
|
+
Meterbase posts to your endpoints when a customer crosses a usage threshold.
|
|
595
|
+
Add an endpoint in the dashboard under **Webhooks**; each has its own signing
|
|
596
|
+
secret, shown once when you add it and revealable from its settings.
|
|
597
|
+
|
|
598
|
+
| Event | When |
|
|
599
|
+
| --------------------- | -------------------------------------------------------------------------------------- |
|
|
600
|
+
| `threshold.crossed` | a customer's usage of a meter crosses one of their thresholds |
|
|
601
|
+
| `entitlement.reached` | that crossing at 100% of their plan's entitlement, sent as well as `threshold.crossed` |
|
|
602
|
+
|
|
603
|
+
```json
|
|
604
|
+
{
|
|
605
|
+
"id": "0199c3b1-3f05-7a90-b6e2-49c817d05fa3",
|
|
606
|
+
"type": "threshold.crossed",
|
|
607
|
+
"timestamp": "2026-09-18T14:08:12Z",
|
|
608
|
+
"data": {
|
|
609
|
+
"customer_id": "acct_1",
|
|
610
|
+
"meter_id": "ai_tokens",
|
|
611
|
+
"plan": { "id": "01993c40-…", "key": "pro", "name": "Pro" },
|
|
612
|
+
"threshold": 80,
|
|
613
|
+
"used": 801400,
|
|
614
|
+
"entitlement": 1000000,
|
|
615
|
+
"period": {
|
|
616
|
+
"start": "2026-09-01T00:00:00Z",
|
|
617
|
+
"end": "2026-10-01T00:00:00Z"
|
|
618
|
+
},
|
|
619
|
+
"usage_event_id": "0199c3b0-9e41-7d2a-8c55-2f6e1a3bc738"
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`used` and `entitlement` are the plan's figures at the crossing; neither counts
|
|
625
|
+
grants, so ask `check` for capacity. `entitlement.reached` marks the plan's
|
|
626
|
+
entitlement, not spent capacity: a grant can still have balance, and a soft cap
|
|
627
|
+
admits past it.
|
|
628
|
+
|
|
629
|
+
### Verifying a delivery
|
|
630
|
+
|
|
631
|
+
Verify every request before you trust it. `verify_webhook` checks the signature
|
|
632
|
+
and the timestamp, then returns the event:
|
|
633
|
+
|
|
634
|
+
```python
|
|
635
|
+
from flask import Flask, request
|
|
636
|
+
|
|
637
|
+
from meterbase import WebhookVerificationError, verify_webhook
|
|
638
|
+
from meterbase.types import EntitlementReachedEvent
|
|
639
|
+
|
|
640
|
+
app = Flask(__name__)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@app.post("/meterbase")
|
|
644
|
+
def meterbase_webhook():
|
|
645
|
+
try:
|
|
646
|
+
event = verify_webhook(
|
|
647
|
+
payload=request.get_data(), # the raw body: re-serialized JSON will not verify
|
|
648
|
+
headers=request.headers,
|
|
649
|
+
secret=os.environ["METERBASE_WEBHOOK_SECRET"],
|
|
650
|
+
)
|
|
651
|
+
except WebhookVerificationError:
|
|
652
|
+
return "", 400
|
|
653
|
+
|
|
654
|
+
if isinstance(event, EntitlementReachedEvent):
|
|
655
|
+
... # event.data.customer_id has used their plan's allowance for event.data.meter_id
|
|
656
|
+
return "", 204
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
In FastAPI or Starlette, pass `await request.body()` and `request.headers`; in
|
|
660
|
+
Django, `request.body` and `request.headers`. Header names are matched without
|
|
661
|
+
regard to case, so a plain `dict` works too.
|
|
662
|
+
|
|
663
|
+
The event is a `ThresholdCrossedEvent` or an `EntitlementReachedEvent`, so
|
|
664
|
+
`isinstance` tells them apart. New types can be added: one this release does not
|
|
665
|
+
know reads as a plain `WebhookEvent`, so ignore any you do not handle.
|
|
666
|
+
`verify_webhook` is synchronous, and fast enough to call from an async handler.
|
|
667
|
+
|
|
668
|
+
Without the SDK, it is [Standard Webhooks](https://www.standardwebhooks.com):
|
|
669
|
+
|
|
670
|
+
1. Each request carries `webhook-id`, `webhook-timestamp` (Unix seconds) and
|
|
671
|
+
`webhook-signature`.
|
|
672
|
+
2. Compute HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed
|
|
673
|
+
with the base64-decoded part of the secret after `whsec_`.
|
|
674
|
+
3. `webhook-signature` is a space-separated list of `v1,{base64}`. Accept the
|
|
675
|
+
request if any of them equals yours, compared in constant time.
|
|
676
|
+
4. Refuse a timestamp more than five minutes from your clock.
|
|
677
|
+
|
|
678
|
+
### Responding and retries
|
|
679
|
+
|
|
680
|
+
Answer with a 2xx within 10 seconds. Anything else is a failure, a redirect
|
|
681
|
+
included: Meterbase does not follow them.
|
|
682
|
+
|
|
683
|
+
| Attempt | After the one before |
|
|
684
|
+
| ------- | ------------------------------- |
|
|
685
|
+
| 1 | within a minute of the crossing |
|
|
686
|
+
| 2 | 1 minute |
|
|
687
|
+
| 3 | 6 minutes |
|
|
688
|
+
| 4 | 36 minutes |
|
|
689
|
+
| 5 | 3 hours 36 minutes |
|
|
690
|
+
| 6 | 21 hours 36 minutes |
|
|
691
|
+
|
|
692
|
+
Each wait varies by up to 10%. An event keeps its `id` on every attempt and
|
|
693
|
+
every endpoint, so dedupe on it, and do not rely on order. The dashboard logs
|
|
694
|
+
every attempt for 30 days and can retry a delivery by hand.
|
|
695
|
+
|
|
696
|
+
An answer of `410 Gone` pauses the endpoint, as does a failure a day into a
|
|
697
|
+
streak of them. A paused endpoint holds new deliveries until you resume it,
|
|
698
|
+
with or without replaying them.
|
|
699
|
+
|
|
700
|
+
### Rotating a secret
|
|
701
|
+
|
|
702
|
+
Rotate from the endpoint's settings. For the next 24 hours both secrets sign
|
|
703
|
+
every request, so deploy the new one at your own pace; while you switch over,
|
|
704
|
+
`secret` takes both: `secret=[new_secret, old_secret]`.
|
|
705
|
+
|
|
706
|
+
## Errors
|
|
707
|
+
|
|
708
|
+
Every failure is a `MeterbaseError`. Catch the specific one you care about, or
|
|
709
|
+
the base class for all of them.
|
|
710
|
+
|
|
711
|
+
```python
|
|
712
|
+
from meterbase import ConflictError, RateLimitError
|
|
713
|
+
|
|
714
|
+
try:
|
|
715
|
+
meterbase.customers.create(external_id="acct_1")
|
|
716
|
+
except ConflictError as error:
|
|
717
|
+
... # error.code == "customer_external_id_taken"
|
|
718
|
+
except RateLimitError as error:
|
|
719
|
+
... # error.retry_after, in seconds
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
| Class | When |
|
|
723
|
+
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
724
|
+
| `AuthenticationError` | 401 — unknown, revoked or malformed key |
|
|
725
|
+
| `PermissionDeniedError` | 403 — the key may not use this route |
|
|
726
|
+
| `NotFoundError` | 404 |
|
|
727
|
+
| `ConflictError` | 409 — a key or external id is taken |
|
|
728
|
+
| `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
|
|
729
|
+
| `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
|
|
730
|
+
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
731
|
+
| `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
|
|
732
|
+
| `TooLateError` | 422 — an `InvalidRequestError`: the key is over an hour from the engine's clock, which it carries on `server_time`. Nothing was recorded |
|
|
733
|
+
| `ReservationMismatchError` | 422 — an `InvalidRequestError`: the reservation was made for a different customer or meter |
|
|
734
|
+
| `RateLimitError` | 429 |
|
|
735
|
+
| `ServerError` | 5xx |
|
|
736
|
+
| `APIConnectionError` / `APITimeoutError` | the request never got an answer |
|
|
737
|
+
| `WebhookVerificationError` | `verify_webhook`: not a delivery Meterbase signed in the last five minutes |
|
|
738
|
+
|
|
739
|
+
`APIError` carries `status`, `code`, `message`, `request_id` and the parsed
|
|
740
|
+
`body`. Branch on `code`, which is stable; `message` is for humans and may
|
|
741
|
+
change. The two connection errors are named so they do not shadow Python's own
|
|
742
|
+
`ConnectionError` and `TimeoutError`. Every error pickles with its fields, so a
|
|
743
|
+
task queue can carry one back from a worker.
|
|
744
|
+
|
|
745
|
+
`error_from_response(status=..., code=..., message=...)` builds the error the
|
|
746
|
+
SDK would raise for a response, for realistic errors in your own tests.
|
|
747
|
+
|
|
748
|
+
## Options
|
|
749
|
+
|
|
750
|
+
```python
|
|
751
|
+
Meterbase(
|
|
752
|
+
api_key="mb_sk_live_…",
|
|
753
|
+
base_url="https://api.meterbase.dev",
|
|
754
|
+
timeout=10.0, # seconds, per attempt
|
|
755
|
+
max_retries=2,
|
|
756
|
+
http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
|
|
757
|
+
)
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
`timeout` bounds each attempt — connecting, sending, and each wait for the
|
|
761
|
+
engine to answer — not the whole call. Pass an `http_client` for proxies,
|
|
762
|
+
limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
|
|
763
|
+
`httpx.AsyncClient` for `AsyncMeterbase`.
|
|
764
|
+
|
|
765
|
+
For other settings on some calls, derive a client with them. It shares the
|
|
766
|
+
original's connections, and its clock:
|
|
767
|
+
|
|
768
|
+
```python
|
|
769
|
+
meterbase.with_options(timeout=2.0, max_retries=0).check(
|
|
770
|
+
customer_id="acct_1", meter_id="ai_tokens"
|
|
771
|
+
)
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
**Cancelling** an async call — `task.cancel()`, or a deadline of your own such as
|
|
775
|
+
`asyncio.timeout` — ends it where it stands, mid-attempt or in the wait before
|
|
776
|
+
a retry, and the cancellation reaches you untouched. Nothing is sent after it:
|
|
777
|
+
an attempt already on the wire may still land, but it is never replayed, so a
|
|
778
|
+
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
779
|
+
bound it with `timeout` and `max_retries` instead.
|
|
780
|
+
|
|
781
|
+
**Retries** apply to `GET`, `DELETE`, `track` and `reserve`, on connection
|
|
782
|
+
failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
|
|
783
|
+
`Retry-After` is honoured when the engine sends it, up to eight seconds. Every
|
|
784
|
+
other `POST` and `PATCH` is never replayed, because a retried create could
|
|
785
|
+
produce a second row.
|
|
786
|
+
|
|
787
|
+
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
788
|
+
and because the SDK fixes that key **before** the retry loop starts: every
|
|
789
|
+
attempt of one call names the same logical event, so the engine replays it
|
|
790
|
+
instead of counting the work twice. If you retry `track` yourself, pass your
|
|
791
|
+
own `idempotency_key` and keep it the same across attempts for exactly that
|
|
792
|
+
reason — and do it inside the hour the engine holds that key for.
|
|
793
|
+
|
|
794
|
+
## Types
|
|
795
|
+
|
|
796
|
+
Every answer is the engine's JSON as it arrived, `snake_case` included, so this
|
|
797
|
+
SDK reads the same as the API reference and cannot drift from it through a
|
|
798
|
+
translation layer. Each field is a typed, read-only attribute — `result.allowed`,
|
|
799
|
+
`hold.rate_limits[0].remaining` — and a nested object is a model too.
|
|
800
|
+
|
|
801
|
+
`model.to_dict()` returns the JSON exactly as it was sent, and `model["field"]`
|
|
802
|
+
reads one field of it: the way to reach a field the engine has added since this
|
|
803
|
+
release. Times stay RFC 3339 strings, as sent. The models are in
|
|
804
|
+
`meterbase.types`, and they are type hints rather than validation: an answer
|
|
805
|
+
the engine shapes differently reaches you rather than failing the call that
|
|
806
|
+
carried it.
|
|
807
|
+
|
|
808
|
+
## Versioning
|
|
809
|
+
|
|
810
|
+
Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
811
|
+
change that will not run against your code cannot arrive as a patch. The SDK
|
|
812
|
+
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
813
|
+
|
|
814
|
+
## Development
|
|
815
|
+
|
|
816
|
+
```bash
|
|
817
|
+
uv sync
|
|
818
|
+
make test # pytest, against a local test double
|
|
819
|
+
make check # generated code, types, lint, format, build, tests, packaging
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
### One client, generated twice
|
|
823
|
+
|
|
824
|
+
The async client in `src/meterbase/_async` is the source. `make unasync`
|
|
825
|
+
generates the sync client in `src/meterbase/_sync` from it — `async def`
|
|
826
|
+
becomes `def`, `await` goes, `AsyncMeterbase` becomes `Meterbase` — and the
|
|
827
|
+
async tests in `tests/_async` into sync ones in `tests/_sync`. Edit the async
|
|
828
|
+
side and regenerate; `make check` fails when the two disagree. The one place
|
|
829
|
+
they genuinely differ, sleeping and running two calls at once, is `_io.py`,
|
|
830
|
+
written by hand on both sides.
|
|
831
|
+
|
|
832
|
+
### Two layers of tests
|
|
833
|
+
|
|
834
|
+
**`make test`** runs against a real HTTP server on an ephemeral port rather
|
|
835
|
+
than a stubbed transport, so request building, headers, status handling and the
|
|
836
|
+
retry loop are exercised for real, for both clients. It can make the server
|
|
837
|
+
drop a socket or stall, which is the only way to test the retry, timeout and
|
|
838
|
+
cancellation paths.
|
|
839
|
+
|
|
840
|
+
What it cannot check is whether those scripted responses match the engine: a
|
|
841
|
+
route that does not exist, a renamed field or a changed error code all pass
|
|
842
|
+
here and fail in production.
|
|
843
|
+
|
|
844
|
+
**`make contract`** closes that gap by running against a live engine.
|
|
845
|
+
|
|
846
|
+
```bash
|
|
847
|
+
cp .env.example .env # then fill in a key, or export the two variables
|
|
848
|
+
make contract
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
It asserts the whole lifecycle of a customer, a meter and a plan; that a plan's
|
|
852
|
+
allowances become real entitlement a `check` can see; that tracked usage takes
|
|
853
|
+
exactly that much away, that a replay takes nothing, and that a reused key
|
|
854
|
+
replays whatever the body says; that a hold's commit records what the work cost
|
|
855
|
+
and gives the rest back; that a rate limit comes back on every `check` and
|
|
856
|
+
refuses with the allowance untouched; that a soft cap admits and holds past its
|
|
857
|
+
amount and reports the overage; that a period a `restart` closes settles for a
|
|
858
|
+
minute, which the run waits out, and then reads final; that plan changes
|
|
859
|
+
schedule, cancel and re-anchor as documented; and — reading each model's
|
|
860
|
+
annotations — that the engine's fields still match the SDK's types exactly, in
|
|
861
|
+
both directions.
|
|
862
|
+
|
|
863
|
+
> **It creates and deletes real data.** Point it at a test workspace. It only
|
|
864
|
+
> touches resources it created, tagging each with a per-run id, and cleans up
|
|
865
|
+
> afterwards; soft-deleted customers and archived meters and plans remain, as
|
|
866
|
+
> the engine intends. It is outside `make test` on purpose — a mutating suite
|
|
867
|
+
> should only run because someone asked for it.
|