@forumulate/sdk 0.4.0 → 0.7.0

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/http.js ADDED
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ /**
3
+ * The one place a request leaves the SDK: headers, body encoding, retries,
4
+ * response decoding and the error type. Every surface — the positional
5
+ * helpers, `typed()`, `request(operationId)` and the generated namespaces —
6
+ * goes through `send`, so a retry or a header rule cannot apply to one door
7
+ * and not another.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.ForumulateError = exports.SDK_VERSION = void 0;
11
+ exports.retryDelayMs = retryDelayMs;
12
+ exports.send = send;
13
+ /** Kept in step with package.json by `coverage.test.ts`. */
14
+ exports.SDK_VERSION = '0.7.0';
15
+ /**
16
+ * Thrown for any non-2xx API response, and for a network failure that
17
+ * outlived its retries (`status: 0`, `code: 'network.error'`). Mirrors the
18
+ * API's error envelope.
19
+ */
20
+ class ForumulateError extends Error {
21
+ status;
22
+ code;
23
+ details;
24
+ requestId;
25
+ retryAfter;
26
+ body;
27
+ headers;
28
+ constructor(status, code, message, init = {}) {
29
+ super(message, init.cause === undefined ? undefined : { cause: init.cause });
30
+ this.status = status;
31
+ this.code = code;
32
+ this.name = 'ForumulateError';
33
+ this.details = init.details;
34
+ this.requestId = init.requestId;
35
+ this.retryAfter = init.retryAfter;
36
+ this.body = init.body;
37
+ this.headers = init.headers;
38
+ }
39
+ }
40
+ exports.ForumulateError = ForumulateError;
41
+ /** 408, 429 and the gateway family. A 500 is not here: the API means it ("retrying will not help"). */
42
+ const RETRY_STATUS = new Set([408, 429, 502, 503, 504]);
43
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
44
+ /** A Retry-After longer than this is a "come back later", not a retry; surface it. */
45
+ const MAX_RETRY_AFTER_MS = 60_000;
46
+ const hasHeader = (h, name) => Object.keys(h).some((k) => k.toLowerCase() === name.toLowerCase());
47
+ const isRawBody = (b) => (typeof FormData !== 'undefined' && b instanceof FormData) ||
48
+ (typeof Blob !== 'undefined' && b instanceof Blob) ||
49
+ (typeof URLSearchParams !== 'undefined' && b instanceof URLSearchParams) ||
50
+ (typeof ReadableStream !== 'undefined' && b instanceof ReadableStream) ||
51
+ b instanceof ArrayBuffer ||
52
+ ArrayBuffer.isView(b);
53
+ function newIdempotencyKey() {
54
+ const c = globalThis.crypto;
55
+ if (c?.randomUUID)
56
+ return c.randomUUID();
57
+ return 'sdk-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 12);
58
+ }
59
+ /**
60
+ * How long to wait before retry number `attempt` (0-based), or `null` to stop.
61
+ *
62
+ * `Retry-After` wins when present — seconds or an HTTP date — because the
63
+ * API sends it on purpose (a 429's window, a 503's batch interruption). Without
64
+ * it: exponential from 0.5s, capped at 8s, with jitter so a fleet of clients
65
+ * that failed together does not retry together.
66
+ */
67
+ function retryDelayMs(attempt, retryAfter, now = Date.now()) {
68
+ if (retryAfter != null && retryAfter.trim() !== '') {
69
+ const secs = Number(retryAfter);
70
+ const ms = Number.isFinite(secs) ? secs * 1000 : Date.parse(retryAfter) - now;
71
+ if (Number.isFinite(ms))
72
+ return ms > MAX_RETRY_AFTER_MS ? null : Math.max(0, ms);
73
+ }
74
+ return Math.min(500 * 2 ** attempt, 8000) * (1 - Math.random() * 0.25);
75
+ }
76
+ function sleep(ms, signal) {
77
+ return new Promise((resolve, reject) => {
78
+ if (signal?.aborted)
79
+ return reject(signal.reason);
80
+ const t = setTimeout(resolve, ms);
81
+ signal?.addEventListener?.('abort', () => {
82
+ clearTimeout(t);
83
+ reject(signal.reason);
84
+ }, { once: true });
85
+ });
86
+ }
87
+ function buildUrl(base, path, query) {
88
+ const url = new URL(base + path);
89
+ for (const [k, v] of Object.entries(query ?? {})) {
90
+ if (v === undefined || v === null)
91
+ continue;
92
+ // Lists are comma-joined: `include=sections,body` is how the API reads them.
93
+ url.searchParams.set(k, Array.isArray(v) ? v.join(',') : String(v));
94
+ }
95
+ return url.toString();
96
+ }
97
+ async function decode(res, init) {
98
+ if (init.raw || init.kind === 'binary')
99
+ return res;
100
+ const text = await res.text();
101
+ if (init.kind === 'text')
102
+ return text;
103
+ if (!text)
104
+ return undefined;
105
+ const type = res.headers?.get?.('content-type') ?? '';
106
+ // No content type (a test double, an odd proxy) is read as JSON, as it always was.
107
+ if (!type || /json/i.test(type))
108
+ return JSON.parse(text);
109
+ return text;
110
+ }
111
+ async function toError(res) {
112
+ let body;
113
+ try {
114
+ const text = await res.text();
115
+ body = text ? JSON.parse(text) : undefined;
116
+ }
117
+ catch {
118
+ /* non-JSON body (an edge block page) */
119
+ }
120
+ const env = (body ?? {});
121
+ const after = res.headers?.get?.('retry-after');
122
+ const retryAfter = after != null && Number.isFinite(Number(after)) ? Number(after) : undefined;
123
+ return new ForumulateError(res.status, env.error?.code ?? 'error', env.error?.message ?? res.statusText, {
124
+ details: env.error?.details,
125
+ requestId: env.requestId ?? res.headers?.get?.('x-request-id') ?? undefined,
126
+ retryAfter,
127
+ body,
128
+ headers: res.headers,
129
+ });
130
+ }
131
+ async function send(t, init) {
132
+ const method = init.method.toUpperCase();
133
+ const url = buildUrl(t.baseUrl, init.path, init.query);
134
+ /*
135
+ * Ours first, the caller's last, so a per-request header wins.
136
+ *
137
+ * `Idempotency-Key` has to be per-REQUEST (a key reused across two
138
+ * different writes is worse than none), and `Forumulate-Version` is
139
+ * naturally per-client — so both layers exist and the narrower one
140
+ * overrides. `Content-Type` is set before the caller's so a multipart or
141
+ * ndjson body can correct it rather than be forced to JSON.
142
+ */
143
+ const headers = { ...t.headers };
144
+ if (t.token)
145
+ headers.Authorization = `Bearer ${t.token}`;
146
+ const raw = init.body !== undefined && isRawBody(init.body);
147
+ if (init.body !== undefined && !raw)
148
+ headers['Content-Type'] = 'application/json';
149
+ if (init.version &&
150
+ !hasHeader(headers, 'forumulate-version') &&
151
+ !hasHeader(init.headers ?? {}, 'forumulate-version')) {
152
+ headers['Forumulate-Version'] = init.version;
153
+ }
154
+ Object.assign(headers, init.headers ?? {});
155
+ if (init.idempotencyKey)
156
+ headers['Idempotency-Key'] = init.idempotencyKey;
157
+ else if (t.autoIdempotencyKey &&
158
+ (method === 'POST' || method === 'PATCH') &&
159
+ !hasHeader(headers, 'idempotency-key')) {
160
+ // Generated ONCE per call, so every retry of this call carries the same key.
161
+ headers['Idempotency-Key'] = newIdempotencyKey();
162
+ }
163
+ const retryable = SAFE_METHODS.has(method) || hasHeader(headers, 'idempotency-key');
164
+ const maxRetries = retryable ? Math.max(0, init.maxRetries ?? t.maxRetries) : 0;
165
+ const body = init.body === undefined ? undefined : raw ? init.body : JSON.stringify(init.body);
166
+ for (let attempt = 0;; attempt++) {
167
+ let res;
168
+ try {
169
+ res = await t.fetch(url, {
170
+ method,
171
+ headers,
172
+ credentials: t.credentials ? 'include' : 'same-origin',
173
+ body,
174
+ signal: init.signal,
175
+ });
176
+ }
177
+ catch (err) {
178
+ if (err?.name === 'AbortError' || init.signal?.aborted)
179
+ throw err;
180
+ const wait = attempt < maxRetries ? retryDelayMs(attempt, null) : null;
181
+ if (wait === null) {
182
+ throw new ForumulateError(0, 'network.error', err?.message ?? 'Network request failed', { cause: err });
183
+ }
184
+ await sleep(wait, init.signal);
185
+ continue;
186
+ }
187
+ if (res.ok)
188
+ return (await decode(res, init));
189
+ if (attempt < maxRetries && RETRY_STATUS.has(res.status)) {
190
+ const wait = retryDelayMs(attempt, res.headers?.get?.('retry-after'));
191
+ if (wait !== null) {
192
+ await res.text?.().catch(() => undefined);
193
+ await sleep(wait, init.signal);
194
+ continue;
195
+ }
196
+ }
197
+ throw await toError(res);
198
+ }
199
+ }
200
+ //# sourceMappingURL=http.js.map