@limitry/sdk 0.0.0-stage → 1.0.0-rc.3

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 ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Safe retries, one rule for every client (the SDK, the CLI, the MCP
3
+ * server; docs/architecture.md "Idempotency"): a WRITE gets one
4
+ * Idempotency-Key per logical call, and every automatic retry of that
5
+ * call sends the same key, so the API runs it once however many times
6
+ * it is sent. Without a key a timed-out create that actually ran would
7
+ * run again on retry; with a fresh key per attempt it would too.
8
+ *
9
+ * Retried: a network failure, 408, 429, 500, 502, 503, 504, and 409
10
+ * `idempotency_in_progress` (the first attempt is still running). Waits
11
+ * the response's Retry-After when it gives one, else a jittered backoff
12
+ * (0.5s, 1s, … capped at 8s); a Retry-After longer than MAX_WAIT_MS is
13
+ * handed back to the caller rather than slept through. A request whose
14
+ * own signal aborted (a caller's timeout) is never retried: the caller
15
+ * decided.
16
+ */
17
+ const WRITES = new Set(["POST", "PUT", "PATCH", "DELETE"]);
18
+ const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
19
+ export const DEFAULT_MAX_RETRIES = 2;
20
+ export const MAX_WAIT_MS = 20_000;
21
+ /** The longest key a caller may pass where a client derives keys from it. */
22
+ export const MAX_BASE_KEY = 200;
23
+ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
24
+ function backoff(attempt) {
25
+ const base = Math.min(500 * 2 ** attempt, 8_000);
26
+ return base * (0.75 + Math.random() * 0.25);
27
+ }
28
+ function retryAfter(response) {
29
+ const value = response.headers.get("retry-after");
30
+ if (!value)
31
+ return undefined;
32
+ const seconds = Number(value);
33
+ if (Number.isFinite(seconds))
34
+ return Math.max(0, seconds * 1000);
35
+ const date = Date.parse(value);
36
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
37
+ }
38
+ async function retryable(response) {
39
+ if (RETRY_STATUSES.has(response.status))
40
+ return true;
41
+ if (response.status !== 409)
42
+ return false;
43
+ try {
44
+ const body = (await response.clone().json());
45
+ return body.error?.code === "idempotency_in_progress";
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ /** `fetch` with the rule above. Each request is one logical call. */
52
+ export function withRetries(fetch, options = {}) {
53
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
54
+ const sleep = options.sleep ?? wait;
55
+ return async (request) => {
56
+ if (WRITES.has(request.method) && !request.headers.has("idempotency-key")) {
57
+ const url = new URL(request.url);
58
+ const key = options.key
59
+ ? await options.key(request.method, url.pathname)
60
+ : crypto.randomUUID();
61
+ const headers = new Headers(request.headers);
62
+ headers.set("idempotency-key", key);
63
+ request = new Request(request, { headers });
64
+ }
65
+ for (let attempt = 0;; attempt++) {
66
+ const last = attempt >= maxRetries;
67
+ // Send a copy while a retry may follow: a body is read once.
68
+ const sending = last ? request : request.clone();
69
+ let response;
70
+ try {
71
+ response = await fetch(sending);
72
+ }
73
+ catch (error) {
74
+ if (last || request.signal.aborted)
75
+ throw error;
76
+ await sleep(backoff(attempt));
77
+ continue;
78
+ }
79
+ if (last || !(await retryable(response)))
80
+ return response;
81
+ const delay = retryAfter(response) ?? backoff(attempt);
82
+ if (delay > MAX_WAIT_MS)
83
+ return response;
84
+ await response.body?.cancel();
85
+ await sleep(delay);
86
+ }
87
+ };
88
+ }
89
+ const hex = (bytes) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
90
+ /**
91
+ * Keys for a command or tool call that may make several writes, from
92
+ * one key its caller chose (`--idempotency-key`, an MCP tool's
93
+ * `idempotencyKey`): each write gets `<key>:<16 hex>`, a digest of its
94
+ * method, path and how many times the call wrote there before — so the
95
+ * same call run again sends the same keys, write for write, and a write
96
+ * it skips the second time shifts no other write's key. Without a key,
97
+ * a fresh UUID per write.
98
+ */
99
+ export function keysFrom(base) {
100
+ if (base !== undefined && (base.length === 0 || base.length > MAX_BASE_KEY)) {
101
+ throw new Error(`the idempotency key must be 1–${MAX_BASE_KEY} characters`);
102
+ }
103
+ if (base !== undefined && !/^[\x21-\x7e]+$/.test(base)) {
104
+ throw new Error("the idempotency key must be printable ASCII with no spaces");
105
+ }
106
+ const seen = new Map();
107
+ return async (method, path) => {
108
+ if (base === undefined)
109
+ return crypto.randomUUID();
110
+ const target = `${method} ${path}`;
111
+ const n = (seen.get(target) ?? 0) + 1;
112
+ seen.set(target, n);
113
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${target}#${n}`));
114
+ return `${base}:${hex(digest).slice(0, 16)}`;
115
+ };
116
+ }