@limitry/sdk 1.0.0-rc.8 → 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.
- package/CHANGELOG.md +50 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/product/limits.d.ts +18 -1
- package/dist/product/limits.js +90 -22
- package/dist/resources.gen.d.ts +1 -1
- package/dist/retry.d.ts +38 -0
- package/dist/retry.js +85 -1
- package/dist/v1.gen.d.ts +11 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in the Limitry TypeScript SDK (`@limitry/sdk`). Generated from the source on each release; newest first.
|
|
4
4
|
|
|
5
|
+
## 1.0.0-rc.9 (2026-10-22)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `POST /v1/checks` can now answer `503` with the code `busy` when
|
|
10
|
+
Limitry is deciding too many checks for those subjects at once. Nothing
|
|
11
|
+
was charged. `guard` and `reserve` (TypeScript and Python) send the
|
|
12
|
+
check again with the same key, within the same 30 seconds they give
|
|
13
|
+
`moving`, before the limit's fail mode decides. Each retry of `busy` or
|
|
14
|
+
`moving` now waits the answer's `Retry-After` plus a random backoff that
|
|
15
|
+
grows with each try, so clients don't all come back at the same moment.
|
|
16
|
+
Older SDK versions treat `busy` as Limitry being unreachable after their
|
|
17
|
+
usual two quick retries, so the fail mode decides; upgrade to retry it.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- A very busy workspace's counts are now spread over more of Limitry's
|
|
22
|
+
servers as its traffic grows. While some of them move, which takes a few
|
|
23
|
+
seconds, checks, commits and releases on those subjects answer `503`
|
|
24
|
+
with the new error code `moving` and `Retry-After: 1`; nothing is
|
|
25
|
+
counted. `guard` and `reserve` (TypeScript and Python) and the CLI send
|
|
26
|
+
the same request again with the same Idempotency-Key for up to 30
|
|
27
|
+
seconds, each try with its own time budget, before a limit's fail mode
|
|
28
|
+
applies, so work is neither run uncounted nor left uncommitted.
|
|
29
|
+
- When `guard` gets no answer to its check in time and the limit fails
|
|
30
|
+
open (so your work runs), it now sends the same check once more in the
|
|
31
|
+
background, with the same Idempotency-Key and the header
|
|
32
|
+
`limitry-finish-only: 1`: Limitry finishes a check it was part way
|
|
33
|
+
through, and never decides one afresh, so a check that never arrived
|
|
34
|
+
charges nothing. A limit that fails closed sends nothing more.
|
|
35
|
+
|
|
36
|
+
`POST /v1/checks` answers `204` (no body) to such a resend when there was
|
|
37
|
+
nothing to finish, so `checks.create` is typed to allow no decision; a
|
|
38
|
+
check sent without `limitry-finish-only` always gets one.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- A reservation (`mode: "reserve"`) now starts a subject's period, as a
|
|
43
|
+
plain check does: on a `billing_date` limit with no billing date set
|
|
44
|
+
yet, the first reservation's time becomes the subject's billing date,
|
|
45
|
+
and on a `first_use` limit it starts the period. Before, reservations
|
|
46
|
+
made before the subject's first commit could each count in a period of
|
|
47
|
+
their own, so a monthly limit could let more through than its amount.
|
|
48
|
+
A reservation you release still starts the period, with nothing used in
|
|
49
|
+
it.
|
|
50
|
+
- A `503 moving` answer with `Retry-After: 0` is now retried after at least
|
|
51
|
+
a second, not at once, and still for at most 30 seconds. A client made
|
|
52
|
+
with `maxRetries: 0` no longer sends the SDK's internal
|
|
53
|
+
`x-limitry-moving` header to the API.
|
|
54
|
+
|
|
5
55
|
## 1.0.0-rc.8 (2026-10-21)
|
|
6
56
|
|
|
7
57
|
### Added
|
package/dist/index.d.ts
CHANGED
|
@@ -87,7 +87,7 @@ declare function createBaseClient(options: ClientOptions): import("openapi-fetch
|
|
|
87
87
|
id: string;
|
|
88
88
|
};
|
|
89
89
|
retryAfterSeconds: number | null;
|
|
90
|
-
}>;
|
|
90
|
+
} | undefined>;
|
|
91
91
|
};
|
|
92
92
|
limits: {
|
|
93
93
|
archive: (limitId: string | number, options?: import("./resources.ts").RequestOptions) => Promise<undefined>;
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import createOpenApiClient from "openapi-fetch";
|
|
|
3
3
|
import { apiErrorFrom } from "./errors.js";
|
|
4
4
|
import * as product from "./product/index.js";
|
|
5
5
|
import { resources } from "./resources.gen.js";
|
|
6
|
-
import { withRetries } from "./retry.js";
|
|
6
|
+
import { withoutMovingHeader, withRetries } from "./retry.js";
|
|
7
7
|
import { verifyWebhook } from "./webhooks.js";
|
|
8
8
|
export { ApiError, apiErrorFrom } from "./errors.js";
|
|
9
9
|
export { verifyWebhook, WebhookVerificationError, } from "./webhooks.js";
|
|
@@ -35,7 +35,7 @@ function createBaseClient(options) {
|
|
|
35
35
|
const client = createOpenApiClient({
|
|
36
36
|
baseUrl: `${origin}/v1`,
|
|
37
37
|
fetch: options.maxRetries === 0
|
|
38
|
-
? send
|
|
38
|
+
? withoutMovingHeader(send)
|
|
39
39
|
: withRetries(send, { maxRetries: options.maxRetries }),
|
|
40
40
|
});
|
|
41
41
|
client.use({
|
package/dist/product/limits.d.ts
CHANGED
|
@@ -33,6 +33,17 @@ import type { BaseClient, Schemas } from "../index.ts";
|
|
|
33
33
|
* `Unavailable` while `open` — the default, and the answer before any
|
|
34
34
|
* decision was seen — lets the work run unmetered.
|
|
35
35
|
*
|
|
36
|
+
* A subject being handed over between Limitry's servers (a split, a few
|
|
37
|
+
* seconds) answers `503 moving`: Limitry was reached and charged nothing,
|
|
38
|
+
* so it is not "unreachable" — the check (or commit, or release) is sent
|
|
39
|
+
* again with the same key for up to 30 s, each attempt with its own
|
|
40
|
+
* `timeoutMs`, before the fail mode applies. A fail-open product never
|
|
41
|
+
* runs uncounted through a handover. A limiter with too many checks
|
|
42
|
+
* inside at once answers `503 busy` the same way (charged nothing; retry
|
|
43
|
+
* with the same key): both share those 30 s, and each wait is the
|
|
44
|
+
* answer's Retry-After plus full jitter on a growing backoff, so clients
|
|
45
|
+
* turned away together don't all come back together.
|
|
46
|
+
*
|
|
36
47
|
* Retries are safe (docs/product idempotency): each guard or reserve
|
|
37
48
|
* sends one Idempotency-Key, which the client's own retries reuse, so a
|
|
38
49
|
* check counts once however often it is sent. A reservation whose check
|
|
@@ -103,7 +114,13 @@ export declare class LimitReached extends Error {
|
|
|
103
114
|
export declare class Unavailable extends Error {
|
|
104
115
|
constructor();
|
|
105
116
|
}
|
|
106
|
-
export
|
|
117
|
+
export type LimitsInternals = {
|
|
118
|
+
/** Waits (tests pass a fake). */
|
|
119
|
+
sleep?: (ms: number) => Promise<void>;
|
|
120
|
+
/** 0 ≤ x < 1 for the jitter (tests pass a fixed one). */
|
|
121
|
+
random?: () => number;
|
|
122
|
+
};
|
|
123
|
+
export declare function createLimits(client: BaseClient, options?: LimitsOptions, internals?: LimitsInternals): {
|
|
107
124
|
/**
|
|
108
125
|
* Where a subject stands on every limit on it (and on `shared`
|
|
109
126
|
* subjects): used, remaining, resetsAt — for showing an end user. Call
|
package/dist/product/limits.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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) {
|
|
@@ -40,8 +41,17 @@ const UNREACHABLE = Symbol("unreachable");
|
|
|
40
41
|
const TIMED_OUT = Symbol("timed out");
|
|
41
42
|
/** How long a settle (or a replay to find a hold) may take, retries included. */
|
|
42
43
|
const SETTLE_TIMEOUT_MS = 5000;
|
|
43
|
-
|
|
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 = {}) {
|
|
44
52
|
const timeoutMs = options.timeoutMs ?? 1500;
|
|
53
|
+
const sleep = internals.sleep ?? wait;
|
|
54
|
+
const random = internals.random ?? Math.random;
|
|
45
55
|
/** The last fail mode seen per subject + action (closed if any limit is). */
|
|
46
56
|
const remembered = new Map();
|
|
47
57
|
const key = (s, action) => `${s.kind}\u0000${s.id}\u0000${action}`;
|
|
@@ -57,9 +67,24 @@ export function createLimits(client, options = {}) {
|
|
|
57
67
|
};
|
|
58
68
|
/**
|
|
59
69
|
* The decision — or UNREACHABLE (network error, 5xx, past the plan, the
|
|
60
|
-
* flood guard's 429), or TIMED_OUT (no answer within `wait`).
|
|
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`.
|
|
61
73
|
*/
|
|
62
|
-
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) {
|
|
63
88
|
let timer;
|
|
64
89
|
// Raced as well as aborted: a custom fetch (a service binding, a
|
|
65
90
|
// test) may ignore the signal.
|
|
@@ -70,11 +95,19 @@ export function createLimits(client, options = {}) {
|
|
|
70
95
|
try {
|
|
71
96
|
const { data, error, response } = await client.POST("/checks", {
|
|
72
97
|
body,
|
|
73
|
-
headers: {
|
|
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
|
+
},
|
|
74
105
|
signal: AbortSignal.timeout(wait),
|
|
75
106
|
});
|
|
76
107
|
if (data)
|
|
77
108
|
return data;
|
|
109
|
+
if (isRetryLater(response, error))
|
|
110
|
+
return { later: retryAfterMs(response) };
|
|
78
111
|
// Past the plan's allowance (402 plan_limit_reached), or stopped by
|
|
79
112
|
// the edge's flood guard (429): no decision — each limit's fail
|
|
80
113
|
// mode applies, as if Limitry were unreachable.
|
|
@@ -97,7 +130,9 @@ export function createLimits(client, options = {}) {
|
|
|
97
130
|
const answer = await Promise.race([asked, late]);
|
|
98
131
|
if (answer instanceof Error)
|
|
99
132
|
throw answer;
|
|
100
|
-
|
|
133
|
+
if (typeof answer === "object" && "later" in answer)
|
|
134
|
+
return { kind: "later", retryAfterMs: answer.later };
|
|
135
|
+
return { kind: "answer", value: answer };
|
|
101
136
|
}
|
|
102
137
|
finally {
|
|
103
138
|
clearTimeout(timer);
|
|
@@ -109,23 +144,40 @@ export function createLimits(client, options = {}) {
|
|
|
109
144
|
* same expires by itself.
|
|
110
145
|
*/
|
|
111
146
|
async function settle(id, action, cost) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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);
|
|
119
173
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
params: { path: { reservationId: id } },
|
|
123
|
-
signal: AbortSignal.timeout(SETTLE_TIMEOUT_MS),
|
|
124
|
-
});
|
|
174
|
+
catch {
|
|
175
|
+
// The reservation expires on its own.
|
|
125
176
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
177
|
+
if (retryMs === null || waited + retryMs > MOVING_MS)
|
|
178
|
+
return;
|
|
179
|
+
waited += retryMs;
|
|
180
|
+
await sleep(retryMs);
|
|
129
181
|
}
|
|
130
182
|
}
|
|
131
183
|
/**
|
|
@@ -167,16 +219,26 @@ export function createLimits(client, options = {}) {
|
|
|
167
219
|
},
|
|
168
220
|
/** Checks `cost`, runs `fn` only if allowed, and returns its result. */
|
|
169
221
|
async guard(request, fn) {
|
|
170
|
-
const
|
|
222
|
+
const body = {
|
|
171
223
|
subject: request.subject,
|
|
172
224
|
shared: request.shared,
|
|
173
225
|
action: request.action,
|
|
174
226
|
cost: request.cost ?? 1,
|
|
175
|
-
}
|
|
227
|
+
};
|
|
228
|
+
const idempotencyKey = crypto.randomUUID();
|
|
229
|
+
const decision = await check(body, idempotencyKey);
|
|
176
230
|
if (decision === UNREACHABLE || decision === TIMED_OUT) {
|
|
177
231
|
if (failMode(request.subject, request.action) === "closed") {
|
|
178
232
|
throw new Unavailable();
|
|
179
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(() => { });
|
|
241
|
+
}
|
|
180
242
|
return fn();
|
|
181
243
|
}
|
|
182
244
|
remember(request.subject, request.action, decision);
|
|
@@ -257,3 +319,9 @@ export function createLimits(client, options = {}) {
|
|
|
257
319
|
},
|
|
258
320
|
};
|
|
259
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
|
+
}
|
package/dist/resources.gen.d.ts
CHANGED
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
|
/**
|
package/dist/retry.js
CHANGED
|
@@ -13,11 +13,31 @@
|
|
|
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
|
const WRITES = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
18
28
|
const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
19
29
|
export const DEFAULT_MAX_RETRIES = 2;
|
|
20
30
|
export const MAX_WAIT_MS = 20_000;
|
|
31
|
+
/**
|
|
32
|
+
* How long a `moving` or `busy` answer is retried, all told: a handover's
|
|
33
|
+
* freeze lasts at most 30 s.
|
|
34
|
+
*/
|
|
35
|
+
export const MOVING_MS = 30_000;
|
|
36
|
+
/** Sent by a caller that retries `moving` and `busy` itself (never reaches the API). */
|
|
37
|
+
export const MOVING_HEADER = "x-limitry-moving";
|
|
38
|
+
/** The backoff added to a `moving` or `busy` wait: full jitter under base × 2^n, capped. */
|
|
39
|
+
export const JITTER_BASE_MS = 250;
|
|
40
|
+
export const JITTER_CAP_MS = 4_000;
|
|
21
41
|
/** The longest key a caller may pass where a client derives keys from it. */
|
|
22
42
|
export const MAX_BASE_KEY = 200;
|
|
23
43
|
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
@@ -35,6 +55,34 @@ function retryAfter(response) {
|
|
|
35
55
|
const date = Date.parse(value);
|
|
36
56
|
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
37
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The wait before retrying a `moving` or `busy` answer, the `attempt`th
|
|
60
|
+
* in a row (0 first): the server's Retry-After, at least a second (a 0,
|
|
61
|
+
* or a date past, must not spin), plus full jitter — anywhere from 0 to
|
|
62
|
+
* min(JITTER_CAP_MS, JITTER_BASE_MS × 2^attempt).
|
|
63
|
+
*/
|
|
64
|
+
export function retryWaitMs(retryAfterMs, attempt, random = Math.random) {
|
|
65
|
+
const floor = Math.max(1_000, retryAfterMs ?? 1_000);
|
|
66
|
+
const ceiling = Math.min(JITTER_CAP_MS, JITTER_BASE_MS * 2 ** attempt);
|
|
67
|
+
return floor + random() * ceiling;
|
|
68
|
+
}
|
|
69
|
+
/** A `503` that says retry later with the same key: `moving` or `busy` (else null). */
|
|
70
|
+
export async function retryLater(response) {
|
|
71
|
+
if (response.status !== 503)
|
|
72
|
+
return null;
|
|
73
|
+
try {
|
|
74
|
+
const body = (await response.clone().json());
|
|
75
|
+
const code = body.error?.code;
|
|
76
|
+
return code === "moving" || code === "busy" ? code : null;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** A `503` whose error code is `moving`. */
|
|
83
|
+
export async function isMoving(response) {
|
|
84
|
+
return (await retryLater(response)) === "moving";
|
|
85
|
+
}
|
|
38
86
|
async function retryable(response) {
|
|
39
87
|
if (RETRY_STATUSES.has(response.status))
|
|
40
88
|
return true;
|
|
@@ -48,11 +96,31 @@ async function retryable(response) {
|
|
|
48
96
|
return false;
|
|
49
97
|
}
|
|
50
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* `fetch` that only drops MOVING_HEADER (a client with retries off): the
|
|
101
|
+
* header is between the SDK's own parts, never sent to the API.
|
|
102
|
+
*/
|
|
103
|
+
export function withoutMovingHeader(fetch) {
|
|
104
|
+
return (request) => {
|
|
105
|
+
if (!request.headers.has(MOVING_HEADER))
|
|
106
|
+
return fetch(request);
|
|
107
|
+
const headers = new Headers(request.headers);
|
|
108
|
+
headers.delete(MOVING_HEADER);
|
|
109
|
+
return fetch(new Request(request, { headers }));
|
|
110
|
+
};
|
|
111
|
+
}
|
|
51
112
|
/** `fetch` with the rule above. Each request is one logical call. */
|
|
52
113
|
export function withRetries(fetch, options = {}) {
|
|
53
114
|
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
54
115
|
const sleep = options.sleep ?? wait;
|
|
116
|
+
const random = options.random ?? Math.random;
|
|
55
117
|
return async (request) => {
|
|
118
|
+
const returnMoving = request.headers.get(MOVING_HEADER) === "return";
|
|
119
|
+
if (request.headers.has(MOVING_HEADER)) {
|
|
120
|
+
const headers = new Headers(request.headers);
|
|
121
|
+
headers.delete(MOVING_HEADER);
|
|
122
|
+
request = new Request(request, { headers });
|
|
123
|
+
}
|
|
56
124
|
if (WRITES.has(request.method) && !request.headers.has("idempotency-key")) {
|
|
57
125
|
const url = new URL(request.url);
|
|
58
126
|
const key = options.key
|
|
@@ -62,10 +130,12 @@ export function withRetries(fetch, options = {}) {
|
|
|
62
130
|
headers.set("idempotency-key", key);
|
|
63
131
|
request = new Request(request, { headers });
|
|
64
132
|
}
|
|
133
|
+
let movingWaited = 0;
|
|
134
|
+
let movingTries = 0;
|
|
65
135
|
for (let attempt = 0;; attempt++) {
|
|
66
136
|
const last = attempt >= maxRetries;
|
|
67
137
|
// Send a copy while a retry may follow: a body is read once.
|
|
68
|
-
const sending =
|
|
138
|
+
const sending = request.clone();
|
|
69
139
|
let response;
|
|
70
140
|
try {
|
|
71
141
|
response = await fetch(sending);
|
|
@@ -76,6 +146,20 @@ export function withRetries(fetch, options = {}) {
|
|
|
76
146
|
await sleep(backoff(attempt));
|
|
77
147
|
continue;
|
|
78
148
|
}
|
|
149
|
+
const later = await retryLater(response);
|
|
150
|
+
if (later && returnMoving)
|
|
151
|
+
return response;
|
|
152
|
+
if (later) {
|
|
153
|
+
// Retried apart from maxRetries, for up to MOVING_MS.
|
|
154
|
+
const delay = retryWaitMs(retryAfter(response), movingTries++, random);
|
|
155
|
+
if (movingWaited + delay > MOVING_MS)
|
|
156
|
+
return response;
|
|
157
|
+
movingWaited += delay;
|
|
158
|
+
await response.body?.cancel();
|
|
159
|
+
await sleep(delay);
|
|
160
|
+
attempt--;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
79
163
|
if (last || !(await retryable(response)))
|
|
80
164
|
return response;
|
|
81
165
|
const delay = retryAfter(response) ?? backoff(attempt);
|
package/dist/v1.gen.d.ts
CHANGED
|
@@ -1470,6 +1470,13 @@ export interface operations {
|
|
|
1470
1470
|
"application/json": components["schemas"]["CheckDecision"];
|
|
1471
1471
|
};
|
|
1472
1472
|
};
|
|
1473
|
+
/** @description Sent with `limitry-finish-only: 1` (a client finishing an earlier try with the same Idempotency-Key, after a timeout), and there was no earlier try to finish: nothing was decided or charged. */
|
|
1474
|
+
204: {
|
|
1475
|
+
headers: {
|
|
1476
|
+
[name: string]: unknown;
|
|
1477
|
+
};
|
|
1478
|
+
content?: never;
|
|
1479
|
+
};
|
|
1473
1480
|
/** @description Invalid request (code: invalid_request). */
|
|
1474
1481
|
400: {
|
|
1475
1482
|
headers: {
|
|
@@ -1524,7 +1531,7 @@ export interface operations {
|
|
|
1524
1531
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
|
1525
1532
|
};
|
|
1526
1533
|
};
|
|
1527
|
-
/** @description
|
|
1534
|
+
/** @description The subject is moving between Limitry's servers, a few seconds (code: moving, with Retry-After): retry with the same Idempotency-Key — nothing was charged (anything held is given back, or finished by the retry). Or too many checks for these subjects are being decided at once (code: busy, with Retry-After): nothing was charged — retry with the same Idempotency-Key after Retry-After, with backoff. Or checks are unavailable, or a check across Limitry's servers couldn't be confirmed in time and may have been charged (code: internal_error): retry with the same Idempotency-Key. */
|
|
1528
1535
|
503: {
|
|
1529
1536
|
headers: {
|
|
1530
1537
|
[name: string]: unknown;
|
|
@@ -2251,7 +2258,7 @@ export interface operations {
|
|
|
2251
2258
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
|
2252
2259
|
};
|
|
2253
2260
|
};
|
|
2254
|
-
/** @description
|
|
2261
|
+
/** @description The reservation's subject is moving between Limitry's servers, a few seconds (code: moving, with Retry-After): retry — the same settle answers the same. Or checks are unavailable (code: internal_error). */
|
|
2255
2262
|
503: {
|
|
2256
2263
|
headers: {
|
|
2257
2264
|
[name: string]: unknown;
|
|
@@ -2336,7 +2343,7 @@ export interface operations {
|
|
|
2336
2343
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
|
2337
2344
|
};
|
|
2338
2345
|
};
|
|
2339
|
-
/** @description
|
|
2346
|
+
/** @description The reservation's subject is moving between Limitry's servers, a few seconds (code: moving, with Retry-After): retry — the same settle answers the same. Or checks are unavailable (code: internal_error). */
|
|
2340
2347
|
503: {
|
|
2341
2348
|
headers: {
|
|
2342
2349
|
[name: string]: unknown;
|
|
@@ -2417,7 +2424,7 @@ export interface operations {
|
|
|
2417
2424
|
"application/json": components["schemas"]["ErrorEnvelope"];
|
|
2418
2425
|
};
|
|
2419
2426
|
};
|
|
2420
|
-
/** @description
|
|
2427
|
+
/** @description The reservation's subject is moving between Limitry's servers, a few seconds (code: moving, with Retry-After): retry — the same settle answers the same. Or checks are unavailable (code: internal_error). */
|
|
2421
2428
|
503: {
|
|
2422
2429
|
headers: {
|
|
2423
2430
|
[name: string]: unknown;
|