meterbase 0.1.5__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {meterbase-0.1.5 → meterbase-0.2.0}/PKG-INFO +67 -13
  2. {meterbase-0.1.5 → meterbase-0.2.0}/README.md +66 -12
  3. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/_transport.py +11 -6
  4. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/client.py +22 -5
  5. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/customers.py +51 -1
  6. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_http.py +16 -3
  7. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/_transport.py +11 -6
  8. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/client.py +22 -5
  9. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/customers.py +51 -1
  10. meterbase-0.2.0/src/meterbase/_version.py +1 -0
  11. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/errors.py +1 -1
  12. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/types.py +20 -0
  13. meterbase-0.1.5/src/meterbase/_version.py +0 -1
  14. {meterbase-0.1.5 → meterbase-0.2.0}/.gitignore +0 -0
  15. {meterbase-0.1.5 → meterbase-0.2.0}/LICENSE +0 -0
  16. {meterbase-0.1.5 → meterbase-0.2.0}/pyproject.toml +0 -0
  17. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/__init__.py +0 -0
  18. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/__init__.py +0 -0
  19. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/_io.py +0 -0
  20. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/__init__.py +0 -0
  21. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_allowances.py +0 -0
  22. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
  23. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_plan.py +0 -0
  24. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/meters.py +0 -0
  25. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/plans.py +0 -0
  26. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_json.py +0 -0
  27. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_keys.py +0 -0
  28. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_models.py +0 -0
  29. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_not_given.py +0 -0
  30. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/__init__.py +0 -0
  31. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/_io.py +0 -0
  32. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/__init__.py +0 -0
  33. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_allowances.py +0 -0
  34. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
  35. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_plan.py +0 -0
  36. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/meters.py +0 -0
  37. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/plans.py +0 -0
  38. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_time.py +0 -0
  39. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/py.typed +0 -0
  40. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/resources.py +0 -0
  41. {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meterbase
3
- Version: 0.1.5
3
+ Version: 0.2.0
4
4
  Summary: Python SDK for the Meterbase engine
5
5
  Project-URL: Homepage, https://meterbase.tech
6
6
  Project-URL: Documentation, https://meterbase.tech/docs
@@ -185,11 +185,26 @@ see the error. When **you** supply the key, you get `TooLateError` with
185
185
  that was already recorded.
186
186
 
187
187
  **The SDK generates a key when you omit one, and reuses it across that call's
188
- retries** — including its own, since `track` is a `POST` it will replay.
189
- Supply your own when the caller already has an id for the work — a job id, a
190
- request id — so that a retry from further out than this SDK replays too. Keys
191
- are retained 35 days; a retry after that records a second event, which is not a
192
- case this API sets out to serve.
188
+ retries.** For a retry from further out than this SDK — a queue, a restarted
189
+ worker — mint the key once the work is done, and store it with the job before
190
+ the first send:
191
+
192
+ ```python
193
+ if job.usage_key is None:
194
+ job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
195
+ save_job(job) # before sending, so a rerun reuses it
196
+
197
+ meterbase.track(
198
+ customer_id=job.customer_id,
199
+ meter_id="ai_tokens",
200
+ quantity=job.tokens,
201
+ idempotency_key=job.usage_key,
202
+ )
203
+ ```
204
+
205
+ It is dated by the engine's clock as this client knows it, and the hour it is
206
+ admitted for starts then: mint it when the work is queued and a slow queue can
207
+ outlast it.
193
208
 
194
209
  ### Work you cannot do twice
195
210
 
@@ -256,6 +271,30 @@ afresh rather than finding anything.
256
271
 
257
272
  ### Customers
258
273
 
274
+ **At sign-up, ensure the customer.** One call gets or creates them by your own
275
+ id and gives them a first plan, and running it again changes nothing:
276
+
277
+ ```python
278
+ ensured = meterbase.customers.ensure(
279
+ external_id="acct_1",
280
+ name="Acme", # applied only when it creates the customer
281
+ plan="free", # a plan key
282
+ )
283
+ ensured.created, ensured.plan_result, ensured.customer.plan
284
+ ```
285
+
286
+ `plan_result` says what it did with the plan: `assigned`, `already_held`,
287
+ `kept_other` when the customer holds another plan, or `none` when you named
288
+ none. It never changes a plan the customer holds — a checkout that must move
289
+ them calls [`customers.plan`](#a-customers-plan) — and never cancels a
290
+ scheduled change. `cycle_anchor` puts a first plan's periods on a date the
291
+ customer already has, and `mint_one_time=False` writes the plan's one-time
292
+ grants already spent, for a customer you import with their remaining bonus.
293
+
294
+ `ensure` and `retrieve_by_external_id` raise `MeterbaseError` before sending
295
+ when the id is missing, empty or whitespace: without one, the lookup would name
296
+ no customer.
297
+
259
298
  ```python
260
299
  customer = meterbase.customers.create(
261
300
  external_id="acct_1",
@@ -811,14 +850,18 @@ SDK would raise for a response, for realistic errors in your own tests.
811
850
  Meterbase(
812
851
  api_key="mb_sk_live_…",
813
852
  base_url="https://api.meterbase.dev",
814
- timeout=35.0, # seconds, per attempt
853
+ timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
815
854
  max_retries=2,
816
855
  http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
817
856
  )
818
857
  ```
819
858
 
820
859
  `timeout` bounds each attempt — connecting, sending, and each wait for the
821
- engine to answer — not the whole call. Pass an `http_client` for proxies,
860
+ engine to answer — not the whole call. **`check` waits 3 s** unless the client
861
+ sets `timeout`: it gates a request someone is waiting on, and answers in
862
+ milliseconds when the engine is well. Decide what an `APITimeoutError` means
863
+ there — failing open or failing closed — rather than leaving the user waiting
864
+ out the 35 s the writes get. Pass an `http_client` for proxies,
822
865
  limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
823
866
  `httpx.AsyncClient` for `AsyncMeterbase`.
824
867
 
@@ -838,11 +881,13 @@ an attempt already on the wire may still land, but it is never replayed, so a
838
881
  cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
839
882
  bound it with `timeout` and `max_retries` instead.
840
883
 
841
- **Retries** apply to `GET`, `DELETE`, `track` and `reserve`, on connection
842
- failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
843
- `Retry-After` is honoured when the engine sends it, up to eight seconds. Every
844
- other `POST` and `PATCH` is never replayed, because a retried create could
845
- produce a second row.
884
+ **Retries** apply to `GET`, `DELETE`, `track`, `reserve` and
885
+ `customers.ensure`, on connection failures, timeouts, 408, 429 and 5xx, with
886
+ exponential backoff and full jitter. `Retry-After` is honoured when the engine
887
+ sends it, up to eight seconds. Every other `POST` and `PATCH` is never
888
+ replayed, because a retried create could produce a second row. An `ensure` can
889
+ be, because running it twice changes nothing: one whose first attempt landed
890
+ answers `created` false.
846
891
 
847
892
  `track` and `reserve` are the exception because they carry an idempotency key,
848
893
  and because the SDK fixes that key **before** the retry loop starts: every
@@ -871,6 +916,15 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
871
916
  change that will not run against your code cannot arrive as a patch. The SDK
872
917
  follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
873
918
 
919
+ **0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
920
+ behaviours:
921
+
922
+ - `check` waits 3 s by default. The other calls keep 35 s, and a `timeout` set
923
+ on the client, or through `with_options`, still wins.
924
+ - `retrieve_by_external_id` raises before sending when the id is missing, empty
925
+ or whitespace. Before, a `None` was dropped from the query, and the engine
926
+ answered with every customer, the newest first, which this returned.
927
+
874
928
  ## Development
875
929
 
876
930
  ```bash
@@ -158,11 +158,26 @@ see the error. When **you** supply the key, you get `TooLateError` with
158
158
  that was already recorded.
159
159
 
160
160
  **The SDK generates a key when you omit one, and reuses it across that call's
161
- retries** — including its own, since `track` is a `POST` it will replay.
162
- Supply your own when the caller already has an id for the work — a job id, a
163
- request id — so that a retry from further out than this SDK replays too. Keys
164
- are retained 35 days; a retry after that records a second event, which is not a
165
- case this API sets out to serve.
161
+ retries.** For a retry from further out than this SDK — a queue, a restarted
162
+ worker — mint the key once the work is done, and store it with the job before
163
+ the first send:
164
+
165
+ ```python
166
+ if job.usage_key is None:
167
+ job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
168
+ save_job(job) # before sending, so a rerun reuses it
169
+
170
+ meterbase.track(
171
+ customer_id=job.customer_id,
172
+ meter_id="ai_tokens",
173
+ quantity=job.tokens,
174
+ idempotency_key=job.usage_key,
175
+ )
176
+ ```
177
+
178
+ It is dated by the engine's clock as this client knows it, and the hour it is
179
+ admitted for starts then: mint it when the work is queued and a slow queue can
180
+ outlast it.
166
181
 
167
182
  ### Work you cannot do twice
168
183
 
@@ -229,6 +244,30 @@ afresh rather than finding anything.
229
244
 
230
245
  ### Customers
231
246
 
247
+ **At sign-up, ensure the customer.** One call gets or creates them by your own
248
+ id and gives them a first plan, and running it again changes nothing:
249
+
250
+ ```python
251
+ ensured = meterbase.customers.ensure(
252
+ external_id="acct_1",
253
+ name="Acme", # applied only when it creates the customer
254
+ plan="free", # a plan key
255
+ )
256
+ ensured.created, ensured.plan_result, ensured.customer.plan
257
+ ```
258
+
259
+ `plan_result` says what it did with the plan: `assigned`, `already_held`,
260
+ `kept_other` when the customer holds another plan, or `none` when you named
261
+ none. It never changes a plan the customer holds — a checkout that must move
262
+ them calls [`customers.plan`](#a-customers-plan) — and never cancels a
263
+ scheduled change. `cycle_anchor` puts a first plan's periods on a date the
264
+ customer already has, and `mint_one_time=False` writes the plan's one-time
265
+ grants already spent, for a customer you import with their remaining bonus.
266
+
267
+ `ensure` and `retrieve_by_external_id` raise `MeterbaseError` before sending
268
+ when the id is missing, empty or whitespace: without one, the lookup would name
269
+ no customer.
270
+
232
271
  ```python
233
272
  customer = meterbase.customers.create(
234
273
  external_id="acct_1",
@@ -784,14 +823,18 @@ SDK would raise for a response, for realistic errors in your own tests.
784
823
  Meterbase(
785
824
  api_key="mb_sk_live_…",
786
825
  base_url="https://api.meterbase.dev",
787
- timeout=35.0, # seconds, per attempt
826
+ timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
788
827
  max_retries=2,
789
828
  http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
790
829
  )
791
830
  ```
792
831
 
793
832
  `timeout` bounds each attempt — connecting, sending, and each wait for the
794
- engine to answer — not the whole call. Pass an `http_client` for proxies,
833
+ engine to answer — not the whole call. **`check` waits 3 s** unless the client
834
+ sets `timeout`: it gates a request someone is waiting on, and answers in
835
+ milliseconds when the engine is well. Decide what an `APITimeoutError` means
836
+ there — failing open or failing closed — rather than leaving the user waiting
837
+ out the 35 s the writes get. Pass an `http_client` for proxies,
795
838
  limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
796
839
  `httpx.AsyncClient` for `AsyncMeterbase`.
797
840
 
@@ -811,11 +854,13 @@ an attempt already on the wire may still land, but it is never replayed, so a
811
854
  cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
812
855
  bound it with `timeout` and `max_retries` instead.
813
856
 
814
- **Retries** apply to `GET`, `DELETE`, `track` and `reserve`, on connection
815
- failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
816
- `Retry-After` is honoured when the engine sends it, up to eight seconds. Every
817
- other `POST` and `PATCH` is never replayed, because a retried create could
818
- produce a second row.
857
+ **Retries** apply to `GET`, `DELETE`, `track`, `reserve` and
858
+ `customers.ensure`, on connection failures, timeouts, 408, 429 and 5xx, with
859
+ exponential backoff and full jitter. `Retry-After` is honoured when the engine
860
+ sends it, up to eight seconds. Every other `POST` and `PATCH` is never
861
+ replayed, because a retried create could produce a second row. An `ensure` can
862
+ be, because running it twice changes nothing: one whose first attempt landed
863
+ answers `created` false.
819
864
 
820
865
  `track` and `reserve` are the exception because they carry an idempotency key,
821
866
  and because the SDK fixes that key **before** the retry loop starts: every
@@ -844,6 +889,15 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
844
889
  change that will not run against your code cannot arrive as a patch. The SDK
845
890
  follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
846
891
 
892
+ **0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
893
+ behaviours:
894
+
895
+ - `check` waits 3 s by default. The other calls keep 35 s, and a `timeout` set
896
+ on the client, or through `with_options`, still wins.
897
+ - `retrieve_by_external_id` raises before sending when the id is missing, empty
898
+ or whitespace. Before, a `None` was dropped from the query, and the engine
899
+ answered with every customer, the newest first, which this returned.
900
+
847
901
  ## Development
848
902
 
849
903
  ```bash
@@ -13,6 +13,7 @@ from typing import Any, TypeVar
13
13
  import httpx
14
14
 
15
15
  from .._http import (
16
+ DEFAULT_TIMEOUT,
16
17
  Body,
17
18
  Query,
18
19
  RequestOptions,
@@ -70,17 +71,21 @@ class AsyncTransport:
70
71
  query: Query | None = None,
71
72
  body: Body | None = None,
72
73
  idempotent: bool = False,
74
+ default_timeout: float = DEFAULT_TIMEOUT,
73
75
  ) -> Any:
74
76
  """
75
77
  `idempotent` replays a call even though its method is not safe. Only a
76
78
  route that carries an idempotency key may set it, and the key has to be
77
79
  in `body` already: the loop re-sends one body, so a key generated per
78
80
  attempt would count the same usage twice.
81
+
82
+ `default_timeout` applies when the options set none.
79
83
  """
80
84
  # Safe methods, plus the routes that carry an idempotency key and say
81
85
  # so. Any other POST or PATCH could create a second row on a replay.
82
86
  retryable = method in ("GET", "DELETE") or idempotent
83
87
 
88
+ timeout = default_timeout if options.timeout is None else options.timeout
84
89
  url = build_url(self._base_url, path, query)
85
90
  content = None if body is None else encode_body(body)
86
91
  headers = request_headers(self._api_key, has_body=content is not None)
@@ -91,7 +96,7 @@ class AsyncTransport:
91
96
  await sleep(backoff(attempt, last_error))
92
97
 
93
98
  try:
94
- response = await self._send(method, url, headers, content, options)
99
+ response = await self._send(method, url, headers, content, timeout)
95
100
  except APIConnectionError as unanswered:
96
101
  # A connection failure proves nothing about whether the server
97
102
  # acted, so it is replayed under the same idempotency rule.
@@ -122,7 +127,7 @@ class AsyncTransport:
122
127
  url: str,
123
128
  headers: dict[str, str],
124
129
  content: bytes | None,
125
- options: RequestOptions,
130
+ timeout: float,
126
131
  ) -> httpx.Response:
127
132
  # Neither this nor the loop catches a BaseException — a cancelled task,
128
133
  # a Ctrl-C — so one ends the call where it stands and nothing is sent
@@ -134,12 +139,10 @@ class AsyncTransport:
134
139
  url,
135
140
  headers=headers,
136
141
  content=content,
137
- timeout=options.timeout,
142
+ timeout=timeout,
138
143
  )
139
144
  except httpx.TimeoutException as error:
140
- raise APITimeoutError(
141
- f"Request timed out after {options.timeout:g}s."
142
- ) from error
145
+ raise APITimeoutError(f"Request timed out after {timeout:g}s.") from error
143
146
  except httpx.TransportError as error:
144
147
  raise APIConnectionError(f"Could not reach {self._origin}.") from error
145
148
 
@@ -169,6 +172,7 @@ class AsyncRequester:
169
172
  query: Query | None = None,
170
173
  body: Body | None = None,
171
174
  idempotent: bool = False,
175
+ default_timeout: float = DEFAULT_TIMEOUT,
172
176
  ) -> _T:
