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.
Files changed (39) hide show
  1. meterbase/__init__.py +59 -0
  2. meterbase/_async/__init__.py +0 -0
  3. meterbase/_async/_io.py +22 -0
  4. meterbase/_async/_transport.py +181 -0
  5. meterbase/_async/client.py +427 -0
  6. meterbase/_async/resources/__init__.py +16 -0
  7. meterbase/_async/resources/customer_allowances.py +89 -0
  8. meterbase/_async/resources/customer_periods.py +41 -0
  9. meterbase/_async/resources/customer_plan.py +228 -0
  10. meterbase/_async/resources/customers.py +176 -0
  11. meterbase/_async/resources/meters.py +102 -0
  12. meterbase/_async/resources/plans.py +140 -0
  13. meterbase/_http.py +205 -0
  14. meterbase/_json.py +18 -0
  15. meterbase/_keys.py +39 -0
  16. meterbase/_models.py +177 -0
  17. meterbase/_not_given.py +32 -0
  18. meterbase/_sync/__init__.py +4 -0
  19. meterbase/_sync/_io.py +21 -0
  20. meterbase/_sync/_transport.py +185 -0
  21. meterbase/_sync/client.py +431 -0
  22. meterbase/_sync/resources/__init__.py +20 -0
  23. meterbase/_sync/resources/customer_allowances.py +93 -0
  24. meterbase/_sync/resources/customer_periods.py +45 -0
  25. meterbase/_sync/resources/customer_plan.py +230 -0
  26. meterbase/_sync/resources/customers.py +176 -0
  27. meterbase/_sync/resources/meters.py +106 -0
  28. meterbase/_sync/resources/plans.py +144 -0
  29. meterbase/_time.py +49 -0
  30. meterbase/_version.py +1 -0
  31. meterbase/errors.py +309 -0
  32. meterbase/py.typed +0 -0
  33. meterbase/resources.py +41 -0
  34. meterbase/types.py +600 -0
  35. meterbase/webhooks.py +154 -0
  36. meterbase-0.1.0.dist-info/METADATA +867 -0
  37. meterbase-0.1.0.dist-info/RECORD +39 -0
  38. meterbase-0.1.0.dist-info/WHEEL +4 -0
  39. meterbase-0.1.0.dist-info/licenses/LICENSE +21 -0
