@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/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 = last ? request : request.clone();
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);