173
177
  data = await self.transport.request(
174
178
  method,
@@ -177,5 +181,6 @@ class AsyncRequester:
177
181
  query=query,
178
182
  body=body,
179
183
  idempotent=idempotent,
184
+ default_timeout=default_timeout,
180
185
  )
181
186
  return construct(cast_to, data)
@@ -8,8 +8,8 @@ from typing import Any, TypeVar
8
8
  import httpx
9
9
 
10
10
  from .._http import (
11
+ CHECK_TIMEOUT,
11
12
  DEFAULT_MAX_RETRIES,
12
- DEFAULT_TIMEOUT,
13
13
  RequestOptions,
14
14
  normalize_base_url,
15
15
  )
@@ -34,9 +34,9 @@ class AsyncMeterbase:
34
34
  there is no workspace to pass.
35
35
 
36
36
  `timeout` is in seconds and applies to each attempt rather than to the
37
- whole call. `max_retries` counts the attempts after the first. Pass an
38
- `http_client` to configure proxies, limits or transports yourself; it
39
- stays yours to close.
37
+ whole call. Unset, it is 35 s, and 3 s for `check`. `max_retries` counts
38
+ the attempts after the first. Pass an `http_client` to configure proxies,
39
+ limits or transports yourself; it stays yours to close.
40
40
  """
41
41
 
42
42
  customers: AsyncCustomers
@@ -48,7 +48,7 @@ class AsyncMeterbase:
48
48
  *,
49
49
  api_key: str,
50
50
  base_url: str | None = None,
51
- timeout: float = DEFAULT_TIMEOUT,
51
+ timeout: float | None = None,
52
52
  max_retries: int = DEFAULT_MAX_RETRIES,
53
53
  http_client: httpx.AsyncClient | None = None,
54
54
  ) -> None:
@@ -121,6 +121,11 @@ class AsyncMeterbase:
121
121
 
122
122
  `quantity` is what is about to be consumed, `1 … 100000000000`.
123
123
  Omitted, it is 1, which asks whether there is any capacity at all.
124
+
125
+ Waits 3 s unless the client sets `timeout`: it gates a request someone
126
+ is waiting on, and answers in milliseconds when the engine is well.
127
+ Decide what an `APITimeoutError` means here, failing open or closed,
128
+ rather than waiting out the 35 s the writes are given.
124
129
  """
