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