@limitry/sdk 1.0.0-rc.7 → 1.0.0-rc.9

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.
@@ -1,39 +1,57 @@
1
1
  import { apiErrorFrom } from "../errors.js";
2
+ import { MOVING_HEADER, MOVING_MS, retryWaitMs } from "../retry.js";
2
3
  /** The largest cost a commit takes. */
3
4
  const MAX_COST = 1_000_000_000;
4
5
  function checkedCost(units) {
5
6
  if (!Number.isInteger(units) || units < 0 || units > MAX_COST) {
6
- throw new RangeError(`held.cost(${units}): the cost must be a whole number from 0 to ${MAX_COST}`);
7
+ throw new RangeError(`reservation.cost(${units}): the cost must be a whole number from 0 to ${MAX_COST}`);
7
8
  }
8
9
  return units;
9
10
  }
10
- /** Not allowed: a limit is used up — or Limitry is unreachable and a limit fails closed. */
11
- export class LimitExceeded extends Error {
11
+ /** Refused: a limit is reached, or the subject holds too many open reservations. */
12
+ export class LimitReached extends Error {
12
13
  reason;
13
- /** The limit that refused; null when unavailable or too many holds. */
14
- limit;
14
+ /** The limit that refused; null for too many open reservations. */
15
+ limitId;
15
16
  retryAfterSeconds;
16
17
  decision;
17
18
  constructor(decision) {
18
- const reason = decision?.reason?.code ?? "unavailable";
19
- super(reason === "unavailable"
20
- ? "Limitry could not be reached and a limit denies when unreachable"
21
- : // The API's own sentence (reason.message), one wording everywhere.
22
- (decision?.reason?.message ?? "Refused"));
23
- this.name = "LimitExceeded";
24
- this.reason = reason;
25
- this.limit = decision?.reason?.limit ?? null;
26
- this.retryAfterSeconds = decision?.retryAfterSeconds ?? null;
19
+ // The API's own sentence (reason.message), one wording everywhere.
20
+ super(decision.reason?.message ?? "Refused");
21
+ this.name = "LimitReached";
22
+ this.reason = decision.reason?.code ?? "limit_reached";
23
+ this.limitId = decision.reason?.limitId ?? null;
24
+ this.retryAfterSeconds = decision.retryAfterSeconds ?? null;
27
25
  this.decision = decision;
28
26
  }
29
27
  }
28
+ /**
29
+ * Limitry could not be reached (an error, a 5xx, a timeout, the plan's
30
+ * allowance used up, or the flood guard's 429) and a limit on this
31
+ * subject and action fails closed. With `open`, the work runs instead.
32
+ */
33
+ export class Unavailable extends Error {
34
+ constructor() {
35
+ super("Limitry could not be reached and a limit refuses when unreachable");
36
+ this.name = "Unavailable";
37
+ }
38
+ }
30
39
  const UNREACHABLE = Symbol("unreachable");
31
40
  /** No answer in time: unlike an error, the check may have been decided. */
32
41
  const TIMED_OUT = Symbol("timed out");
33
42
  /** How long a settle (or a replay to find a hold) may take, retries included. */
34
43
  const SETTLE_TIMEOUT_MS = 5000;
35
- export function createLimits(client, options = {}) {
44
+ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
45
+ /** Seconds of a `Retry-After`, as ms (none: undefined; `retryWaitMs` floors it at a second). */
46
+ function retryAfterMs(response) {
47
+ const value = response.headers.get("retry-after");
48
+ const seconds = value === null ? NaN : Number(value);
49
+ return Number.isFinite(seconds) ? seconds * 1000 : undefined;
50
+ }
51
+ export function createLimits(client, options = {}, internals = {}) {
36
52
  const timeoutMs = options.timeoutMs ?? 1500;
53
+ const sleep = internals.sleep ?? wait;
54
+ const random = internals.random ?? Math.random;
37
55
  /** The last fail mode seen per subject + action (closed if any limit is). */
38
56
  const remembered = new Map();
39
57
  const key = (s, action) => `${s.kind}\u0000${s.id}\u0000${action}`;
@@ -41,17 +59,32 @@ export function createLimits(client, options = {}) {
41
59
  const remember = (s, action, decision) => {
42
60
  if (options.failMode)
43
61
  return;
44
- const closed = decision.remaining.some((r) => r.failMode === "closed");
62
+ const closed = decision.limits.some((r) => r.failMode === "closed");
45
63
  remembered.set(key(s, action), closed ? "closed" : "open");
46
64
  if (remembered.size > 10_000) {
47
65
  remembered.delete(remembered.keys().next().value);
48
66
  }
49
67
  };
50
68
  /**
51
- * The decision — or UNREACHABLE (network error, 5xx, past the plan),
52
- * or TIMED_OUT (no answer within `wait`).
69
+ * The decision — or UNREACHABLE (network error, 5xx, past the plan, the
70
+ * flood guard's 429), or TIMED_OUT (no answer within `wait`). A
71
+ * `moving` or `busy` answer is asked again, with the same key, for up
72
+ * to 30 s in all: each attempt has its own `wait`.
53
73
  */
54
- async function check(body, idempotencyKey, wait = timeoutMs) {
74
+ async function check(body, idempotencyKey, wait = timeoutMs, finishOnly = false) {
75
+ let waited = 0;
76
+ for (let tries = 0;; tries++) {
77
+ const answer = await checkOnce(body, idempotencyKey, wait, finishOnly);
78
+ if (answer.kind !== "later")
79
+ return answer.value;
80
+ const pause = retryWaitMs(answer.retryAfterMs, tries, random);
81
+ if (waited + pause > MOVING_MS)
82
+ return UNREACHABLE;
83
+ waited += pause;
84
+ await sleep(pause);
85
+ }
86
+ }
87
+ async function checkOnce(body, idempotencyKey, wait, finishOnly) {
55
88
  let timer;
56
89
  // Raced as well as aborted: a custom fetch (a service binding, a
57
90
  // test) may ignore the signal.
@@ -62,15 +95,25 @@ export function createLimits(client, options = {}) {
62
95
  try {
63
96
  const { data, error, response } = await client.POST("/checks", {
64
97
  body,
65
- headers: { "idempotency-key": idempotencyKey },
98
+ headers: {
99
+ "idempotency-key": idempotencyKey,
100
+ // `moving` and `busy` come back here at once: this loop
101
+ // retries them.
102
+ [MOVING_HEADER]: "return",
103
+ ...(finishOnly ? { "limitry-finish-only": "1" } : {}),
104
+ },
66
105
  signal: AbortSignal.timeout(wait),
67
106
  });
68
107
  if (data)
69
108
  return data;
70
- // Past the plan's allowance (402 plan_limit_reached): no decision
71
- // — each limit's fail mode applies, as if Limitry were unreachable.
72
- if (response.status === 402)
109
+ if (isRetryLater(response, error))
110
+ return { later: retryAfterMs(response) };
111
+ // Past the plan's allowance (402 plan_limit_reached), or stopped by
112
+ // the edge's flood guard (429): no decision — each limit's fail
113
+ // mode applies, as if Limitry were unreachable.
114
+ if (response.status === 402 || response.status === 429) {
73
115
  return UNREACHABLE;
116
+ }
74
117
  // A 4xx is the caller's mistake (bad request, bad key): say so.
75
118
  if (response.status < 500 && error) {
76
119
  return new Error(`Limitry refused the check (${response.status}): ${JSON.stringify(error)}`);
@@ -87,40 +130,59 @@ export function createLimits(client, options = {}) {
87
130
  const answer = await Promise.race([asked, late]);
88
131
  if (answer instanceof Error)
89
132
  throw answer;
90
- return answer;
133
+ if (typeof answer === "object" && "later" in answer)
134
+ return { kind: "later", retryAfterMs: answer.later };
135
+ return { kind: "answer", value: answer };
91
136
  }
92
137
  finally {
93
138
  clearTimeout(timer);
94
139
  }
95
140
  }
96
141
  /**
97
- * Settling never fails the caller's work: the client retries it (the
98
- * same settle answers the same), and a settle lost all the same expires
99
- * by itself.
142
+ * Committing or releasing never fails the caller's work: the client
143
+ * retries it (the same commit answers the same), and one lost all the
144
+ * same expires by itself.
100
145
  */
101
146
  async function settle(id, action, cost) {
102
- try {
103
- if (action === "commit") {
104
- await client.POST("/reservations/{reservationId}/commit", {
105
- params: { path: { reservationId: id } },
106
- body: { cost: cost ?? 0 },
107
- signal: AbortSignal.timeout(SETTLE_TIMEOUT_MS),
108
- });
147
+ // A commit lost would leave work uncounted: `moving` or `busy` is
148
+ // asked again, with the same key, for up to 30 s, each attempt with
149
+ // its own budget.
150
+ const idempotencyKey = crypto.randomUUID();
151
+ let waited = 0;
152
+ for (let tries = 0;; tries++) {
153
+ let retryMs = null;
154
+ try {
155
+ const headers = {
156
+ "idempotency-key": idempotencyKey,
157
+ [MOVING_HEADER]: "return",
158
+ };
159
+ const { error, response } = action === "commit"
160
+ ? await client.POST("/reservations/{reservationId}/commit", {
161
+ params: { path: { reservationId: id } },
162
+ body: { cost: cost ?? 0 },
163
+ headers,
164
+ signal: AbortSignal.timeout(SETTLE_TIMEOUT_MS),
165
+ })
166
+ : await client.POST("/reservations/{reservationId}/release", {
167
+ params: { path: { reservationId: id } },
168
+ headers,
169
+ signal: AbortSignal.timeout(SETTLE_TIMEOUT_MS),
170
+ });
171
+ if (isRetryLater(response, error))
172
+ retryMs = retryWaitMs(retryAfterMs(response), tries, random);
109
173
  }
110
- else {
111
- await client.POST("/reservations/{reservationId}/release", {
112
- params: { path: { reservationId: id } },
113
- signal: AbortSignal.timeout(SETTLE_TIMEOUT_MS),
114
- });
174
+ catch {
175
+ // The reservation expires on its own.
115
176
  }
116
- }
117
- catch {
118
- // The hold expires on its own.
177
+ if (retryMs === null || waited + retryMs > MOVING_MS)
178
+ return;
179
+ waited += retryMs;
180
+ await sleep(retryMs);
119
181
  }
120
182
  }
121
183
  /**
122
- * After a hold's check timed out: ask again with the same key (a replay
123
- * if Limitry decided it) and release any hold it was granted.
184
+ * After a reservation's check timed out: ask again with the same key (a
185
+ * replay if Limitry decided it) and release any reservation it made.
124
186
  */
125
187
  async function releaseUnheard(body, idempotencyKey) {
126
188
  const replay = await check(body, idempotencyKey, SETTLE_TIMEOUT_MS);
@@ -140,8 +202,7 @@ export function createLimits(client, options = {}) {
140
202
  const { data, error, response } = await client.GET("/usage/standing", {
141
203
  params: {
142
204
  query: {
143
- kind: request.subject.kind,
144
- id: request.subject.id,
205
+ subject: `${request.subject.kind}:${request.subject.id}`,
145
206
  ...(request.shared?.length
146
207
  ? {
147
208
  shared: request.shared
@@ -158,36 +219,46 @@ export function createLimits(client, options = {}) {
158
219
  },
159
220
  /** Checks `cost`, runs `fn` only if allowed, and returns its result. */
160
221
  async guard(request, fn) {
161
- const decision = await check({
222
+ const body = {
162
223
  subject: request.subject,
163
224
  shared: request.shared,
164
225
  action: request.action,
165
226
  cost: request.cost ?? 1,
166
- }, crypto.randomUUID());
227
+ };
228
+ const idempotencyKey = crypto.randomUUID();
229
+ const decision = await check(body, idempotencyKey);
167
230
  if (decision === UNREACHABLE || decision === TIMED_OUT) {
168
231
  if (failMode(request.subject, request.action) === "closed") {
169
- throw new LimitExceeded(null);
232
+ throw new Unavailable();
233
+ }
234
+ if (decision === TIMED_OUT) {
235
+ // No answer in time, and the work runs (fail open): the check may
236
+ // be part way (held on some of Limitry's servers, across
237
+ // subjects). Sent once more with the same key, in the background,
238
+ // ONLY TO FINISH it — Limitry never decides it afresh then, so a
239
+ // check that never arrived charges nothing.
240
+ void check(body, idempotencyKey, SETTLE_TIMEOUT_MS, true).catch(() => { });
170
241
  }
171
242
  return fn();
172
243
  }
173
244
  remember(request.subject, request.action, decision);
174
245
  if (!decision.allowed)
175
- throw new LimitExceeded(decision);
246
+ throw new LimitReached(decision);
176
247
  return fn();
177
248
  },
178
249
  /**
179
- * Holds `estimate`, runs `fn`, then commits the cost it reported
180
- * (`held.cost(n)`, default the estimate) — or releases the hold if
181
- * `fn` throws, and rethrows. A concurrent limit's slot is held for
182
- * exactly as long as `fn` runs.
250
+ * Reserves `cost`, runs `fn`, then commits the cost it reported
251
+ * (`reservation.cost(n)`, default the reserved cost) — or releases the
252
+ * reservation if `fn` throws, and rethrows. A concurrent limit's slot
253
+ * is taken for exactly as long as `fn` runs.
183
254
  */
184
- async hold(request, fn) {
185
- const estimate = request.estimate ?? 1;
255
+ async reserve(request, fn) {
256
+ const reserved = request.cost ?? 1;
186
257
  const body = {
187
258
  subject: request.subject,
188
259
  shared: request.shared,
189
260
  action: request.action,
190
- cost: estimate,
261
+ cost: reserved,
191
262
  mode: "reserve",
192
263
  ttlSeconds: request.ttlSeconds,
193
264
  };
@@ -205,7 +276,7 @@ export function createLimits(client, options = {}) {
205
276
  : Promise.resolve();
206
277
  if (failMode(request.subject, request.action) === "closed") {
207
278
  await unheard();
208
- throw new LimitExceeded(null);
279
+ throw new Unavailable();
209
280
  }
210
281
  try {
211
282
  return await fn(unmetered);
@@ -216,11 +287,11 @@ export function createLimits(client, options = {}) {
216
287
  }
217
288
  remember(request.subject, request.action, decision);
218
289
  if (!decision.allowed || !decision.reservation) {
219
- throw new LimitExceeded(decision);
290
+ throw new LimitReached(decision);
220
291
  }
221
292
  const id = decision.reservation.id;
222
- let used = estimate;
223
- const held = {
293
+ let used = reserved;
294
+ const reservation = {
224
295
  cost(units) {
225
296
  used = checkedCost(units);
226
297
  },
@@ -237,7 +308,7 @@ export function createLimits(client, options = {}) {
237
308
  };
238
309
  let result;
239
310
  try {
240
- result = await fn(held);
311
+ result = await fn(reservation);
241
312
  }
242
313
  catch (error) {
243
314
  await settle(id, "release");
@@ -248,3 +319,9 @@ export function createLimits(client, options = {}) {
248
319
  },
249
320
  };
250
321
  }
322
+ /** `503 moving` or `503 busy`: nothing was charged — retry with the same key. */
323
+ function isRetryLater(response, error) {
324
+ const code = error?.error
325
+ ?.code;
326
+ return response.status === 503 && (code === "moving" || code === "busy");
327
+ }
@@ -5,18 +5,18 @@ export declare function resources(client: RawClient): {
5
5
  create: (input: Input<"/checks", "post">, options?: RequestOptions) => Promise<{
6
6
  allowed: boolean;
7
7
  id: string;
8
+ limits: import("./v1.gen.ts").components["schemas"]["CheckLimit"][];
8
9
  reason: {
9
- code: "limit_exceeded" | "too_many_reservations";
10
- limit: string | null;
10
+ code: "limit_reached" | "too_many_reservations";
11
+ limitId: string | null;
11
12
  message: string;
12
13
  } | null;
13
- remaining: import("./v1.gen.ts").components["schemas"]["CheckRemaining"][];
14
14
  reservation?: {
15
15
  expiresAt: string;
16
16
  id: string;
17
17
  };
18
18
  retryAfterSeconds: number | null;
19
- }>;
19
+ } | undefined>;
20
20
  };
21
21
  limits: {
22
22
  archive: (limitId: string | number, options?: RequestOptions) => Promise<undefined>;
@@ -29,15 +29,33 @@ export declare function resources(client: RawClient): {
29
29
  every: number;
30
30
  failMode: "open" | "closed";
31
31
  id: string;
32
+ lastCheckedAt: string | null;
32
33
  name: string;
33
34
  rolling: boolean;
34
- start: "calendar" | "anchor" | "first_use";
35
+ start: "calendar" | "billing_date" | "first_use";
35
36
  subject: import("./v1.gen.ts").components["schemas"]["LimitSubject"];
36
37
  unit: string;
37
38
  updatedAt: string;
38
39
  version: number;
39
40
  window: "minute" | "hour" | "day" | "week" | "month" | "year" | "total" | "concurrent";
40
41
  }>;
42
+ credits: {
43
+ create: (limitId: string | number, input: Input<"/limits/{limitId}/credits", "post">, options?: RequestOptions) => Promise<{
44
+ amount: number;
45
+ createdAt: string;
46
+ createdByUserId: string | null;
47
+ id: string;
48
+ limitId: string;
49
+ note: string;
50
+ subject: {
51
+ id: string;
52
+ kind: string;
53
+ };
54
+ }>;
55
+ list: (limitId: string | number, options?: RequestOptions) => Promise<{
56
+ items: import("./v1.gen.ts").components["schemas"]["Credit"][];
57
+ }>;
58
+ };
41
59
  get: (limitId: string | number, options?: RequestOptions) => Promise<{
42
60
  action: string;
43
61
  amount: number;
@@ -47,32 +65,16 @@ export declare function resources(client: RawClient): {
47
65
  every: number;
48
66
  failMode: "open" | "closed";
49
67
  id: string;
68
+ lastCheckedAt: string | null;
50
69
  name: string;
51
70
  rolling: boolean;
52
- start: "calendar" | "anchor" | "first_use";
71
+ start: "calendar" | "billing_date" | "first_use";
53
72
  subject: import("./v1.gen.ts").components["schemas"]["LimitSubject"];
54
73
  unit: string;
55
74
  updatedAt: string;
56
75
  version: number;
57
76
  window: "minute" | "hour" | "day" | "week" | "month" | "year" | "total" | "concurrent";
58
77
  }>;
59
- grants: {
60
- create: (limitId: string | number, input: Input<"/limits/{limitId}/grants", "post">, options?: RequestOptions) => Promise<{
61
- amount: number;
62
- createdAt: string;
63
- createdByUserId: string | null;
64
- id: string;
65
- limitId: string;
66
- note: string;
67
- subject: {
68
- id: string;
69
- kind: string;
70
- };
71
- }>;
72
- list: (limitId: string | number, options?: RequestOptions) => Promise<{
73
- items: import("./v1.gen.ts").components["schemas"]["LimitGrant"][];
74
- }>;
75
- };
76
78
  list: (input?: Input<"/limits", "get">, options?: RequestOptions) => Promise<{
77
79
  items: import("./v1.gen.ts").components["schemas"]["Limit"][];
78
80
  nextCursor: string | null;
@@ -86,9 +88,10 @@ export declare function resources(client: RawClient): {
86
88
  every: number;
87
89
  failMode: "open" | "closed";
88
90
  id: string;
91
+ lastCheckedAt: string | null;
89
92
  name: string;
90
93
  rolling: boolean;
91
- start: "calendar" | "anchor" | "first_use";
94
+ start: "calendar" | "billing_date" | "first_use";
92
95
  subject: import("./v1.gen.ts").components["schemas"]["LimitSubject"];
93
96
  unit: string;
94
97
  updatedAt: string;
@@ -98,7 +101,7 @@ export declare function resources(client: RawClient): {
98
101
  sync: (input: Input<"/limits/sync", "post">, options?: RequestOptions) => Promise<{
99
102
  archived: string[];
100
103
  created: string[];
101
- dryRun: boolean;
104
+ preview: boolean;
102
105
  unchanged: string[];
103
106
  updated: {
104
107
  changes: {
@@ -119,9 +122,10 @@ export declare function resources(client: RawClient): {
119
122
  every: number;
120
123
  failMode: "open" | "closed";
121
124
  id: string;
125
+ lastCheckedAt: string | null;
122
126
  name: string;
123
127
  rolling: boolean;
124
- start: "calendar" | "anchor" | "first_use";
128
+ start: "calendar" | "billing_date" | "first_use";
125
129
  subject: import("./v1.gen.ts").components["schemas"]["LimitSubject"];
126
130
  unit: string;
127
131
  updatedAt: string;
@@ -154,7 +158,7 @@ export declare function resources(client: RawClient): {
154
158
  };
155
159
  subjects: {
156
160
  get: (subjectKind: string | number, subjectId: string | number, options?: RequestOptions) => Promise<{
157
- anchor: string | null;
161
+ billingDate: string | null;
158
162
  subject: import("./v1.gen.ts").components["schemas"]["SubjectRef"];
159
163
  }>;
160
164
  reset: (subjectKind: string | number, subjectId: string | number, input?: Input<"/subjects/{subjectKind}/{subjectId}/reset", "post">, options?: RequestOptions) => Promise<{
@@ -162,7 +166,7 @@ export declare function resources(client: RawClient): {
162
166
  subject: import("./v1.gen.ts").components["schemas"]["SubjectRef"];
163
167
  }>;
164
168
  update: (subjectKind: string | number, subjectId: string | number, input: Input<"/subjects/{subjectKind}/{subjectId}", "patch">, options?: RequestOptions) => Promise<{
165
- anchor: string | null;
169
+ billingDate: string | null;
166
170
  subject: import("./v1.gen.ts").components["schemas"]["SubjectRef"];
167
171
  }>;
168
172
  };
@@ -172,7 +176,7 @@ export declare function resources(client: RawClient): {
172
176
  limitId: string;
173
177
  points: {
174
178
  allowed: number;
175
- denied: number;
179
+ refused: number;
176
180
  start: string;
177
181
  used: number;
178
182
  }[];
@@ -182,7 +186,7 @@ export declare function resources(client: RawClient): {
182
186
  limits: {
183
187
  action: string;
184
188
  amount: number;
185
- limit: string;
189
+ limitId: string;
186
190
  name: string;
187
191
  remaining: number;
188
192
  resetsAt: string | null;
@@ -203,8 +207,7 @@ export declare function resources(client: RawClient): {
203
207
  }>;
204
208
  subjects: (input: Input<"/usage/subjects", "get">, options?: RequestOptions) => Promise<{
205
209
  items: {
206
- denied: number;
207
- over: boolean;
210
+ refused: number;
208
211
  remaining: number;
209
212
  subject: {
210
213
  id: string;
@@ -11,11 +11,11 @@ export function resources(client) {
11
11
  limits: {
12
12
  archive: /** Archive a limit */ (limitId, options) => call(client, { "method": "post", "path": "/limits/{limitId}/archive", "query": [], "hasBody": false }, { "limitId": limitId }, undefined, options),
13
13
  create: /** Create a limit */ (input, options) => call(client, { "method": "post", "path": "/limits", "query": [], "hasBody": true }, {}, input, options),
14
- get: /** Get a limit */ (limitId, options) => call(client, { "method": "get", "path": "/limits/{limitId}", "query": [], "hasBody": false }, { "limitId": limitId }, undefined, options),
15
- grants: {
16
- create: /** Add credits to a balance */ (limitId, input, options) => call(client, { "method": "post", "path": "/limits/{limitId}/grants", "query": [], "hasBody": true }, { "limitId": limitId }, input, options),
17
- list: /** List a balance's grants */ (limitId, options) => call(client, { "method": "get", "path": "/limits/{limitId}/grants", "query": [], "hasBody": false }, { "limitId": limitId }, undefined, options),
14
+ credits: {
15
+ create: /** Add credits to a balance */ (limitId, input, options) => call(client, { "method": "post", "path": "/limits/{limitId}/credits", "query": [], "hasBody": true }, { "limitId": limitId }, input, options),
16
+ list: /** List a balance's credits */ (limitId, options) => call(client, { "method": "get", "path": "/limits/{limitId}/credits", "query": [], "hasBody": false }, { "limitId": limitId }, undefined, options),
18
17
  },
18
+ get: /** Get a limit */ (limitId, options) => call(client, { "method": "get", "path": "/limits/{limitId}", "query": [], "hasBody": false }, { "limitId": limitId }, undefined, options),
19
19
  list: /** List limits */ (input, options) => call(client, { "method": "get", "path": "/limits", "query": ["limit", "cursor", "subjectKind", "action", "archived"], "hasBody": false }, {}, input, options),
20
20
  restore: /** Restore an archived limit */ (limitId, options) => call(client, { "method": "post", "path": "/limits/{limitId}/restore", "query": [], "hasBody": false }, { "limitId": limitId }, undefined, options),
21
21
  sync: /** Sync limits from a list */ (input, options) => call(client, { "method": "post", "path": "/limits/sync", "query": [], "hasBody": true }, {}, input, options),
@@ -29,11 +29,11 @@ export function resources(client) {
29
29
  subjects: {
30
30
  get: /** Get a subject */ (subjectKind, subjectId, options) => call(client, { "method": "get", "path": "/subjects/{subjectKind}/{subjectId}", "query": [], "hasBody": false }, { "subjectKind": subjectKind, "subjectId": subjectId }, undefined, options),
31
31
  reset: /** Reset a subject's limits */ (subjectKind, subjectId, input, options) => call(client, { "method": "post", "path": "/subjects/{subjectKind}/{subjectId}/reset", "query": [], "hasBody": true }, { "subjectKind": subjectKind, "subjectId": subjectId }, input, options),
32
- update: /** Set a subject's anchor */ (subjectKind, subjectId, input, options) => call(client, { "method": "patch", "path": "/subjects/{subjectKind}/{subjectId}", "query": [], "hasBody": true }, { "subjectKind": subjectKind, "subjectId": subjectId }, input, options),
32
+ update: /** Set a subject's billing date */ (subjectKind, subjectId, input, options) => call(client, { "method": "patch", "path": "/subjects/{subjectKind}/{subjectId}", "query": [], "hasBody": true }, { "subjectKind": subjectKind, "subjectId": subjectId }, input, options),
33
33
  },
34
34
  usage: {
35
- query: /** Get a limit's usage over time */ (input, options) => call(client, { "method": "get", "path": "/usage", "query": ["limitId", "subjectKind", "subjectId", "from", "to", "granularity"], "hasBody": false }, {}, input, options),
36
- standing: /** Get where a subject stands */ (input, options) => call(client, { "method": "get", "path": "/usage/standing", "query": ["kind", "id", "shared"], "hasBody": false }, {}, input, options),
35
+ query: /** Get a limit's usage over time */ (input, options) => call(client, { "method": "get", "path": "/usage", "query": ["limitId", "subject", "from", "to", "granularity"], "hasBody": false }, {}, input, options),
36
+ standing: /** Get where a subject stands */ (input, options) => call(client, { "method": "get", "path": "/usage/standing", "query": ["subject", "shared"], "hasBody": false }, {}, input, options),
37
37
  subjects: /** List who is using a limit now */ (input, options) => call(client, { "method": "get", "path": "/usage/subjects", "query": ["limitId", "limit"], "hasBody": false }, {}, input, options),
38
38
  },
39
39
  user: {
package/dist/retry.d.ts CHANGED
@@ -13,9 +13,29 @@
13
13
  * handed back to the caller rather than slept through. A request whose
14
14
  * own signal aborted (a caller's timeout) is never retried: the caller
15
15
  * decided.
16
+ *
17
+ * A `503 moving` (a subject handed over between Limitry's servers, a few
18
+ * seconds) or `503 busy` (too many checks inside one of them at once) is
19
+ * retried for up to MOVING_MS whatever `maxRetries` says: nothing was
20
+ * charged, and the same key gets the answer once it lands. Each wait is
21
+ * the Retry-After (at least a second) plus full jitter on a capped
22
+ * exponential backoff (`retryWaitMs`), so clients turned away together
23
+ * don't come back together. A caller that keeps its own time budget (the
24
+ * guard and reserve helpers) sends `MOVING_HEADER: return` and gets
25
+ * either answer back at once.
16
26
  */
17
27
  export declare const DEFAULT_MAX_RETRIES = 2;
18
28
  export declare const MAX_WAIT_MS = 20000;
29
+ /**
30
+ * How long a `moving` or `busy` answer is retried, all told: a handover's
31
+ * freeze lasts at most 30 s.
32
+ */
33
+ export declare const MOVING_MS = 30000;
34
+ /** Sent by a caller that retries `moving` and `busy` itself (never reaches the API). */
35
+ export declare const MOVING_HEADER = "x-limitry-moving";
36
+ /** The backoff added to a `moving` or `busy` wait: full jitter under base × 2^n, capped. */
37
+ export declare const JITTER_BASE_MS = 250;
38
+ export declare const JITTER_CAP_MS = 4000;
19
39
  /** The longest key a caller may pass where a client derives keys from it. */
20
40
  export declare const MAX_BASE_KEY = 200;
21
41
  type Fetch = (request: Request) => Promise<Response>;
@@ -26,7 +46,25 @@ export type RetryOptions = {
26
46
  key?: (method: string, path: string) => string | Promise<string>;
27
47
  /** Waits (tests pass a fake). */
28
48
  sleep?: (ms: number) => Promise<void>;
49
+ /** 0 ≤ x < 1 for the jitter (tests pass a fixed one). */
50
+ random?: () => number;
29
51
  };
52
+ /**
53
+ * The wait before retrying a `moving` or `busy` answer, the `attempt`th
54
+ * in a row (0 first): the server's Retry-After, at least a second (a 0,
55
+ * or a date past, must not spin), plus full jitter — anywhere from 0 to
56
+ * min(JITTER_CAP_MS, JITTER_BASE_MS × 2^attempt).
57
+ */
58
+ export declare function retryWaitMs(retryAfterMs: number | undefined, attempt: number, random?: () => number): number;
59
+ /** A `503` that says retry later with the same key: `moving` or `busy` (else null). */
60
+ export declare function retryLater(response: Response): Promise<"moving" | "busy" | null>;
61
+ /** A `503` whose error code is `moving`. */
62
+ export declare function isMoving(response: Response): Promise<boolean>;
63
+ /**
64
+ * `fetch` that only drops MOVING_HEADER (a client with retries off): the
65
+ * header is between the SDK's own parts, never sent to the API.
66
+ */
67
+ export declare function withoutMovingHeader(fetch: Fetch): Fetch;
30
68
  /** `fetch` with the rule above. Each request is one logical call. */
31
69
  export declare function withRetries(fetch: Fetch, options?: RetryOptions): Fetch;
32
70
  /**