125
130
  return await self._api.request(
126
131
  "POST",
@@ -131,6 +136,7 @@ class AsyncMeterbase:
131
136
  "meter_id": meter_id,
132
137
  "quantity": quantity,
133
138
  },
139
+ default_timeout=CHECK_TIMEOUT,
134
140
  )
135
141
 
136
142
  async def track(
@@ -246,6 +252,17 @@ class AsyncMeterbase:
246
252
  """Reports which workspace this key acts for."""
247
253
  return await self._api.request("GET", "/v1/whoami", WhoAmI)
248
254
 
255
+ def idempotency_key(self) -> str:
256
+ """
257
+ A UUIDv7 dated by the engine's clock as this client knows it: an
258
+ `idempotency_key` for `track`, or a `reservation_id`. For a retry from
259
+ further out than this SDK, mint one once the work is done, store it
260
+ with the job before the first send, and send it on every retry. The
261
+ engine admits a key for an hour from its date, and a reservation id
262
+ for 30 minutes.
263
+ """
264
+ return mint(self._api.transport.now_ms())
265
+
249
266
  async def aclose(self) -> None:
250
267
  """Closes the connections this client opened. An `http_client` passed
251
268
  in stays open: it is yours."""
@@ -3,12 +3,13 @@ from __future__ import annotations
3
3
  from collections.abc import Mapping, Sequence
4
4
  from typing import Any, overload
5
5
 
6
- from ..._http import segment
6
+ from ..._http import require_external_id, segment
7
7
  from ..._models import ListResult, list_of
8
8
  from ..._not_given import NOT_GIVEN, NotGiven
9
9
  from ...errors import NotFoundError
10
10
  from ...types import (
11
11
  Customer,
12
+ CustomerEnsureResult,
12
13
  CustomerEntitlement,
13
14
  CustomerUsage,
14
15
  CustomerWithPlan,
@@ -59,6 +60,50 @@ class AsyncCustomers:
59
60
  },
60
61
  )
