mbase-sdk 0.0.2 → 0.0.5
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.
- package/README.md +133 -32
- package/dist/index.cjs +209 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +219 -24
- package/dist/index.d.ts +219 -24
- package/dist/index.js +207 -33
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,20 +12,25 @@ npm install @meterbase/sdk
|
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
15
|
-
Two calls, in this order: **`check` → do the work → `track
|
|
15
|
+
Two calls, in this order: **`check` → do the work → `track`** — or, for work
|
|
16
|
+
that must not be done twice, [`reserve`](#work-you-cannot-do-twice).
|
|
16
17
|
|
|
17
18
|
```ts
|
|
18
19
|
import { Meterbase } from "@meterbase/sdk"
|
|
19
20
|
|
|
20
21
|
const meterbase = new Meterbase({ apiKey: process.env.METERBASE_API_KEY! })
|
|
21
22
|
|
|
22
|
-
const { allowed } = await meterbase.check({
|
|
23
|
+
const { allowed, reason, rate_limits } = await meterbase.check({
|
|
23
24
|
customer_id: "acct_1", // the tenant's own id, not ours
|
|
24
25
|
meter_id: "ai_tokens", // the meter's key, not ours
|
|
25
26
|
quantity: 50_000, // what you are about to spend; defaults to 1
|
|
26
27
|
})
|
|
27
28
|
|
|
28
|
-
if (!allowed) throw new Error(
|
|
29
|
+
if (!allowed) throw new Error(`Refused: ${reason}`)
|
|
30
|
+
|
|
31
|
+
// Allowed, and still worth reading: every window comes back on every call.
|
|
32
|
+
const tight = rate_limits.find((w) => w.remaining < w.max / 10)
|
|
33
|
+
if (tight) console.warn(`Slowing down until ${tight.resets_at}`)
|
|
29
34
|
|
|
30
35
|
const completion = await generate(prompt)
|
|
31
36
|
|
|
@@ -48,6 +53,16 @@ capacity is not a number. It is one figure and not a breakdown: `check` answers
|
|
|
48
53
|
from a single cached integer, and itemising where the capacity came from would
|
|
49
54
|
cost it that. For the per-grant detail, read a customer's allowances.
|
|
50
55
|
|
|
56
|
+
A check answers **two gates**, and `allowed` needs both: the capacity has to
|
|
57
|
+
cover the quantity, and every rate limit in force — _at most so much of this
|
|
58
|
+
meter per minute, hour or day_ — has to admit it. `reason` names the one that
|
|
59
|
+
said no, and is absent when the answer is yes. `rate_limits` reports every
|
|
60
|
+
window whether or not it refused, so a caller can ease off as its headroom
|
|
61
|
+
closes instead of finding the ceiling by hitting it; it is `[]` when no rule
|
|
62
|
+
applies. A limit is protective and not commercial, so it is never folded into
|
|
63
|
+
`available`: a customer well inside their plan can still be limited, and one on
|
|
64
|
+
`no_cap` is precisely the customer a runaway loop costs most.
|
|
65
|
+
|
|
51
66
|
### Recording usage
|
|
52
67
|
|
|
53
68
|
`track` counts work that already happened — the tokens were spent, the image
|
|
@@ -60,7 +75,7 @@ const { event } = await meterbase.track({
|
|
|
60
75
|
customer_id: "acct_1",
|
|
61
76
|
meter_id: "ai_tokens",
|
|
62
77
|
quantity: 250_000,
|
|
63
|
-
idempotency_key: "
|
|
78
|
+
idempotency_key: "0199e0c0-8f3a-7c21-9d44-6b2e91a0f5c3", // a UUIDv7; generated when you omit it
|
|
64
79
|
})
|
|
65
80
|
|
|
66
81
|
event.replayed // false the first time, true for a retry of the same call
|
|
@@ -75,16 +90,37 @@ bill drawn from a period is never moved by a report that arrives after it.
|
|
|
75
90
|
|
|
76
91
|
#### Idempotency
|
|
77
92
|
|
|
78
|
-
`
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
The
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
`idempotency_key` identifies one logical event, and **is** that event's id.
|
|
94
|
+
It has to be a **UUIDv7**, whose first 48 bits are the millisecond it was
|
|
95
|
+
minted. That timestamp is not decoration: the engine admits a key only while
|
|
96
|
+
it is within **one hour** of the engine's clock, and keeps the claim behind it
|
|
97
|
+
until exactly that hour is up. A retry is therefore recognised for as long as
|
|
98
|
+
it is accepted at all.
|
|
99
|
+
|
|
100
|
+
| The call sends | The engine answers |
|
|
101
|
+
| ------------------------------------- | ------------------------------------------------- |
|
|
102
|
+
| a fresh key | the recorded event, `replayed: false` |
|
|
103
|
+
| a key it has already seen | that event with `replayed: true`, writing nothing |
|
|
104
|
+
| a key more than an hour old, or ahead | `422 too_late`, writing nothing |
|
|
105
|
+
| anything that is not a UUIDv7 | `422 invalid_idempotency_key`, writing nothing |
|
|
106
|
+
|
|
107
|
+
A key it has already seen replays **whatever the rest of the body says**:
|
|
108
|
+
there is one claim behind the key and nothing to compare a retry against, so
|
|
109
|
+
a changed quantity is a replay rather than an error. The upside is that a
|
|
110
|
+
replay costs one lookup and stores nothing.
|
|
111
|
+
|
|
112
|
+
**The hour is a retry budget.** Retry inside it and your call is recognised.
|
|
113
|
+
Past it, `too_late` — and do not then resend under a fresh key, because the
|
|
114
|
+
original may have been recorded. Nothing is written on either refusal: both
|
|
115
|
+
are checked before the engine touches anything.
|
|
116
|
+
|
|
117
|
+
**Clocks matter now.** The key is minted here, from this machine's clock, so
|
|
118
|
+
a device running more than an hour off would have every call refused. When
|
|
119
|
+
this SDK mints the key it handles that itself: `too_late` carries the engine's
|
|
120
|
+
`server_time`, the client corrects its offset and retries once, and you never
|
|
121
|
+
see the error. When **you** supply the key, you get `TooLateError` with
|
|
122
|
+
`serverTime` on it, because only you can know whether that key names work that
|
|
123
|
+
was already recorded.
|
|
88
124
|
|
|
89
125
|
**The SDK generates a key when you omit one, and reuses it across that call's
|
|
90
126
|
retries** — including its own, since `track` is the one `POST` it will replay.
|
|
@@ -93,6 +129,68 @@ request id — so that a retry from further out than this SDK replays too. Keys
|
|
|
93
129
|
are retained 35 days; a retry after that records a second event, which is not a
|
|
94
130
|
case this API sets out to serve.
|
|
95
131
|
|
|
132
|
+
### Work you cannot do twice
|
|
133
|
+
|
|
134
|
+
`check` → work → `track` has a hole: two concurrent callers both pass the gate,
|
|
135
|
+
both do the work, and both record it. For an API call that costs nothing that
|
|
136
|
+
is an accepted trade. For an LLM completion, an image, a transcode, it is the
|
|
137
|
+
whole problem — the customer is over their cap and the money is already spent.
|
|
138
|
+
|
|
139
|
+
`reserve` closes it. It makes the same decision `check` makes and then **holds**
|
|
140
|
+
the capacity, so two reserves for the same last units cannot both be granted:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const hold = await meterbase.reserve({
|
|
144
|
+
customer_id: "acct_1",
|
|
145
|
+
meter_id: "ai_tokens",
|
|
146
|
+
quantity: 50_000, // what you are about to spend; defaults to 1
|
|
147
|
+
expires_in_seconds: 60, // how long to hold it; defaults to 60, at most 900
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
if (!hold.allowed) throw new Error(`Refused: ${hold.reason}`)
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const completion = await generate(prompt)
|
|
154
|
+
await hold.commit(completion.usage.total_tokens) // records what it cost
|
|
155
|
+
} catch (error) {
|
|
156
|
+
await hold.release() // nothing happened, so nothing is recorded
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**The rule for choosing: can you afford to do the work twice? `check`. No?
|
|
162
|
+
`reserve`.** A reserve costs a database transaction where a check costs a cached
|
|
163
|
+
read — refusals included — so it is not the call to put in front of cheap work.
|
|
164
|
+
|
|
165
|
+
A reserve answers everything `check` answers, plus the hold: `reservation_id`,
|
|
166
|
+
the `quantity` it set aside, and `expires_at`. `available` is already net of it.
|
|
167
|
+
A refusal holds nothing, so it carries no id and no expiry, and `allowed`
|
|
168
|
+
narrows the type — after `if (!hold.allowed)`, `commit` and `release` are there.
|
|
169
|
+
|
|
170
|
+
`commit` is `track` under the hold's own id, which is what makes it a commit
|
|
171
|
+
rather than a second event beside it. Pass what the work actually cost and the
|
|
172
|
+
difference comes back; omit it to record what was held. Going over is allowed —
|
|
173
|
+
the hold only ever guaranteed what it reserved.
|
|
174
|
+
|
|
175
|
+
`release` gives the capacity back and records nothing. It is built for the
|
|
176
|
+
`catch` block, so it resolves with `null` instead of throwing when there is
|
|
177
|
+
nothing left to release — already committed, already released — because
|
|
178
|
+
throwing there would mask the error you are already handling.
|
|
179
|
+
`meterbase.release({ reservation_id })` throws that 404, as every other call
|
|
180
|
+
does.
|
|
181
|
+
|
|
182
|
+
**Nothing is lost if your process dies.** A hold expires on its own, and the
|
|
183
|
+
capacity comes back with nothing having run. A commit that arrives after
|
|
184
|
+
`expires_at` is still recorded — you lose the guarantee, not the event — and
|
|
185
|
+
the SDK warns when that happens, which is the only feedback there is on an
|
|
186
|
+
`expires_in_seconds` guessed too short.
|
|
187
|
+
|
|
188
|
+
`reservation_id` is a UUIDv7 on the same terms as `idempotency_key`, generated
|
|
189
|
+
when you omit it, and it becomes the event's id once the hold is committed. One
|
|
190
|
+
id names the hold, the claim and the event. An id is for one reserve and its
|
|
191
|
+
retries: once its hold is committed or released, sending it again reserves
|
|
192
|
+
afresh rather than finding anything.
|
|
193
|
+
|
|
96
194
|
### Customers
|
|
97
195
|
|
|
98
196
|
```ts
|
|
@@ -362,18 +460,19 @@ try {
|
|
|
362
460
|
}
|
|
363
461
|
```
|
|
364
462
|
|
|
365
|
-
| Class | When
|
|
366
|
-
| ---------------------------------- |
|
|
367
|
-
| `AuthenticationError` | 401 — unknown, revoked or malformed key
|
|
368
|
-
| `PermissionDeniedError` | 403 — the key may not use this route
|
|
369
|
-
| `NotFoundError` | 404
|
|
370
|
-
| `ConflictError` | 409 — a key or external id is taken
|
|
371
|
-
| `
|
|
372
|
-
| `
|
|
373
|
-
| `
|
|
374
|
-
| `
|
|
375
|
-
| `
|
|
376
|
-
| `
|
|
463
|
+
| Class | When |
|
|
464
|
+
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
465
|
+
| `AuthenticationError` | 401 — unknown, revoked or malformed key |
|
|
466
|
+
| `PermissionDeniedError` | 403 — the key may not use this route |
|
|
467
|
+
| `NotFoundError` | 404 |
|
|
468
|
+
| `ConflictError` | 409 — a key or external id is taken |
|
|
469
|
+
| `InvalidRequestError` | 422 — a field failed validation |
|
|
470
|
+
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
471
|
+
| `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
|
|
472
|
+
| `TooLateError` | 422 — an `InvalidRequestError`: the key is over an hour from the engine's clock, which it carries on `serverTime`. Nothing was recorded |
|
|
473
|
+
| `RateLimitError` | 429 |
|
|
474
|
+
| `ServerError` | 5xx |
|
|
475
|
+
| `ConnectionError` / `TimeoutError` | the request never got an answer |
|
|
377
476
|
|
|
378
477
|
`APIError` carries `status`, `code` and the parsed `body`. Branch on `code`,
|
|
379
478
|
which is stable; `message` is for humans and may change.
|
|
@@ -405,7 +504,8 @@ because a retried create could produce a second row.
|
|
|
405
504
|
SDK fixes that key **before** the retry loop starts: every attempt of one call
|
|
406
505
|
names the same logical event, so the engine replays it instead of counting the
|
|
407
506
|
work twice. If you retry `track` yourself, pass your own `idempotency_key` and
|
|
408
|
-
keep it the same across attempts for exactly that reason
|
|
507
|
+
keep it the same across attempts for exactly that reason — and do it inside
|
|
508
|
+
the hour the engine holds that key for.
|
|
409
509
|
|
|
410
510
|
## Types
|
|
411
511
|
|
|
@@ -481,11 +581,12 @@ It asserts the whole lifecycle of a customer, a meter and a plan; that a plan's
|
|
|
481
581
|
allowances become real entitlement a `check` can see, with the recurring cap
|
|
482
582
|
and the minted bonus summed into `available`; that tracked usage takes exactly
|
|
483
583
|
that much away, that a replay takes nothing, and that a reused key naming a
|
|
484
|
-
different event is refused; that a
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
584
|
+
different event is refused; that a rate limit set on a meter comes back on
|
|
585
|
+
every `check` as a live window, and refuses with the allowance untouched; that
|
|
586
|
+
a scheduled plan change is dated ahead and reads as `pending`, that cancelling
|
|
587
|
+
it supersedes it, that a `restart` re-anchors the customer, and that two plans
|
|
588
|
+
on different cycles refuse a scheduled change; that soft deletes and archiving
|
|
589
|
+
behave as documented; that duplicates really answer
|
|
489
590
|
`409 customer_external_id_taken` / `409 meter_key_taken`; and — via
|
|
490
591
|
`Record<keyof T, true>` field maps — that the engine's fields still match the
|
|
491
592
|
SDK's types exactly, in both directions.
|
package/dist/index.cjs
CHANGED
|
@@ -29,16 +29,19 @@ var NotFoundError = class extends APIError {
|
|
|
29
29
|
};
|
|
30
30
|
var ConflictError = class extends APIError {
|
|
31
31
|
};
|
|
32
|
-
var
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
var InvalidRequestError = class extends APIError {
|
|
33
|
+
};
|
|
34
|
+
var InvalidIdempotencyKeyError = class extends InvalidRequestError {
|
|
35
|
+
};
|
|
36
|
+
var TooLateError = class extends InvalidRequestError {
|
|
37
|
+
/** The engine's clock when it refused. */
|
|
38
|
+
serverTime;
|
|
36
39
|
constructor(args) {
|
|
37
40
|
super(args);
|
|
38
|
-
this.
|
|
41
|
+
this.serverTime = serverTime(args.body);
|
|
39
42
|
}
|
|
40
43
|
};
|
|
41
|
-
var
|
|
44
|
+
var ReservationMismatchError = class extends InvalidRequestError {
|
|
42
45
|
};
|
|
43
46
|
var CycleChangeRequiresResetError = class extends InvalidRequestError {
|
|
44
47
|
};
|
|
@@ -65,21 +68,34 @@ function errorFromResponse(args) {
|
|
|
65
68
|
case 404:
|
|
66
69
|
return new NotFoundError(args);
|
|
67
70
|
case 409:
|
|
68
|
-
return
|
|
71
|
+
return new ConflictError(args);
|
|
69
72
|
case 422:
|
|
70
|
-
|
|
73
|
+
switch (args.code) {
|
|
74
|
+
case "cycle_change_requires_reset":
|
|
75
|
+
return new CycleChangeRequiresResetError(args);
|
|
76
|
+
case "invalid_idempotency_key":
|
|
77
|
+
return new InvalidIdempotencyKeyError(args);
|
|
78
|
+
case "reservation_mismatch":
|
|
79
|
+
return new ReservationMismatchError(args);
|
|
80
|
+
case "too_late":
|
|
81
|
+
return new TooLateError(args);
|
|
82
|
+
default:
|
|
83
|
+
return new InvalidRequestError(args);
|
|
84
|
+
}
|
|
71
85
|
case 429:
|
|
72
86
|
return new RateLimitError(args);
|
|
73
87
|
default:
|
|
74
88
|
return args.status >= 500 ? new ServerError(args) : new APIError(args);
|
|
75
89
|
}
|
|
76
90
|
}
|
|
77
|
-
function
|
|
91
|
+
function serverTime(body) {
|
|
78
92
|
if (typeof body !== "object" || body === null) return void 0;
|
|
79
93
|
const error = body.error;
|
|
80
94
|
if (typeof error !== "object" || error === null) return void 0;
|
|
81
|
-
const
|
|
82
|
-
|
|
95
|
+
const at = error.server_time;
|
|
96
|
+
if (typeof at !== "string") return void 0;
|
|
97
|
+
const parsed = new Date(at);
|
|
98
|
+
return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
// src/client.ts
|
|
@@ -93,6 +109,21 @@ var Client = class {
|
|
|
93
109
|
#timeout;
|
|
94
110
|
#maxRetries;
|
|
95
111
|
#fetch;
|
|
112
|
+
/**
|
|
113
|
+
* How far the engine's clock is ahead of this machine's, in milliseconds.
|
|
114
|
+
* Idempotency keys are minted here and the engine refuses one dated more
|
|
115
|
+
* than an hour from its own clock, so without this a device with a wrong
|
|
116
|
+
* clock would fail every call forever.
|
|
117
|
+
*/
|
|
118
|
+
#clockOffset = 0;
|
|
119
|
+
/** The engine's clock, as well as this client knows it. */
|
|
120
|
+
now() {
|
|
121
|
+
return Date.now() + this.#clockOffset;
|
|
122
|
+
}
|
|
123
|
+
/** Off by up to one round trip, which a window in hours does not notice. */
|
|
124
|
+
observeServerTime(at) {
|
|
125
|
+
this.#clockOffset = at.getTime() - Date.now();
|
|
126
|
+
}
|
|
96
127
|
constructor(options) {
|
|
97
128
|
if (!options.apiKey) {
|
|
98
129
|
throw new MeterbaseError(
|
|
@@ -637,6 +668,12 @@ var Meterbase = class {
|
|
|
637
668
|
*
|
|
638
669
|
* This is the gate, and the only call that ever refuses. Run it before the
|
|
639
670
|
* work; record the work with `track` afterwards.
|
|
671
|
+
*
|
|
672
|
+
* Two gates answer it, and `allowed` needs both: the customer's capacity has
|
|
673
|
+
* to cover the quantity, and every rate-limit window in force has to admit
|
|
674
|
+
* it. `reason` names the one that said no. `rate_limits` reports each window
|
|
675
|
+
* whether or not it refused, so a caller can ease off as its headroom closes
|
|
676
|
+
* instead of discovering the ceiling by hitting it.
|
|
640
677
|
*/
|
|
641
678
|
check(params, options) {
|
|
642
679
|
return this.#client.request({
|
|
@@ -652,24 +689,70 @@ var Meterbase = class {
|
|
|
652
689
|
* customer's capacity is recorded, drives `available` to 0, and the next
|
|
653
690
|
* `check` says no. The answer is a receipt, not a verdict.
|
|
654
691
|
*
|
|
655
|
-
* `idempotency_key` is generated when omitted, so retrying this
|
|
656
|
-
* SDK's own retries included — replays the original rather than
|
|
657
|
-
* the same work twice.
|
|
692
|
+
* `idempotency_key` is a UUIDv7, generated when omitted, so retrying this
|
|
693
|
+
* call — the SDK's own retries included — replays the original rather than
|
|
694
|
+
* counting the same work twice. The engine keeps that claim for an hour,
|
|
695
|
+
* which is how long a retry stays recognisable.
|
|
658
696
|
*/
|
|
659
697
|
// `async` so that generating the key cannot throw synchronously out of a
|
|
660
698
|
// method that otherwise only ever rejects: one call, one way to fail.
|
|
661
699
|
async track(params, options) {
|
|
700
|
+
return this.#keyed(
|
|
701
|
+
IDEMPOTENCY_WINDOW_MS,
|
|
702
|
+
params.idempotency_key,
|
|
703
|
+
(key) => this.#client.request({
|
|
704
|
+
...options,
|
|
705
|
+
method: "POST",
|
|
706
|
+
path: "/v1/usage/track",
|
|
707
|
+
// Fixed before the retry loop is entered: every attempt of this call
|
|
708
|
+
// carries the same key, which is what makes replaying it safe.
|
|
709
|
+
body: { ...params, idempotency_key: key },
|
|
710
|
+
idempotent: true
|
|
711
|
+
})
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Holds capacity for work that cannot be done twice, or refuses. The same
|
|
716
|
+
* decision `check` makes, and then a hold on the capacity until the work
|
|
717
|
+
* is committed, released, or expires.
|
|
718
|
+
*
|
|
719
|
+
* The rule for choosing: can you afford to do the work twice? `check`. No?
|
|
720
|
+
* `reserve`. A reserve costs a transaction where a check costs a cached
|
|
721
|
+
* read, refusals included, so it is not a per-request gate on cheap work.
|
|
722
|
+
*
|
|
723
|
+
* Two concurrent reserves for the same customer and meter cannot both be
|
|
724
|
+
* granted the same capacity; that is the whole point, and it is what
|
|
725
|
+
* `check` → work → `track` could never promise.
|
|
726
|
+
*/
|
|
727
|
+
async reserve(params, options) {
|
|
728
|
+
const response = await this.#keyed(
|
|
729
|
+
RESERVE_WINDOW_MS,
|
|
730
|
+
params.reservation_id,
|
|
731
|
+
(id) => this.#client.request({
|
|
732
|
+
...options,
|
|
733
|
+
method: "POST",
|
|
734
|
+
path: "/v1/usage/reserve",
|
|
735
|
+
body: { ...params, reservation_id: id },
|
|
736
|
+
// A retry under the same id finds the hold it already made rather
|
|
737
|
+
// than making a second one.
|
|
738
|
+
idempotent: true
|
|
739
|
+
})
|
|
740
|
+
);
|
|
741
|
+
return this.#hold(params, response);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Gives a hold back: the work did not happen, so nothing is recorded.
|
|
745
|
+
*
|
|
746
|
+
* Throws `NotFoundError` when the id names nothing — committed, released
|
|
747
|
+
* already, or never made, which are one absence with one meaning. Prefer
|
|
748
|
+
* `hold.release()`, which treats that as the success it is.
|
|
749
|
+
*/
|
|
750
|
+
release(params, options) {
|
|
662
751
|
return this.#client.request({
|
|
663
752
|
...options,
|
|
664
753
|
method: "POST",
|
|
665
|
-
path: "/v1/usage/
|
|
666
|
-
|
|
667
|
-
// this call carries the same key, which is what makes replaying it safe.
|
|
668
|
-
body: {
|
|
669
|
-
...params,
|
|
670
|
-
idempotency_key: params.idempotency_key ?? idempotencyKey()
|
|
671
|
-
},
|
|
672
|
-
idempotent: true
|
|
754
|
+
path: "/v1/usage/release",
|
|
755
|
+
body: params
|
|
673
756
|
});
|
|
674
757
|
}
|
|
675
758
|
/** Reports which workspace this key acts for. */
|
|
@@ -680,19 +763,110 @@ var Meterbase = class {
|
|
|
680
763
|
path: "/v1/whoami"
|
|
681
764
|
});
|
|
682
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* Sends a call that carries a caller-minted UUIDv7, minting one when the
|
|
768
|
+
* caller supplied none and re-minting once if this machine's clock put it
|
|
769
|
+
* outside the engine's window.
|
|
770
|
+
*
|
|
771
|
+
* A key the caller supplied is never re-minted: this cannot know whether it
|
|
772
|
+
* names work already recorded, and replacing it could count that work
|
|
773
|
+
* twice.
|
|
774
|
+
*/
|
|
775
|
+
async #keyed(window, supplied, send) {
|
|
776
|
+
if (supplied !== void 0) return send(supplied);
|
|
777
|
+
const key = idempotencyKey(this.#client.now());
|
|
778
|
+
const startedAt = Date.now();
|
|
779
|
+
try {
|
|
780
|
+
return await send(key);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
if (!(error instanceof TooLateError) || error.serverTime === void 0) {
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
const elapsed = Date.now() - startedAt;
|
|
786
|
+
const serverAtStart = error.serverTime.getTime() - elapsed;
|
|
787
|
+
if (Math.abs(serverAtStart - mintedAtMs(key)) <= window) throw error;
|
|
788
|
+
this.#client.observeServerTime(error.serverTime);
|
|
789
|
+
return await send(idempotencyKey(this.#client.now()));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/** Puts `commit` and `release` on a granted hold; a refusal is left as it is. */
|
|
793
|
+
#hold(params, response) {
|
|
794
|
+
if (!response.allowed) return response;
|
|
795
|
+
const id = response.reservation_id;
|
|
796
|
+
const expiresAt = response.expires_at;
|
|
797
|
+
if (id === void 0 || expiresAt === void 0) {
|
|
798
|
+
throw new MeterbaseError(
|
|
799
|
+
"The engine granted a reservation without an id or an expiry, so there is no hold to commit or release. The capacity it set aside comes back on its own."
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
803
|
+
return {
|
|
804
|
+
...response,
|
|
805
|
+
allowed: true,
|
|
806
|
+
reservation_id: id,
|
|
807
|
+
quantity: response.quantity ?? params.quantity ?? 1,
|
|
808
|
+
expires_at: expiresAt,
|
|
809
|
+
commit: (quantity, commitOptions) => {
|
|
810
|
+
if (Number.isFinite(expiresAtMs) && this.#client.now() > expiresAtMs && typeof globalThis.console?.warn === "function") {
|
|
811
|
+
globalThis.console.warn(
|
|
812
|
+
`Meterbase: committing reservation ${id} after it expired at ${expiresAt}. The event is still recorded, but the capacity was no longer held \u2014 raise expires_in_seconds for this work.`
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
return this.track(
|
|
816
|
+
{
|
|
817
|
+
customer_id: params.customer_id,
|
|
818
|
+
meter_id: params.meter_id,
|
|
819
|
+
quantity,
|
|
820
|
+
// The hold's own id, so the track commits it rather than
|
|
821
|
+
// recording a second event beside it.
|
|
822
|
+
idempotency_key: id
|
|
823
|
+
},
|
|
824
|
+
commitOptions
|
|
825
|
+
);
|
|
826
|
+
},
|
|
827
|
+
release: async (releaseOptions) => {
|
|
828
|
+
try {
|
|
829
|
+
return await this.release({ reservation_id: id }, releaseOptions);
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (error instanceof NotFoundError) return null;
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
683
837
|
};
|
|
684
|
-
|
|
838
|
+
var IDEMPOTENCY_WINDOW_MS = 60 * 60 * 1e3;
|
|
839
|
+
var RESERVE_WINDOW_MS = 30 * 60 * 1e3;
|
|
840
|
+
function mintedAtMs(key) {
|
|
841
|
+
return Number.parseInt(key.replace(/-/g, "").slice(0, 12), 16);
|
|
842
|
+
}
|
|
843
|
+
function idempotencyKey(atMs) {
|
|
685
844
|
const webcrypto = globalThis.crypto;
|
|
686
|
-
if (typeof webcrypto?.
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const bytes = webcrypto.getRandomValues(new Uint8Array(16));
|
|
691
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
845
|
+
if (typeof webcrypto?.getRandomValues !== "function") {
|
|
846
|
+
throw new MeterbaseError(
|
|
847
|
+
"No crypto to generate an idempotency key with: pass `idempotency_key` yourself (a UUIDv7), or run somewhere `crypto.getRandomValues` exists."
|
|
848
|
+
);
|
|
692
849
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
);
|
|
850
|
+
const bytes = webcrypto.getRandomValues(new Uint8Array(16));
|
|
851
|
+
const ms = Math.max(0, Math.trunc(atMs));
|
|
852
|
+
const high = Math.floor(ms / 4294967296);
|
|
853
|
+
const low = ms >>> 0;
|
|
854
|
+
bytes[0] = high >>> 8 & 255;
|
|
855
|
+
bytes[1] = high & 255;
|
|
856
|
+
bytes[2] = low >>> 24 & 255;
|
|
857
|
+
bytes[3] = low >>> 16 & 255;
|
|
858
|
+
bytes[4] = low >>> 8 & 255;
|
|
859
|
+
bytes[5] = low & 255;
|
|
860
|
+
bytes[6] = bytes[6] & 15 | 112;
|
|
861
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
862
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
863
|
+
return [
|
|
864
|
+
hex.slice(0, 8),
|
|
865
|
+
hex.slice(8, 12),
|
|
866
|
+
hex.slice(12, 16),
|
|
867
|
+
hex.slice(16, 20),
|
|
868
|
+
hex.slice(20)
|
|
869
|
+
].join("-");
|
|
696
870
|
}
|
|
697
871
|
|
|
698
872
|
exports.APIError = APIError;
|
|
@@ -700,15 +874,17 @@ exports.AuthenticationError = AuthenticationError;
|
|
|
700
874
|
exports.ConflictError = ConflictError;
|
|
701
875
|
exports.ConnectionError = ConnectionError;
|
|
702
876
|
exports.CycleChangeRequiresResetError = CycleChangeRequiresResetError;
|
|
703
|
-
exports.
|
|
877
|
+
exports.InvalidIdempotencyKeyError = InvalidIdempotencyKeyError;
|
|
704
878
|
exports.InvalidRequestError = InvalidRequestError;
|
|
705
879
|
exports.Meterbase = Meterbase;
|
|
706
880
|
exports.MeterbaseError = MeterbaseError;
|
|
707
881
|
exports.NotFoundError = NotFoundError;
|
|
708
882
|
exports.PermissionDeniedError = PermissionDeniedError;
|
|
709
883
|
exports.RateLimitError = RateLimitError;
|
|
884
|
+
exports.ReservationMismatchError = ReservationMismatchError;
|
|
710
885
|
exports.ServerError = ServerError;
|
|
711
886
|
exports.TimeoutError = TimeoutError;
|
|
887
|
+
exports.TooLateError = TooLateError;
|
|
712
888
|
exports.errorFromResponse = errorFromResponse;
|
|
713
889
|
//# sourceMappingURL=index.cjs.map
|
|
714
890
|
//# sourceMappingURL=index.cjs.map
|