@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.
- package/CHANGELOG.md +122 -0
- package/README.md +15 -13
- package/dist/index.d.ts +35 -32
- package/dist/index.js +2 -2
- package/dist/product/index.d.ts +4 -4
- package/dist/product/index.js +7 -3
- package/dist/product/limits.d.ts +72 -45
- package/dist/product/limits.js +139 -62
- package/dist/resources.gen.d.ts +35 -32
- package/dist/resources.gen.js +7 -7
- package/dist/retry.d.ts +38 -0
- package/dist/retry.js +85 -1
- package/dist/v1.gen.d.ts +202 -171
- package/package.json +1 -1
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);
|