61
62
 
63
+ async def ensure(
64
+ self,
65
+ *,
66
+ external_id: str,
67
+ name: str | NotGiven = NOT_GIVEN,
68
+ metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
69
+ plan: str | NotGiven = NOT_GIVEN,
70
+ cycle_anchor: str | NotGiven = NOT_GIVEN,
71
+ mint_one_time: bool | NotGiven = NOT_GIVEN,
72
+ ) -> CustomerEnsureResult:
73
+ """
74
+ Gets or creates the customer by `external_id`, and gives one who holds
75
+ no plan the `plan` named, in one transaction. Safe on every sign-up and
76
+ safe to retry: rerunning it changes nothing, and it never changes a
77
+ plan the customer already holds. `plan_result` says which of those
78
+ happened.
79
+
80
+ `name` and `metadata` apply only to a customer this creates. `plan` is
81
+ a plan key; omit it to leave the plan alone. `cycle_anchor`, RFC 3339,
82
+ only with `plan` and never in the future, is used only when this call
83
+ assigns the plan. `mint_one_time=False` writes the plan's one-time
84
+ grants already spent, for an imported customer whose remaining bonus
85
+ you grant yourself.
86
+
87
+ Raises `MeterbaseError` before sending when `external_id` is missing,
88
+ empty or whitespace.
89
+ """
90
+ require_external_id("ensure", external_id)
91
+
92
+ return await self._api.request(
93
+ "POST",
94
+ "/v1/customers/ensure",
95
+ CustomerEnsureResult,
96
+ body={
97
+ "external_id": external_id,
98
+ "name": name,
99
+ "metadata": metadata,
100
+ "plan": plan,
101
+ "cycle_anchor": cycle_anchor,
102
+ "mint_one_time": mint_one_time,
103
+ },
104
+ idempotent=True,
105
+ )
106
+
62
107
  async def list(
63
108
  self, *, include_deleted: bool | NotGiven = NOT_GIVEN
64
109
  ) -> ListResult[Customer]:
