@recapi/client 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * HTTP layer: fetch with timeout, JSON, error mapping and retry.
3
+ *
4
+ * Page-hide flushing uses `fetch(..., { keepalive: true })` rather than
5
+ * `navigator.sendBeacon`: a beacon cannot carry the `Authorization` header and
6
+ * the API only accepts `?key=` on `GET /v1/popular`. A keepalive fetch
7
+ * survives page unload like a beacon does, as long as in-flight keepalive
8
+ * bodies stay under the browser's 64 KiB budget.
9
+ *
10
+ * @module transport
11
+ */
12
+
13
+ /** Keepalive requests above the browser's 64 KiB budget are rejected outright. */
14
+ const KEEPALIVE_MAX_BYTES = 60000;
15
+
16
+ const CODE_BY_STATUS = {
17
+ 400: 'invalid_request',
18
+ 401: 'unauthorized',
19
+ 403: 'forbidden',
20
+ 404: 'not_found',
21
+ 413: 'too_large',
22
+ 429: 'rate_limited'
23
+ };
24
+
25
+ /**
26
+ * Every failure surfaced by the SDK. `status` is 0 when no HTTP response was
27
+ * received (`network`, `timeout`) or the error is client-side.
28
+ */
29
+ export class RecapiError extends Error {
30
+ /**
31
+ * @param {string} code API error code (`invalid_request`, `rate_limited`…)
32
+ * or a client code (`network`, `timeout`, `invalid_response`,
33
+ * `event_rejected`, `queue_overflow`).
34
+ * @param {string} message
35
+ * @param {{ status?: number, retryAfterMs?: number, details?: unknown, cause?: unknown }} [extra]
36
+ */
37
+ constructor(code, message, { status = 0, retryAfterMs, details, cause } = {}) {
38
+ super(message, cause === undefined ? undefined : { cause });
39
+ this.name = 'RecapiError';
40
+ this.code = code;
41
+ this.status = status;
42
+ this.retryAfterMs = retryAfterMs;
43
+ this.details = details;
44
+ }
45
+
46
+ /**
47
+ * Whether trying again later can succeed. `quota_exceeded` is a 429 too,
48
+ * but retrying it only burns requests until the quota resets.
49
+ *
50
+ * @returns {boolean}
51
+ */
52
+ get retryable() {
53
+ if (this.code === 'network' || this.code === 'timeout') return true;
54
+ if (this.status === 429) return this.code !== 'quota_exceeded';
55
+ return this.status >= 500;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @typedef {object} RequestOptions
61
+ * @property {'GET' | 'POST'} method
62
+ * @property {string} path
63
+ * @property {Record<string, string | number | undefined>} [query]
64
+ * @property {unknown} [body]
65
+ * @property {'header' | 'query'} [auth] `query` sends `?key=` so GET
66
+ * responses are CDN-cacheable and need no CORS preflight.
67
+ * @property {boolean} [keepalive] Let the request outlive the page.
68
+ * @property {number} [timeoutMs]
69
+ */
70
+
71
+ /**
72
+ * @param {{ baseUrl: string, apiKey: string, fetch: typeof fetch, timeoutMs: number }} options
73
+ * @returns {{ request: (options: RequestOptions) => Promise<any> }}
74
+ */
75
+ export function createTransport({ baseUrl, apiKey, fetch, timeoutMs: defaultTimeoutMs }) {
76
+ const root = baseUrl.replace(/\/+$/, '');
77
+
78
+ /** @param {RequestOptions} options */
79
+ async function request({ method, path, query, body, auth = 'header', keepalive = false, timeoutMs }) {
80
+ const params = new URLSearchParams();
81
+ for (const [name, value] of Object.entries(query ?? {})) {
82
+ if (value !== undefined) params.set(name, String(value));
83
+ }
84
+ if (auth === 'query') params.set('key', apiKey);
85
+ const qs = params.toString();
86
+ const url = `${root}${path}${qs ? `?${qs}` : ''}`;
87
+
88
+ const headers = { Accept: 'application/json' };
89
+ if (auth === 'header') headers.Authorization = `Bearer ${apiKey}`;
90
+ const init = { method, headers };
91
+ if (body !== undefined) {
92
+ headers['Content-Type'] = 'application/json';
93
+ init.body = JSON.stringify(body);
94
+ if (keepalive && byteLength(init.body) <= KEEPALIVE_MAX_BYTES) init.keepalive = true;
95
+ }
96
+
97
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
98
+ if (controller) init.signal = controller.signal;
99
+ const ms = timeoutMs ?? defaultTimeoutMs;
100
+ const timer = controller ? setTimeout(() => controller.abort(), ms) : null;
101
+
102
+ try {
103
+ let response;
104
+ try {
105
+ response = await fetch(url, init);
106
+ } catch (error) {
107
+ throw transportError(error, controller, ms);
108
+ }
109
+ let text;
110
+ try {
111
+ text = await response.text();
112
+ } catch (error) {
113
+ throw transportError(error, controller, ms);
114
+ }
115
+ const json = parseJson(text);
116
+ if (!response.ok) throw httpError(response, json);
117
+ return json;
118
+ } finally {
119
+ if (timer) clearTimeout(timer);
120
+ }
121
+ }
122
+
123
+ return { request };
124
+ }
125
+
126
+ function byteLength(text) {
127
+ return typeof TextEncoder === 'function' ? new TextEncoder().encode(text).length : text.length * 3;
128
+ }
129
+
130
+ function transportError(error, controller, ms) {
131
+ if (controller?.signal.aborted) {
132
+ return new RecapiError('timeout', `request timed out after ${ms} ms`, { cause: error });
133
+ }
134
+ return new RecapiError('network', error?.message || 'network error', { cause: error });
135
+ }
136
+
137
+ function parseJson(text) {
138
+ if (!text) return null;
139
+ try {
140
+ return JSON.parse(text);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function httpError(response, json) {
147
+ const status = response.status;
148
+ const code = json?.error?.code || CODE_BY_STATUS[status] || (status >= 500 ? 'internal' : 'http_error');
149
+ const message = json?.error?.message || `HTTP ${status}`;
150
+ return new RecapiError(code, message, { status, retryAfterMs: retryAfter(response) });
151
+ }
152
+
153
+ function retryAfter(response) {
154
+ const header = response.headers?.get?.('retry-after');
155
+ const seconds = header ? Number(header) : NaN;
156
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
157
+ }
158
+
159
+ /**
160
+ * Retries `fn` on retryable {@link RecapiError}s with exponential backoff
161
+ * and jitter (honouring `Retry-After`, capped at `maxDelayMs`). Only used for
162
+ * events: a recommendation that arrives seconds late is useless, so
163
+ * `recommend()` fails fast and falls back to popular items instead.
164
+ *
165
+ * @template T
166
+ * @param {() => Promise<T>} fn
167
+ * @param {{ retries?: number, baseDelayMs?: number, maxDelayMs?: number,
168
+ * sleep?: (ms: number) => Promise<void>, random?: () => number }} [options]
169
+ * @returns {Promise<T>}
170
+ */
171
+ export async function withRetry(fn, {
172
+ retries = 3,
173
+ baseDelayMs = 500,
174
+ maxDelayMs = 8000,
175
+ sleep = defaultSleep,
176
+ random = Math.random
177
+ } = {}) {
178
+ for (let attempt = 0; ; attempt++) {
179
+ try {
180
+ return await fn();
181
+ } catch (error) {
182
+ if (attempt >= retries || !(error instanceof RecapiError) || !error.retryable) throw error;
183
+ const backoff = baseDelayMs * 2 ** attempt * (0.5 + random() / 2);
184
+ await sleep(Math.min(maxDelayMs, error.retryAfterMs ?? backoff));
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * @param {number} ms
191
+ * @returns {Promise<void>}
192
+ */
193
+ export function defaultSleep(ms) {
194
+ return new Promise((resolve) => setTimeout(resolve, ms));
195
+ }