meterbase/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """Python SDK for the Meterbase engine."""
2
+
3
+ from . import types
4
+ from ._async.client import AsyncHold, AsyncMeterbase
5
+ from ._not_given import NOT_GIVEN, NotGiven
6
+ from ._sync.client import Hold, Meterbase
7
+ from ._version import __version__
8
+ from .errors import (
9
+ APIConnectionError,
10
+ APIError,
11
+ APITimeoutError,
12
+ AuthenticationError,
13
+ ConflictError,
14
+ CycleChangeRequiresResetError,
15
+ InvalidIdempotencyKeyError,
16
+ InvalidRequestError,
17
+ MeterbaseError,
18
+ NotFoundError,
19
+ PeriodSettlingError,
20
+ PermissionDeniedError,
21
+ RateLimitError,
22
+ ReservationMismatchError,
23
+ ServerError,
24
+ TooLateError,
25
+ WebhookVerificationError,
26
+ error_from_response,
27
+ )
28
+ from .webhooks import WebhookHeaders, verify_webhook
29
+
30
+ __all__ = [
31
+ "NOT_GIVEN",
32
+ "APIConnectionError",
33
+ "APIError",
34
+ "APITimeoutError",
35
+ "AsyncHold",
36
+ "AsyncMeterbase",
37
+ "AuthenticationError",
38
+ "ConflictError",
39
+ "CycleChangeRequiresResetError",
40
+ "Hold",
41
+ "InvalidIdempotencyKeyError",
42
+ "InvalidRequestError",
43
+ "Meterbase",
44
+ "MeterbaseError",
45
+ "NotFoundError",
46
+ "NotGiven",
47
+ "PeriodSettlingError",
48
+ "PermissionDeniedError",
49
+ "RateLimitError",
50
+ "ReservationMismatchError",
51
+ "ServerError",
52
+ "TooLateError",
53
+ "WebhookHeaders",
54
+ "WebhookVerificationError",
55
+ "__version__",
56
+ "error_from_response",
57
+ "types",
58
+ "verify_webhook",
59
+ ]
File without changes
@@ -0,0 +1,22 @@
1
+ """
2
+ What the async client does that the sync one does differently, kept out of the
3
+ code `scripts/unasync.py` turns into the sync client. `_sync/_io.py` is its
4
+ twin, and like this file is written by hand.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from collections.abc import Awaitable
11
+ from typing import TypeVar
12
+
13
+ _A = TypeVar("_A")
14
+ _B = TypeVar("_B")
15
+
16
+ sleep = asyncio.sleep
17
+
18
+
19
+ async def both(first: Awaitable[_A], second: Awaitable[_B]) -> tuple[_A, _B]:
20
+ """Two calls at once, where the sync client makes them one after the
21
+ other."""
22
+ return await asyncio.gather(first, second)
@@ -0,0 +1,181 @@
1
+ """
2
+ Everything goes through `request`, so authentication, timeouts, retries and
3
+ error mapping are defined once.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from collections.abc import Callable
10
+ from datetime import datetime
11
+ from typing import Any, TypeVar
12
+
13
+ import httpx
14
+
15
+ from .._http import (
16
+ Body,
17
+ Query,
18
+ RequestOptions,
19
+ backoff,
20
+ build_url,
21
+ encode_body,
22
+ error_from,
23
+ origin_of,
24
+ parse_body,
25
+ request_headers,
26
+ should_retry,
27
+ )
28
+ from .._models import construct
29
+ from .._time import to_ms
30
+ from ..errors import APIConnectionError, APITimeoutError, MeterbaseError
31
+ from ._io import sleep
32
+
33
+
34
+ class AsyncTransport:
35
+ def __init__(
36
+ self,
37
+ *,
38
+ api_key: str,
39
+ base_url: str,
40
+ http_client: httpx.AsyncClient | None,
41
+ ) -> None:
42
+ self._api_key = api_key
43
+ self._base_url = base_url
44
+ self._origin = origin_of(base_url)
45
+ self._http = http_client if http_client is not None else httpx.AsyncClient()
46
+ # A client the caller passed in is theirs to close.
47
+ self._owns_http = http_client is None
48
+
49
+ # How far the engine's clock is ahead of this machine's, in
50
+ # milliseconds. Idempotency keys are minted here and the engine refuses
51
+ # one dated more than an hour from its own clock, so without this a
52
+ # device with a wrong clock would fail every call forever.
53
+ self._clock_offset_ms = 0.0
54
+
55
+ def now_ms(self) -> float:
56
+ """The engine's clock, as well as this client knows it."""
57
+ return time.time() * 1000 + self._clock_offset_ms
58
+
59
+ def observe_server_time(self, at: datetime) -> None:
60
+ """Off by up to one round trip, which a window in hours does not
61
+ notice."""
62
+ self._clock_offset_ms = to_ms(at) - time.time() * 1000
63
+
64
+ async def request(
65
+ self,
66
+ method: str,
67
+ path: str,
68
+ *,
69
+ options: RequestOptions,
70
+ query: Query | None = None,
71
+ body: Body | None = None,
72
+ idempotent: bool = False,
73
+ ) -> Any:
74
+ """
75
+ `idempotent` replays a call even though its method is not safe. Only a
76
+ route that carries an idempotency key may set it, and the key has to be
77
+ in `body` already: the loop re-sends one body, so a key generated per
78
+ attempt would count the same usage twice.
79
+ """
80
+ # Safe methods, plus the routes that carry an idempotency key and say
81
+ # so. Any other POST or PATCH could create a second row on a replay.
82
+ retryable = method in ("GET", "DELETE") or idempotent
83
+
84
+ url = build_url(self._base_url, path, query)
85
+ content = None if body is None else encode_body(body)
86
+ headers = request_headers(self._api_key, has_body=content is not None)
87
+
88
+ last_error: MeterbaseError | None = None
89
+ for attempt in range(options.max_retries + 1):
90
+ if attempt > 0:
91
+ await sleep(backoff(attempt, last_error))
92
+
93
+ try:
94
+ response = await self._send(method, url, headers, content, options)
95
+ except APIConnectionError as unanswered:
96
+ # A connection failure proves nothing about whether the server
97
+ # acted, so it is replayed under the same idempotency rule.
98
+ if not retryable or attempt >= options.max_retries:
99
+ raise
100
+ last_error = unanswered
101
+ continue
102
+
103
+ if response.is_success:
104
+ return parse_body(response)
105
+
106
+ refused = error_from(response)
107
+ if (
108
+ retryable
109
+ and attempt < options.max_retries
110
+ and should_retry(response.status_code)
111
+ ):
112
+ last_error = refused
113
+ continue
114
+ raise refused
115
+
116
+ # The loop always returns or raises.
117
+ raise AssertionError("unreachable") # pragma: no cover
118
+
119
+ async def _send(
120
+ self,
121
+ method: str,
122
+ url: str,
123
+ headers: dict[str, str],
124
+ content: bytes | None,
125
+ options: RequestOptions,
126
+ ) -> httpx.Response:
127
+ # Neither this nor the loop catches a BaseException — a cancelled task,
128
+ # a Ctrl-C — so one ends the call where it stands and nothing is sent
129
+ # after it: a replay would do the work the caller called off, and for
130
+ # `track` record usage they cancelled.
131
+ try:
132
+ return await self._http.request(
133
+ method,
134
+ url,
135
+ headers=headers,
136
+ content=content,
137
+ timeout=options.timeout,
138
+ )
139
+ except httpx.TimeoutException as error:
140
+ raise APITimeoutError(
141
+ f"Request timed out after {options.timeout:g}s."
142
+ ) from error
143
+ except httpx.TransportError as error:
144
+ raise APIConnectionError(f"Could not reach {self._origin}.") from error
145
+
146
+ async def aclose(self) -> None:
147
+ if self._owns_http:
148
+ await self._http.aclose()
149
+
150
+
151
+ _T = TypeVar("_T")
152
+
153
+
154
+ class AsyncRequester:
155
+ """A transport, and the options its calls run with. `with_options` makes
156
+ another over the same transport, which shares its connections and its
157
+ clock."""
158
+
159
+ def __init__(self, transport: AsyncTransport, options: RequestOptions) -> None:
160
+ self.transport = transport
161
+ self.options = options
162
+
163
+ async def request(
164
+ self,
165
+ method: str,
166
+ path: str,
167
+ cast_to: Callable[[dict[str, Any]], _T],
168
+ *,
169
+ query: Query | None = None,
170
+ body: Body | None = None,
171
+ idempotent: bool = False,
172
+ ) -> _T:
173
+ data = await self.transport.request(
174
+ method,
175
+ path,
176
+ options=self.options,
177
+ query=query,
178
+ body=body,
179
+ idempotent=idempotent,
180
+ )
181
+ return construct(cast_to, data)
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Callable, Mapping
6
+ from typing import Any, TypeVar
7
+
8
+ import httpx
9
+
10
+ from .._http import (
11
+ DEFAULT_MAX_RETRIES,
12
+ DEFAULT_TIMEOUT,
13
+ RequestOptions,
14
+ normalize_base_url,
15
+ )
16
+ from .._keys import IDEMPOTENCY_WINDOW_MS, RESERVE_WINDOW_MS, mint, minted_at_ms
17
+ from .._not_given import NOT_GIVEN, NotGiven
18
+ from .._time import parse_instant, to_ms
19
+ from ..errors import MeterbaseError, NotFoundError, TooLateError
20
+ from ..types import CheckResult, ReleaseResult, ReserveResponse, TrackResult, WhoAmI
21
+ from ._transport import AsyncRequester, AsyncTransport
22
+ from .resources.customers import AsyncCustomers
23
+ from .resources.meters import AsyncMeters
24
+ from .resources.plans import AsyncPlans
25
+
26
+ _T = TypeVar("_T")
27
+
28
+ logger = logging.getLogger("meterbase")
29
+
30
+
31
+ class AsyncMeterbase:
32
+ """
33
+ The client every call hangs off. An API key names its own workspace, so
34
+ there is no workspace to pass.
35
+
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.
40
+ """
41
+
42
+ customers: AsyncCustomers
43
+ meters: AsyncMeters
44
+ plans: AsyncPlans
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ api_key: str,
50
+ base_url: str | None = None,
51
+ timeout: float = DEFAULT_TIMEOUT,
52
+ max_retries: int = DEFAULT_MAX_RETRIES,
53
+ http_client: httpx.AsyncClient | None = None,
54
+ ) -> None:
55
+ if not api_key:
56
+ raise MeterbaseError(
57
+ "An API key is required: AsyncMeterbase(api_key='mb_sk_live_…')."
58
+ )
59
+
60
+ options = RequestOptions(timeout=timeout, max_retries=max_retries)
61
+ transport = AsyncTransport(
62
+ api_key=api_key,
63
+ base_url=normalize_base_url(base_url),
64
+ http_client=http_client,
65
+ )
66
+ self._bind(AsyncRequester(transport, options))
67
+
68
+ def _bind(self, api: AsyncRequester) -> None:
69
+ self._api = api
70
+ self.customers = AsyncCustomers(api)
71
+ self.meters = AsyncMeters(api)
72
+ self.plans = AsyncPlans(api)
73
+
74
+ def with_options(
75
+ self,
76
+ *,
77
+ timeout: float | NotGiven = NOT_GIVEN,
78
+ max_retries: int | NotGiven = NOT_GIVEN,
79
+ ) -> AsyncMeterbase:
80
+ """
81
+ This client with other settings, for the calls made through it:
82
+ `meterbase.with_options(timeout=2).check(...)`. It shares this client's
83
+ connections, so closing either closes both.
84
+ """
85
+ current = self._api.options
86
+ options = RequestOptions(
87
+ timeout=current.timeout if isinstance(timeout, NotGiven) else timeout,
88
+ max_retries=(
89
+ current.max_retries
90
+ if isinstance(max_retries, NotGiven)
91
+ else max_retries
92
+ ),
93
+ )
94
+ derived = type(self).__new__(type(self))
95
+ derived._bind(AsyncRequester(self._api.transport, options))
96
+ return derived
97
+
98
+ async def check(
99
+ self,
100
+ *,
101
+ customer_id: str,
102
+ meter_id: str,
103
+ quantity: int | NotGiven = NOT_GIVEN,
104
+ ) -> CheckResult:
105
+ """
106
+ Asks whether a customer may consume `quantity` of a meter, named by the
107
+ tenant's own external id and the meter's key: an SDK never holds
108
+ Meterbase ids. Absence of entitlement is not permission: a customer
109
+ with no plan and no grant is denied.
110
+
111
+ This is the gate, and the only call that ever refuses. Run it before
112
+ the work; record the work with `track` afterwards.
113
+
114
+ Two gates answer it, and `allowed` needs both: the customer's capacity
115
+ has to cover the quantity, and every rate-limit window in force has to
116
+ admit it. `reason` names the one that said no. `rate_limits` reports
117
+ each window whether or not it refused, so a caller can ease off as its
118
+ headroom closes instead of discovering the ceiling by hitting it.
119
+ Under a soft cap (`allow_overage`) the capacity admits any quantity, so
120
+ only a window can refuse.
121
+
122
+ `quantity` is what is about to be consumed, `1 … 100000000000`.
123
+ Omitted, it is 1, which asks whether there is any capacity at all.
124
+ """
125
+ return await self._api.request(
126
+ "POST",
127
+ "/v1/check",
128
+ CheckResult,
129
+ body={
130
+ "customer_id": customer_id,
131
+ "meter_id": meter_id,
132
+ "quantity": quantity,
133
+ },
134
+ )
135
+
136
+ async def track(
137
+ self,
138
+ *,
139
+ customer_id: str,
140
+ meter_id: str,
141
+ quantity: int | NotGiven = NOT_GIVEN,
142
+ idempotency_key: str | NotGiven = NOT_GIVEN,
143
+ ) -> TrackResult:
144
+ """
145
+ Records usage that already happened — the tokens were spent, the image
146
+ was generated — so it never refuses on capacity. A quantity beyond the
147
+ customer's capacity is recorded, drives `available` to 0, and the next
148
+ `check` says no, unless the plan's cap is soft. The answer is a
149
+ receipt, not a verdict.
150
+
151
+ `quantity` is an integer, `1 … 100000000000`, and 1 when omitted,
152
+ which is what a counting meter sends.
153
+
154
+ `idempotency_key` is a UUIDv7, generated when omitted, so retrying this
155
+ call — the SDK's own retries included — replays the original rather
156
+ than counting the same work twice. The engine keeps that claim for an
157
+ hour, which is how long a retry stays recognisable. Supply your own
158
+ when the caller already has an id for the work — a job id, a request
159
+ id — so a retry from further out replays rather than counts twice.
160
+ """
161
+ return await self._keyed(
162
+ "/v1/usage/track",
163
+ "idempotency_key",
164
+ idempotency_key,
165
+ IDEMPOTENCY_WINDOW_MS,
166
+ {"customer_id": customer_id, "meter_id": meter_id, "quantity": quantity},
167
+ TrackResult,
168
+ )
169
+
170
+ async def reserve(
171
+ self,
172
+ *,
173
+ customer_id: str,
174
+ meter_id: str,
175
+ quantity: int | NotGiven = NOT_GIVEN,
176
+ reservation_id: str | NotGiven = NOT_GIVEN,
177
+ expires_in_seconds: int | NotGiven = NOT_GIVEN,
178
+ ) -> AsyncHold:
179
+ """
180
+ Holds capacity for work that cannot be done twice, or refuses. The same
181
+ decision `check` makes, and then a hold on the capacity until the work
182
+ is committed, released, or expires.
183
+
184
+ The rule for choosing: can you afford to do the work twice? `check`.
185
+ No? `reserve`. A reserve costs a transaction where a check costs a
186
+ cached read, refusals included, so it is not a per-request gate on
187
+ cheap work.
188
+
189
+ Two concurrent reserves for the same customer and meter cannot both be
190
+ granted the same capacity; that is the whole point, and it is what
191
+ `check` → work → `track` could never promise.
192
+
193
+ `quantity` is what the hold sets aside, `1 … 100000000000`; 1 when
194
+ omitted. `reservation_id` is the hold's id, and the event's id once a
195
+ track commits it: a UUIDv7, which the SDK mints when it is omitted. An
196
+ id is for one reserve and its retries. Once its hold is committed or
197
+ released the row is gone, so sending the same id again reserves afresh
198
+ rather than finding anything.
199
+
200
+ `expires_in_seconds` is how long the hold counts against the pair
201
+ before it comes back on its own: `1 … 900`, defaulting to `60`. It is
202
+ a timeout on your own work, and the field callers get wrong. Too short
203
+ and the hold evaporates mid-operation, which is worse than no hold at
204
+ all because you believe you are protected; too long and a crash wedges
205
+ the customer's capacity until it expires.
206
+ """
207
+ return await self._keyed(
208
+ "/v1/usage/reserve",
209
+ "reservation_id",
210
+ reservation_id,
211
+ RESERVE_WINDOW_MS,
212
+ {
213
+ "customer_id": customer_id,
214
+ "meter_id": meter_id,
215
+ "quantity": quantity,
216
+ "expires_in_seconds": expires_in_seconds,
217
+ },
218
+ # A retry under the same id finds the hold it already made rather
219
+ # than making a second one.
220
+ lambda raw: AsyncHold(
221
+ raw,
222
+ client=self,
223
+ clock=self._api.transport,
224
+ customer_id=customer_id,
225
+ meter_id=meter_id,
226
+ quantity=quantity,
227
+ ),
228
+ )
229
+
230
+ async def release(self, *, reservation_id: str) -> ReleaseResult:
231
+ """
232
+ Gives a hold back: the work did not happen, so nothing is recorded.
233
+
234
+ Raises `NotFoundError` when the id names nothing — committed, released
235
+ already, or never made, which are one absence with one meaning. Prefer
236
+ `hold.release()`, which treats that as the success it is.
237
+ """
238
+ return await self._api.request(
239
+ "POST",
240
+ "/v1/usage/release",
241
+ ReleaseResult,
242
+ body={"reservation_id": reservation_id},
243
+ )
244
+
245
+ async def whoami(self) -> WhoAmI:
246
+ """Reports which workspace this key acts for."""
247
+ return await self._api.request("GET", "/v1/whoami", WhoAmI)
248
+
249
+ async def aclose(self) -> None:
250
+ """Closes the connections this client opened. An `http_client` passed
251
+ in stays open: it is yours."""
252
+ await self._api.transport.aclose()
253
+
254
+ async def __aenter__(self) -> AsyncMeterbase:
255
+ return self
256
+
257
+ async def __aexit__(self, *exc_info: object) -> None:
258
+ await self.aclose()
259
+
260
+ async def _keyed(
261
+ self,
262
+ path: str,
263
+ field: str,
264
+ supplied: str | NotGiven,
265
+ window_ms: int,
266
+ body: dict[str, Any],
267
+ cast_to: Callable[[dict[str, Any]], _T],
268
+ ) -> _T:
269
+ """
270
+ Sends a call that carries a caller-minted UUIDv7, minting one when the
271
+ caller supplied none and re-minting once if this machine's clock put it
272
+ outside the engine's window.
273
+
274
+ A key the caller supplied is never re-minted: this cannot know whether
275
+ it names work already recorded, and replacing it could count that work
276
+ twice.
277
+ """
278
+ if not isinstance(supplied, NotGiven):
279
+ return await self._api.request(
280
+ "POST", path, cast_to, body={**body, field: supplied}, idempotent=True
281
+ )
282
+
283
+ clock = self._api.transport
284
+ key = mint(clock.now_ms())
285
+ started = time.monotonic()
286
+
287
+ try:
288
+ # Fixed before the retry loop is entered: every attempt of this
289
+ # call carries the same key, which is what makes replaying it safe.
290
+ return await self._api.request(
291
+ "POST", path, cast_to, body={**body, field: key}, idempotent=True
292
+ )
293
+ except TooLateError as error:
294
+ # The only failure this can fix by itself: a clock that disagrees
295
+ # with the engine's.
296
+ if error.server_time is None:
297
+ raise
298
+
299
+ # Re-minting is safe only if no attempt under the old key can have
300
+ # landed. The transport retries, so this 422 may have come from a
301
+ # later attempt while an earlier one succeeded. If the key was
302
+ # already outside the window when the call began, every attempt
303
+ # was refused.
304
+ elapsed_ms = (time.monotonic() - started) * 1000
305
+ server_at_start = to_ms(error.server_time) - elapsed_ms
306
+ if abs(server_at_start - minted_at_ms(key)) <= window_ms:
307
+ raise
308
+
309
+ clock.observe_server_time(error.server_time)
310
+
311
+ return await self._api.request(
312
+ "POST",
313
+ path,
314
+ cast_to,
315
+ body={**body, field: mint(clock.now_ms())},
316
+ idempotent=True,
317
+ )
318
+
319
+
320
+ class AsyncHold(ReserveResponse):
321
+ """
322
+ What a reserve answers: everything `check` answers, and a hold on the
323
+ capacity when `allowed`, with the two calls that close it. `commit` is
324
+ `track` under the hold's own id; `release` gives the capacity back.
325
+
326
+ A refusal holds nothing, so it carries no `reservation_id` and no
327
+ `expires_at`: `commit` raises and `release` answers `None`.
328
+ """
329
+
330
+ _client: AsyncMeterbase
331
+ _clock: AsyncTransport
332
+ _customer_id: str
333
+ _meter_id: str
334
+
335
+ def __init__(
336
+ self,
337
+ raw: Mapping[str, Any],
338
+ *,
339
+ client: AsyncMeterbase,
340
+ clock: AsyncTransport,
341
+ customer_id: str,
342
+ meter_id: str,
343
+ quantity: int | NotGiven = NOT_GIVEN,
344
+ ) -> None:
345
+ super().__init__(raw)
346
+ self.__dict__.update(
347
+ _client=client,
348
+ _clock=clock,
349
+ _customer_id=customer_id,
350
+ _meter_id=meter_id,
351
+ )
352
+
353
+ if not self.allowed:
354
+ return
355
+
356
+ # A grant without them is not a hold this can hand back: `commit`
357
+ # would have no id to commit under, and the caller would find that out
358
+ # at the one moment they are relying on it. Louder here.
359
+ if self.reservation_id is None or self.expires_at is None:
360
+ raise MeterbaseError(
361
+ "The engine granted a reservation without an id or an expiry, so "
362
+ "there is no hold to commit or release. The capacity it set "
363
+ "aside comes back on its own."
364
+ )
365
+ if self.quantity is None:
366
+ self.__dict__["quantity"] = (
367
+ 1 if isinstance(quantity, NotGiven) else quantity
368
+ )
369
+
370
+ async def commit(self, quantity: int | NotGiven = NOT_GIVEN) -> TrackResult:
371
+ """
372
+ Records what the work actually cost and releases the rest. Omit the
373
+ quantity to record what was held. Going over is allowed: the hold only
374
+ ever guaranteed what it reserved.
375
+
376
+ Warns, on the `meterbase` logger, when it runs after `expires_at`: the
377
+ hold stopped holding anything at that instant, so the work was no
378
+ longer protected. The event is still recorded. That warning is the only
379
+ feedback there is on an `expires_in_seconds` guessed too short.
380
+
381
+ Raises `MeterbaseError` on a refusal, which held nothing to commit.
382
+ """
383
+ reservation_id = self.reservation_id
384
+ if not self.allowed or reservation_id is None:
385
+ raise MeterbaseError(
386
+ "This reserve was refused, so it holds nothing to commit. Record "
387
+ "work that ran anyway with track."
388
+ )
389
+
390
+ expires_at = parse_instant(self.expires_at)
391
+ if expires_at is not None and self._clock.now_ms() > to_ms(expires_at):
392
+ logger.warning(
393
+ "Meterbase: committing reservation %s after it expired at %s. The "
394
+ "event is still recorded, but the capacity was no longer held — "
395
+ "raise expires_in_seconds for this work.",
396
+ reservation_id,
397
+ self.expires_at,
398
+ )
399
+
400
+ return await self._client.track(
401
+ customer_id=self._customer_id,
402
+ meter_id=self._meter_id,
403
+ quantity=quantity,
404
+ # The hold's own id, so the track commits it rather than recording
405
+ # a second event beside it.
406
+ idempotency_key=reservation_id,
407
+ )
408
+
409
+ async def release(self) -> ReleaseResult | None:
410
+ """
411
+ Gives the hold back. Nothing is recorded.
412
+
413
+ Answers `None` rather than raising when there is nothing left to
414
+ release — already committed, already released, or refused and so never
415
+ held. This is the one call meant to run on the failure path, where
416
+ raising would mask the error the caller is already handling, and
417
+ "nothing left to release" is the outcome a release wanted anyway.
418
+ `meterbase.release` raises the 404, as every other call does.
419
+ """
420
+ reservation_id = self.reservation_id
421
+ if not self.allowed or reservation_id is None:
422
+ return None
423
+
424
+ try:
425
+ return await self._client.release(reservation_id=reservation_id)
426
+ except NotFoundError:
427
+ return None