@@ -88,7 +133,12 @@ class AsyncCustomers:
88
133
  Resolves the tenant's own identifier, or `None`, embedding the plan the
89
134
  way `retrieve` does. The engine answers this as a filtered list, so a
90
135
  miss is an empty collection rather than a 404.
136
+
137
+ Raises `MeterbaseError` before sending when `external_id` is missing,
138
+ empty or whitespace.
91
139
  """
140
+ require_external_id("retrieve_by_external_id", external_id)
141
+
92
142
  found = await self._api.request(
93
143
  "GET",
94
144
  "/v1/customers",
@@ -24,6 +24,7 @@ from .errors import APIError, MeterbaseError, error_from_response
24
24
 
25
25
  DEFAULT_BASE_URL = "https://api.meterbase.tech"
26
26
  DEFAULT_TIMEOUT = 35.0
27
+ CHECK_TIMEOUT = 3.0
27
28
  DEFAULT_MAX_RETRIES = 2
28
29
  MAX_BACKOFF = 8.0
29
30
 
@@ -35,8 +36,9 @@ Body = Mapping[str, object]
35
36
 
36
37
  @dataclass(frozen=True)
37
38
  class RequestOptions:
38
- timeout: float
39
- """Seconds, per attempt rather than per call."""
39
+ timeout: float | None
40
+ """Seconds, per attempt rather than per call. `None` leaves each call its
41
+ own default."""
40
42
  max_retries: int
41
43
 
42
44
  def __post_init__(self) -> None:
@@ -44,7 +46,7 @@ class RequestOptions:
44
46
  # makes no attempt has nothing to answer with.
45
47
  if self.max_retries < 0:
46
48
  raise MeterbaseError("max_retries should be 0 or more.")
47
- if not self.timeout > 0:
49
+ if self.timeout is not None and not self.timeout > 0:
48
50
  raise MeterbaseError("timeout should be a number of seconds above 0.")
49
51
 
50
52
 
@@ -73,6 +75,17 @@ def segment(value: str) -> str:
73
75
  return quote(value, safe="")
74
76
 
75
77
 
78
+ def require_external_id(method: str, external_id: object) -> None:
79
+ """
80
+ Without an id the lookup becomes an unfiltered list, which older engines
81
+ answered newest first: the caller got another customer back.
82
+ """
83
+ if external_id is None or str(external_id).strip() == "":
84
+ raise MeterbaseError(
85
+ f"customers.{method} needs an external id, and got {external_id!r}."
86
+ )
87
+
88
+
76
89
  def build_url(base_url: str, path: str, query: Query | None) -> str:
77
90
  pairs = [
78
91
  (key, _query_value(value))
@@ -17,6 +17,7 @@ from typing import Any, TypeVar
17
17
  import httpx
18
18
 
19
19
  from .._http import (
20
+ DEFAULT_TIMEOUT,
20
21
  Body,
21
22
  Query,
22
23
  RequestOptions,
@@ -74,17 +75,21 @@ class Transport:
74
75
  query: Query | None = None,
75
76
  body: Body | None = None,
76
77
  idempotent: bool = False,
78
+ default_timeout: float = DEFAULT_TIMEOUT,
77
79
  ) -> Any:
78
80
  """
79
81
  `idempotent` replays a call even though its method is not safe. Only a
80
82
  route that carries an idempotency key may set it, and the key has to be
81
83
  in `body` already: the loop re-sends one body, so a key generated per
82
84
  attempt would count the same usage twice.
85
+
86
+ `default_timeout` applies when the options set none.
83
87
  """
84
88
  # Safe methods, plus the routes that carry an idempotency key and say
85
89
  # so. Any other POST or PATCH could create a second row on a replay.
86
90
  retryable = method in ("GET", "DELETE") or idempotent
87
91
 
92
+ timeout = default_timeout if options.timeout is None else options.timeout
88
93
  url = build_url(self._base_url, path, query)
89
94
  content = None if body is None else encode_body(body)
90
95
  headers = request_headers(self._api_key, has_body=content is not None)
@@ -95,7 +100,7 @@ class Transport:
95
100
  sleep(backoff(attempt, last_error))
96
101
 
97
102
  try:
98
- response = self._send(method, url, headers, content, options)
103
+ response = self._send(method, url, headers, content, timeout)
99
104
  except APIConnectionError as unanswered:
100
105
  # A connection failure proves nothing about whether the server
101
106
  # acted, so it is replayed under the same idempotency rule.
@@ -126,7 +131,7 @@ class Transport:
126
131
  url: str,
127
132
  headers: dict[str, str],
128
133
  content: bytes | None,
129
- options: RequestOptions,
134
+ timeout: float,
130
135
  ) -> httpx.Response:
131
136
  # Neither this nor the loop catches a BaseException — a cancelled task,
132
137
  # a Ctrl-C — so one ends the call where it stands and nothing is sent
@@ -138,12 +143,10 @@ class Transport:
138
143
  url,
139
144
  headers=headers,
140
145
  content=content,
141
- timeout=options.timeout,
146
+ timeout=timeout,
142
147
  )
143
148
  except httpx.TimeoutException as error:
144
- raise APITimeoutError(
145
- f"Request timed out after {options.timeout:g}s."
146
- ) from error
149
+ raise APITimeoutError(f"Request timed out after {timeout:g}s.") from error
147
150
  except httpx.TransportError as error:
148
151
  raise APIConnectionError(f"Could not reach {self._origin}.") from error
149
152
 
@@ -173,6 +176,7 @@ class Requester:
173
176
  query: Query | None = None,
174
177
  body: Body | None = None,
175
178
  idempotent: bool = False,
179
+ default_timeout: float = DEFAULT_TIMEOUT,
176
180
  ) -> _T:
177
181
  data = self.transport.request(
178
182
  method,
@@ -181,5 +185,6 @@ class Requester:
181
185
  query=query,
182
186
  body=body,
183
187
  idempotent=idempotent,
188
+ default_timeout=default_timeout,
184
189
  )
185
190
  return construct(cast_to, data)
@@ -12,8 +12,8 @@ from typing import Any, TypeVar
12
12
  import httpx
13
13
 
14
14
  from .._http import (
15
+ CHECK_TIMEOUT,
15
16
  DEFAULT_MAX_RETRIES,
16
- DEFAULT_TIMEOUT,
17
17
  RequestOptions,
18
18
  normalize_base_url,
19
19
  )
@@ -38,9 +38,9 @@ class Meterbase:
38
38
  there is no workspace to pass.
39
39
 
40
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.
41
+ whole call. Unset, it is 35 s, and 3 s for `check`. `max_retries` counts
42
+ the attempts after the first. Pass an `http_client` to configure proxies,
43
+ limits or transports yourself; it stays yours to close.
44
44
  """
45
45
 
46
46
  customers: Customers
@@ -52,7 +52,7 @@ class Meterbase:
52
52
  *,
53
53
  api_key: str,
54
54
  base_url: str | None = None,
55
- timeout: float = DEFAULT_TIMEOUT,
55
+ timeout: float | None = None,
56
56
  max_retries: int = DEFAULT_MAX_RETRIES,
57
57
  http_client: httpx.Client | None = None,
58
58
  ) -> None:
@@ -125,6 +125,11 @@ class Meterbase:
125
125
 
126
126
  `quantity` is what is about to be consumed, `1 … 100000000000`.
127
127
  Omitted, it is 1, which asks whether there is any capacity at all.
128
+
129
+ Waits 3 s unless the client sets `timeout`: it gates a request someone
130
+ is waiting on, and answers in milliseconds when the engine is well.
131
+ Decide what an `APITimeoutError` means here, failing open or closed,
132
+ rather than waiting out the 35 s the writes are given.
128
133
  """
129
134
  return self._api.request(
130
135
  "POST",
@@ -135,6 +140,7 @@ class Meterbase:
135
140
  "meter_id": meter_id,
136
141
  "quantity": quantity,
137
142
  },
143
+ default_timeout=CHECK_TIMEOUT,
138
144
  )
139
145
 
140
146
  def track(
@@ -250,6 +256,17 @@ class Meterbase:
250
256
  """Reports which workspace this key acts for."""
251
257
  return self._api.request("GET", "/v1/whoami", WhoAmI)
252
258
 
259
+ def idempotency_key(self) -> str:
260
+ """
261
+ A UUIDv7 dated by the engine's clock as this client knows it: an
262
+ `idempotency_key` for `track`, or a `reservation_id`. For a retry from
263
+ further out than this SDK, mint one once the work is done, store it
264
+ with the job before the first send, and send it on every retry. The
265
+ engine admits a key for an hour from its date, and a reservation id
266
+ for 30 minutes.
267
+ """
268
+ return mint(self._api.transport.now_ms())
269
+
253
270
  def close(self) -> None:
254
271
  """Closes the connections this client opened. An `http_client` passed
255
272
  in stays open: it is yours."""
@@ -7,12 +7,13 @@ from __future__ import annotations
7
7
  from collections.abc import Mapping, Sequence
8
8
  from typing import Any, overload
9
9
 
10
- from ..._http import segment
10
+ from ..._http import require_external_id, segment
11
11
  from ..._models import ListResult, list_of
12
12
  from ..._not_given import NOT_GIVEN, NotGiven
13
13
  from ...errors import NotFoundError
14
14
  from ...types import (
15
15
  Customer,
16
+ CustomerEnsureResult,
16
17
  CustomerEntitlement,
17
18
  CustomerUsage,
18
19
  CustomerWithPlan,
@@ -63,6 +64,50 @@ class Customers:
63
64
  },
64
65
  )
65
66
 
67
+ def ensure(
68
+ self,
69
+ *,
70
+ external_id: str,
71
+ name: str | NotGiven = NOT_GIVEN,
72
+ metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
73
+ plan: str | NotGiven = NOT_GIVEN,
74
+ cycle_anchor: str | NotGiven = NOT_GIVEN,
75
+ mint_one_time: bool | NotGiven = NOT_GIVEN,
76
+ ) -> CustomerEnsureResult:
77
+ """
78
+ Gets or creates the customer by `external_id`, and gives one who holds
79
+ no plan the `plan` named, in one transaction. Safe on every sign-up and
80
+ safe to retry: rerunning it changes nothing, and it never changes a
81
+ plan the customer already holds. `plan_result` says which of those
82
+ happened.
83
+
84
+ `name` and `metadata` apply only to a customer this creates. `plan` is
85
+ a plan key; omit it to leave the plan alone. `cycle_anchor`, RFC 3339,
86
+ only with `plan` and never in the future, is used only when this call
87
+ assigns the plan. `mint_one_time=False` writes the plan's one-time
88
+ grants already spent, for an imported customer whose remaining bonus
89
+ you grant yourself.
90
+
91
+ Raises `MeterbaseError` before sending when `external_id` is missing,
92
+ empty or whitespace.
93
+ """
94
+ require_external_id("ensure", external_id)
95
+
96
+ return self._api.request(
97
+ "POST",
98
+ "/v1/customers/ensure",
99
+ CustomerEnsureResult,
100
+ body={
101
+ "external_id": external_id,
102
+ "name": name,
103
+ "metadata": metadata,
104
+ "plan": plan,
105
+ "cycle_anchor": cycle_anchor,
106
+ "mint_one_time": mint_one_time,
107
+ },
108
+ idempotent=True,
109
+ )
110
+
66
111
  def list(
67
112
  self, *, include_deleted: bool | NotGiven = NOT_GIVEN
68
113
  ) -> ListResult[Customer]:
@@ -90,7 +135,12 @@ class Customers:
90
135
  Resolves the tenant's own identifier, or `None`, embedding the plan the
91
136
  way `retrieve` does. The engine answers this as a filtered list, so a
92
137
  miss is an empty collection rather than a 404.
138
+
139
+ Raises `MeterbaseError` before sending when `external_id` is missing,
140
+ empty or whitespace.
93
141
  """
142
+ require_external_id("retrieve_by_external_id", external_id)
143
+
94
144
  found = self._api.request(
95
145
  "GET",
96
146
  "/v1/customers",
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -269,7 +269,7 @@ def error_from_response(
269
269
 
270
270
  if status == 400:
271
271
  # A parameter that failed validation, as a 422 is a field that did.
272
- if code == "invalid_query":
272
+ if code in ("invalid_query", "invalid_external_id"):
273
273
  return InvalidRequestError(**fields)
274
274
  return APIError(**fields)
275
275
  if status == 401:
@@ -27,11 +27,13 @@ __all__ = [
27
27
  "ClosedPeriod",
28
28
  "ClosedPeriodMeter",
29
29
  "Customer",
30
+ "CustomerEnsureResult",
30
31
  "CustomerEntitlement",
31
32
  "CustomerUsage",
32
33
  "CustomerWithPlan",
33
34
  "DayUsage",
34
35
  "EmbeddedPlan",
36
+ "EnsurePlanResult",
35
37
  "EntitlementReachedEvent",
36
38
  "EntitlementState",
37
39
  "IncomingVersion",
@@ -140,6 +142,24 @@ class CustomerWithPlan(Customer):
140
142
  pending_plan: EmbeddedPlan | None
141
143
 
142
144
 
145
+ EnsurePlanResult = Literal["assigned", "already_held", "kept_other", "none"]
146
+ """
147
+ What `ensure` did with the plan it named. It never changes a plan: a customer
148
+ holding another one is `kept_other`, and moving them stays `customers.plan`'s
149
+ job. `none` when no plan was named.
150
+ """
151
+
152
+
153
+ class CustomerEnsureResult(Model):
154
+ customer: CustomerWithPlan
155
+ """As the call left it, with the plan embedded as `retrieve` embeds it."""
156
+ created: bool
157
+ plan_result: EnsurePlanResult
158
+ cycle_anchor_differs: bool
159
+ """`cycle_anchor` was sent, and the plan the customer already held runs on
160
+ another anchor. Nothing changed: `customer.plan.anchor_at` is in force."""
161
+
162
+
143
163
  # Check ----------------------------------------------------------------------
144
164
 
145
165
  CheckReason = Literal["entitlement", "rate_limit"] | str
@@ -1 +0,0 @@
1
- __version__ = "0.1.5"
File without changes
File without changes
File without changes