quickbase-js 2.4.0 → 2.4.1

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.
Files changed (46) hide show
  1. package/README.md +144 -76
  2. package/dist/quickbase.esm.js +3 -3
  3. package/dist/quickbase.min.js +3 -3
  4. package/dist/quickbase.min.js.map +1 -1
  5. package/package.json +13 -9
  6. package/src/auth/index.ts +55 -0
  7. package/src/auth/sso-token.ts +143 -0
  8. package/src/auth/temp-token.ts +160 -0
  9. package/src/auth/ticket.ts +329 -0
  10. package/src/auth/token-cache.ts +97 -0
  11. package/src/auth/types.ts +50 -0
  12. package/src/auth/user-token.ts +38 -0
  13. package/src/cli/schema.ts +512 -0
  14. package/src/client/index.ts +36 -0
  15. package/src/client/pagination.ts +337 -0
  16. package/src/client/request.ts +544 -0
  17. package/src/client/retry.ts +169 -0
  18. package/src/client/throttle.ts +105 -0
  19. package/src/core/config.ts +160 -0
  20. package/src/core/dates.ts +63 -0
  21. package/src/core/errors.ts +349 -0
  22. package/src/core/index.ts +50 -0
  23. package/src/core/logger.ts +113 -0
  24. package/src/core/schema.ts +213 -0
  25. package/src/core/transform.ts +295 -0
  26. package/src/core/types.ts +337 -0
  27. package/src/generated/client.ts +1785 -0
  28. package/src/generated/operations.json +672 -0
  29. package/src/generated/types.ts +4884 -0
  30. package/src/index.ts +259 -0
  31. package/src/xml/codepages.ts +119 -0
  32. package/src/xml/dbvars.ts +75 -0
  33. package/src/xml/errors.ts +133 -0
  34. package/src/xml/fields.ts +121 -0
  35. package/src/xml/groups.ts +560 -0
  36. package/src/xml/html.ts +152 -0
  37. package/src/xml/index.ts +1196 -0
  38. package/src/xml/metadata.ts +111 -0
  39. package/src/xml/records.ts +301 -0
  40. package/src/xml/request.ts +238 -0
  41. package/src/xml/roles.ts +321 -0
  42. package/src/xml/schema.ts +436 -0
  43. package/src/xml/transform.ts +381 -0
  44. package/src/xml/types.ts +602 -0
  45. package/src/xml/users.ts +221 -0
  46. package/src/xml/webhooks.ts +260 -0
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Request execution module
3
+ *
4
+ * Handles the full request lifecycle:
5
+ * 1. Authentication
6
+ * 2. Throttling (if enabled)
7
+ * 3. Request execution
8
+ * 4. Error handling with retry
9
+ */
10
+
11
+ import type { RequestContext, ResolvedConfig } from '../core/types.js';
12
+ import type { AuthStrategy } from '../auth/types.js';
13
+ import type { Logger } from '../core/logger.js';
14
+ import type { Throttle } from './throttle.js';
15
+ import {
16
+ QuickbaseError,
17
+ RateLimitError,
18
+ TimeoutError,
19
+ ReadOnlyError,
20
+ parseErrorResponse,
21
+ isRetryableError,
22
+ } from '../core/errors.js';
23
+ import { extractRateLimitInfo, sleep } from './retry.js';
24
+ import { getRealmHostname } from '../core/config.js';
25
+ import { transformDates } from '../core/dates.js';
26
+ import {
27
+ transformRequest,
28
+ transformResponse,
29
+ extractTableIdFromRequest,
30
+ } from '../core/transform.js';
31
+ import { resolveTableAlias } from '../core/schema.js';
32
+
33
+ // =============================================================================
34
+ // Read-Only Mode - Defense-in-Depth
35
+ // =============================================================================
36
+
37
+ /**
38
+ * JSON API endpoints that modify data.
39
+ * Layer 1 of defense-in-depth - explicit blocklist.
40
+ * Uses "METHOD /path" for exact match, "METHOD /path/" for prefix match.
41
+ * Note: Paths do not include /v1 prefix as the SDK uses relative paths.
42
+ */
43
+ const jsonWriteEndpoints: Record<string, boolean> = {
44
+ // Records
45
+ 'POST /records': true, // Upsert
46
+ 'DELETE /records': true, // DeleteRecords
47
+
48
+ // Apps
49
+ 'POST /apps': true, // CreateApp
50
+ 'DELETE /apps': true, // DeleteApp (prefix)
51
+ 'POST /apps/': true, // UpdateApp, CopyApp (prefix)
52
+ 'DELETE /apps/': true, // DeleteApp (prefix)
53
+
54
+ // Tables
55
+ 'POST /tables': true, // CreateTable
56
+ 'DELETE /tables': true, // DeleteTable (prefix)
57
+ 'POST /tables/': true, // UpdateTable, relationships (prefix)
58
+ 'DELETE /tables/': true, // DeleteRelationship (prefix)
59
+
60
+ // Fields
61
+ 'POST /fields': true, // CreateField
62
+ 'DELETE /fields': true, // DeleteFields
63
+ 'POST /fields/': true, // UpdateField (prefix)
64
+
65
+ // Files
66
+ 'DELETE /files/': true, // DeleteFile (prefix)
67
+
68
+ // User tokens
69
+ 'POST /usertoken': true, // Clone, Transfer, Deactivate
70
+ 'DELETE /usertoken': true, // Delete
71
+
72
+ // Users and groups
73
+ 'PUT /users': true, // DenyUsers, UndenyUsers
74
+ 'PUT /users/': true, // DenyUsersAndGroups (prefix)
75
+ 'POST /groups/': true, // AddMembers, AddManagers, AddSubgroups (prefix)
76
+ 'DELETE /groups/': true, // RemoveMembers, RemoveManagers, RemoveSubgroups (prefix)
77
+
78
+ // Solutions
79
+ 'POST /solutions': true, // CreateSolution
80
+ 'PUT /solutions/': true, // UpdateSolution, ChangesetSolution (prefix)
81
+
82
+ // Document generation (GET but writes)
83
+ 'GET /docTemplates/': true, // GenerateDocument (prefix)
84
+
85
+ // Solution from record (GET but writes)
86
+ 'GET /solutions/fromrecord': true, // CreateSolutionFromRecord
87
+ };
88
+
89
+ /**
90
+ * POST endpoints that are actually read-only (allowlist).
91
+ * These are exceptions to the "POST = write" rule.
92
+ * Note: Paths do not include /v1 prefix as the SDK uses relative paths.
93
+ */
94
+ const jsonReadOnlyPOSTEndpoints: Record<string, boolean> = {
95
+ 'POST /records/query': true, // RunQuery - read-only
96
+ 'POST /reports/': true, // RunReport - read-only (prefix)
97
+ 'POST /formula/run': true, // RunFormula - read-only
98
+ 'POST /audit': true, // Audit logs - read-only
99
+ 'POST /users': true, // GetUsers - read-only despite POST
100
+ 'POST /analytics/': true, // Analytics - read-only (prefix)
101
+ };
102
+
103
+ /**
104
+ * Check if HTTP method is a write operation.
105
+ */
106
+ function isWriteMethod(method: string): boolean {
107
+ const upper = method.toUpperCase();
108
+ return upper === 'POST' || upper === 'PUT' || upper === 'DELETE' || upper === 'PATCH';
109
+ }
110
+
111
+ /**
112
+ * Check if request matches a known write endpoint.
113
+ * Uses both exact matching and prefix matching for paths with IDs.
114
+ */
115
+ function isJSONWriteEndpoint(method: string, path: string): boolean {
116
+ const key = `${method} ${path}`;
117
+
118
+ // Exact match
119
+ if (jsonWriteEndpoints[key]) {
120
+ return true;
121
+ }
122
+
123
+ // Prefix match for paths with IDs (e.g., "POST /v1/apps/" matches "POST /v1/apps/abc123")
124
+ for (let i = path.length - 1; i >= 0; i--) {
125
+ if (path[i] === '/') {
126
+ const prefixKey = `${method} ${path.slice(0, i + 1)}`;
127
+ if (jsonWriteEndpoints[prefixKey]) {
128
+ return true;
129
+ }
130
+ }
131
+ }
132
+
133
+ return false;
134
+ }
135
+
136
+ /**
137
+ * Check if a POST request is actually read-only (allowlist).
138
+ */
139
+ function isJSONReadOnlyPOSTEndpoint(path: string): boolean {
140
+ const key = `POST ${path}`;
141
+
142
+ // Exact match
143
+ if (jsonReadOnlyPOSTEndpoints[key]) {
144
+ return true;
145
+ }
146
+
147
+ // Prefix match
148
+ for (let i = path.length - 1; i >= 0; i--) {
149
+ if (path[i] === '/') {
150
+ const prefixKey = `POST ${path.slice(0, i + 1)}`;
151
+ if (jsonReadOnlyPOSTEndpoints[prefixKey]) {
152
+ return true;
153
+ }
154
+ }
155
+ }
156
+
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * Check if request is blocked by read-only mode.
162
+ * Uses two layers of defense:
163
+ * 1. Explicit blocklist of known write endpoints
164
+ * 2. HTTP method check (catch-all for POST/PUT/DELETE/PATCH)
165
+ */
166
+ function checkReadOnly(config: ResolvedConfig, method: string, path: string): void {
167
+ if (!config.readOnly) {
168
+ return;
169
+ }
170
+
171
+ // Layer 1: Explicit blocklist check (defense-in-depth)
172
+ if (isJSONWriteEndpoint(method, path)) {
173
+ // Exception: Some POST endpoints are read-only (RunQuery, RunReport, etc.)
174
+ if (method === 'POST' && isJSONReadOnlyPOSTEndpoint(path)) {
175
+ return;
176
+ }
177
+ throw new ReadOnlyError(method, path);
178
+ }
179
+
180
+ // Layer 2: HTTP method check (catch-all for any endpoints not in blocklist)
181
+ if (isWriteMethod(method)) {
182
+ // Exception: Some POST endpoints are read-only
183
+ if (method === 'POST' && isJSONReadOnlyPOSTEndpoint(path)) {
184
+ return;
185
+ }
186
+ throw new ReadOnlyError(method, path);
187
+ }
188
+ }
189
+
190
+ // =============================================================================
191
+ // Request Types
192
+ // =============================================================================
193
+
194
+ export interface RequestOptions {
195
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
196
+ path: string;
197
+ body?: unknown;
198
+ /** Content type of a body that isn't JSON (QBL is application/x-yaml); the body is sent as given */
199
+ bodyType?: string;
200
+ /** 'text' returns the response body unparsed (QBL, file content) instead of parsing JSON */
201
+ responseType?: 'json' | 'text';
202
+ query?: Record<string, string | number | boolean | undefined>;
203
+ /** Database ID for temp token auth */
204
+ dbid?: string;
205
+ /** Custom headers */
206
+ headers?: Record<string, string>;
207
+ }
208
+
209
+ export interface RequestExecutorOptions {
210
+ config: ResolvedConfig;
211
+ auth: AuthStrategy;
212
+ throttle: Throttle;
213
+ logger: Logger;
214
+ }
215
+
216
+ /**
217
+ * Extract dbid from request options for temp-token auth.
218
+ * Checks multiple locations where table/app ID may appear.
219
+ * Resolves aliases to IDs if schema is provided.
220
+ */
221
+ export function extractDbid(
222
+ options: RequestOptions,
223
+ config?: ResolvedConfig
224
+ ): string | undefined {
225
+ // 1. Explicit dbid takes priority
226
+ if (options.dbid) {
227
+ return config?.schema
228
+ ? resolveTableAlias(config.schema, options.dbid)
229
+ : options.dbid;
230
+ }
231
+
232
+ // 2. Check query params for tableId
233
+ if (options.query?.tableId) {
234
+ const tableId = String(options.query.tableId);
235
+ return config?.schema ? resolveTableAlias(config.schema, tableId) : tableId;
236
+ }
237
+
238
+ // 3. Check query params for appId (for app-level operations)
239
+ if (options.query?.appId) {
240
+ return String(options.query.appId);
241
+ }
242
+
243
+ // 4. Check path for tableId (e.g., /v1/tables/{tableId}/...)
244
+ const tableIdMatch = options.path.match(/\/tables\/([^/?]+)/);
245
+ if (tableIdMatch) {
246
+ return tableIdMatch[1];
247
+ }
248
+
249
+ // 5. Check path for appId (e.g., /v1/apps/{appId}/...)
250
+ const appIdMatch = options.path.match(/\/apps\/([^/?]+)/);
251
+ if (appIdMatch) {
252
+ return appIdMatch[1];
253
+ }
254
+
255
+ // 6. Check request body for 'from' or 'to' fields
256
+ if (options.body && typeof options.body === 'object') {
257
+ const tableId = extractTableIdFromRequest(
258
+ options.body as Record<string, unknown>,
259
+ config?.schema
260
+ );
261
+ if (tableId) return tableId;
262
+ }
263
+
264
+ return undefined;
265
+ }
266
+
267
+ /**
268
+ * Build URL with query parameters
269
+ */
270
+ function buildUrl(
271
+ baseUrl: string,
272
+ path: string,
273
+ query?: Record<string, string | number | boolean | undefined>
274
+ ): string {
275
+ // Ensure baseUrl ends with / and path doesn't start with / to properly join them
276
+ // new URL('/apps/x', 'https://api.quickbase.com/v1') would give https://api.quickbase.com/apps/x (wrong)
277
+ // new URL('apps/x', 'https://api.quickbase.com/v1/') gives https://api.quickbase.com/v1/apps/x (correct)
278
+ const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
279
+ const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
280
+ const url = new URL(normalizedPath, normalizedBase);
281
+
282
+ if (query) {
283
+ for (const [key, value] of Object.entries(query)) {
284
+ if (value !== undefined) {
285
+ url.searchParams.set(key, String(value));
286
+ }
287
+ }
288
+ }
289
+
290
+ return url.toString();
291
+ }
292
+
293
+ /**
294
+ * Create request headers
295
+ */
296
+ async function buildHeaders(
297
+ auth: AuthStrategy,
298
+ realmHostname: string,
299
+ dbid?: string,
300
+ customHeaders?: Record<string, string>
301
+ ): Promise<Record<string, string>> {
302
+ const token = await auth.getToken(dbid);
303
+ const authHeader = auth.getAuthorizationHeader(token);
304
+
305
+ return {
306
+ 'Content-Type': 'application/json',
307
+ 'QB-Realm-Hostname': realmHostname,
308
+ 'Authorization': authHeader,
309
+ ...customHeaders,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Execute a single request (no retry logic)
315
+ */
316
+ async function executeSingleRequest(
317
+ options: RequestOptions,
318
+ executor: RequestExecutorOptions,
319
+ context: RequestContext
320
+ ): Promise<Response> {
321
+ const { config, auth, throttle, logger } = executor;
322
+ const url = buildUrl(config.baseUrl, options.path, options.query);
323
+ const realmHostname = getRealmHostname(config.realm);
324
+
325
+ // Get auth headers (use extracted dbid from context)
326
+ const headers = await buildHeaders(auth, realmHostname, context.dbid, options.headers);
327
+
328
+ // Apply throttling if enabled
329
+ await throttle.acquire();
330
+
331
+ // Build fetch options
332
+ // Use credentials: 'omit' to avoid CORS issues (temp token fetch uses 'include' separately)
333
+ const fetchOptions: RequestInit = {
334
+ method: options.method,
335
+ headers,
336
+ credentials: 'omit',
337
+ };
338
+
339
+ if (options.body !== undefined) {
340
+ if (options.bodyType) {
341
+ headers['Content-Type'] = options.bodyType;
342
+ fetchOptions.body = options.body as string;
343
+ } else {
344
+ fetchOptions.body = JSON.stringify(options.body);
345
+ }
346
+ }
347
+
348
+ // Create abort controller for timeout
349
+ const controller = new AbortController();
350
+ const timeoutId = setTimeout(() => controller.abort(), config.timeout);
351
+ fetchOptions.signal = controller.signal;
352
+
353
+ const startTime = Date.now();
354
+
355
+ try {
356
+ const response = await config.fetchApi(url, fetchOptions);
357
+ const duration = Date.now() - startTime;
358
+
359
+ logger.timing(options.method, options.path, duration);
360
+
361
+ return response;
362
+ } catch (error) {
363
+ if (error instanceof Error && error.name === 'AbortError') {
364
+ throw new TimeoutError(config.timeout);
365
+ }
366
+ throw error;
367
+ } finally {
368
+ clearTimeout(timeoutId);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Handle response and throw appropriate errors
374
+ */
375
+ async function handleResponse(
376
+ response: Response,
377
+ requestUrl: string,
378
+ context: RequestContext,
379
+ executor: RequestExecutorOptions
380
+ ): Promise<Response> {
381
+ const { config, logger } = executor;
382
+
383
+ if (response.ok) {
384
+ return response;
385
+ }
386
+
387
+ // Extract rate limit info for 429s
388
+ if (response.status === 429) {
389
+ const rateLimitInfo = extractRateLimitInfo(response, requestUrl, context.attempt);
390
+
391
+ logger.rateLimit({
392
+ url: requestUrl,
393
+ status: 429,
394
+ retryAfter: rateLimitInfo.retryAfter,
395
+ attempt: context.attempt,
396
+ rayId: rateLimitInfo.qbApiRay || rateLimitInfo.cfRay,
397
+ });
398
+
399
+ // Notify callback
400
+ if (config.onRateLimit) {
401
+ config.onRateLimit(rateLimitInfo);
402
+ }
403
+
404
+ throw new RateLimitError(rateLimitInfo);
405
+ }
406
+
407
+ // Parse and throw appropriate error
408
+ throw await parseErrorResponse(response, requestUrl);
409
+ }
410
+
411
+ /**
412
+ * Execute a request with full retry logic
413
+ */
414
+ export async function executeRequest<T = unknown>(
415
+ options: RequestOptions,
416
+ executor: RequestExecutorOptions
417
+ ): Promise<T> {
418
+ const { config, auth, logger } = executor;
419
+ const maxAttempts = config.retry.maxAttempts;
420
+
421
+ // Check read-only mode before making any request
422
+ checkReadOnly(config, options.method, options.path);
423
+
424
+ // Extract dbid for temp-token auth (resolves aliases if schema provided)
425
+ const dbid = extractDbid(options, config);
426
+
427
+ // Transform the request body: resolve schema aliases when there is a
428
+ // schema, and wrap plain record values ({ '6': 'x' } -> { '6': { value: 'x' } })
429
+ // always. Raw (non-JSON) bodies are strings and pass through.
430
+ let transformedOptions = options;
431
+ if (options.body && typeof options.body === 'object' && !Array.isArray(options.body)) {
432
+ const transformedBody = transformRequest(
433
+ options.body as Record<string, unknown>,
434
+ { schema: config.schema, tableId: dbid }
435
+ );
436
+ transformedOptions = { ...options, body: transformedBody };
437
+ }
438
+
439
+ const context: RequestContext = {
440
+ methodName: `${options.method} ${options.path}`,
441
+ attempt: 1,
442
+ maxAttempts,
443
+ dbid,
444
+ startTime: Date.now(),
445
+ };
446
+
447
+ let lastError: Error | null = null;
448
+
449
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
450
+ context.attempt = attempt;
451
+
452
+ try {
453
+ const response = await executeSingleRequest(transformedOptions, executor, context);
454
+ const url = buildUrl(config.baseUrl, options.path, options.query);
455
+
456
+ await handleResponse(response, url, context, executor);
457
+
458
+ // A response that isn't JSON (QBL, file content) comes back as text
459
+ if (options.responseType === 'text') {
460
+ return (await response.text()) as T;
461
+ }
462
+
463
+ // Parse JSON response
464
+ let data = await response.json();
465
+
466
+ // Transform response if schema is configured
467
+ if (config.schema && dbid) {
468
+ data = transformResponse(data, { schema: config.schema, tableId: dbid });
469
+ }
470
+
471
+ // Optionally transform dates
472
+ return transformDates(data, config.convertDates) as T;
473
+ } catch (error) {
474
+ lastError = error as Error;
475
+
476
+ // Check if error is retryable
477
+ if (!isRetryableError(error)) {
478
+ throw error;
479
+ }
480
+
481
+ // Don't retry if we've exhausted attempts
482
+ if (attempt >= maxAttempts) {
483
+ throw error;
484
+ }
485
+
486
+ // Handle auth errors specially - try to refresh token
487
+ if (error instanceof QuickbaseError && error.statusCode === 401) {
488
+ const refreshed = await auth.handleAuthError(dbid);
489
+ if (!refreshed) {
490
+ throw error;
491
+ }
492
+ logger.debug('Token refreshed, retrying request');
493
+ continue;
494
+ }
495
+
496
+ // Calculate retry delay
497
+ let delayMs: number;
498
+ let reason: string;
499
+
500
+ if (error instanceof RateLimitError) {
501
+ delayMs = error.retryAfter
502
+ ? error.retryAfter * 1000
503
+ : calculateBackoff(attempt, config.retry);
504
+ reason = 'Rate limited (429)';
505
+ } else if (error instanceof TimeoutError) {
506
+ delayMs = calculateBackoff(attempt, config.retry);
507
+ reason = 'Timeout';
508
+ } else if (error instanceof QuickbaseError && error.statusCode >= 500) {
509
+ delayMs = calculateBackoff(attempt, config.retry);
510
+ reason = `Server error (${error.statusCode})`;
511
+ } else {
512
+ delayMs = calculateBackoff(attempt, config.retry);
513
+ reason = 'Retryable error';
514
+ }
515
+
516
+ logger.retry(attempt, maxAttempts, delayMs, reason);
517
+ await sleep(delayMs);
518
+ }
519
+ }
520
+
521
+ throw lastError || new Error('Request failed');
522
+ }
523
+
524
+ /**
525
+ * Calculate exponential backoff delay with jitter
526
+ */
527
+ function calculateBackoff(
528
+ attempt: number,
529
+ config: { initialDelayMs: number; maxDelayMs: number; multiplier: number }
530
+ ): number {
531
+ const delay = config.initialDelayMs * Math.pow(config.multiplier, attempt - 1);
532
+ const jitter = delay * 0.1 * (Math.random() * 2 - 1);
533
+ return Math.min(delay + jitter, config.maxDelayMs);
534
+ }
535
+
536
+ /**
537
+ * Create a request executor
538
+ */
539
+ export function createRequestExecutor(options: RequestExecutorOptions) {
540
+ return {
541
+ execute: <T = unknown>(requestOptions: RequestOptions) =>
542
+ executeRequest<T>(requestOptions, options),
543
+ };
544
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Retry logic with exponential backoff
3
+ *
4
+ * Handles:
5
+ * - HTTP 429 (Rate Limited) - uses Retry-After header
6
+ * - HTTP 5xx (Server Errors) - exponential backoff
7
+ * - Timeout errors - exponential backoff
8
+ */
9
+
10
+ import type { RequestContext, RateLimitInfo, ResolvedConfig } from '../core/types.js';
11
+ import type { Logger } from '../core/logger.js';
12
+ import {
13
+ RateLimitError,
14
+ ServerError,
15
+ TimeoutError,
16
+ isRetryableError,
17
+ } from '../core/errors.js';
18
+
19
+ export interface RetryOptions {
20
+ maxAttempts: number;
21
+ initialDelayMs: number;
22
+ maxDelayMs: number;
23
+ multiplier: number;
24
+ }
25
+
26
+ /**
27
+ * Calculate delay for exponential backoff
28
+ */
29
+ export function calculateBackoffDelay(
30
+ attempt: number,
31
+ options: RetryOptions
32
+ ): number {
33
+ // Exponential backoff: initialDelay * multiplier^(attempt-1)
34
+ const delay = options.initialDelayMs * Math.pow(options.multiplier, attempt - 1);
35
+
36
+ // Add jitter (±10%) to prevent thundering herd
37
+ const jitter = delay * 0.1 * (Math.random() * 2 - 1);
38
+
39
+ return Math.min(delay + jitter, options.maxDelayMs);
40
+ }
41
+
42
+ /**
43
+ * Get delay from Retry-After header or fall back to backoff calculation
44
+ */
45
+ export function getRetryDelay(
46
+ response: Response | null,
47
+ attempt: number,
48
+ options: RetryOptions
49
+ ): number {
50
+ if (response) {
51
+ const retryAfter = response.headers.get('Retry-After');
52
+ if (retryAfter) {
53
+ // Retry-After can be seconds or a date string
54
+ const seconds = parseInt(retryAfter, 10);
55
+ if (!isNaN(seconds)) {
56
+ return seconds * 1000;
57
+ }
58
+
59
+ // Try parsing as a date
60
+ const date = Date.parse(retryAfter);
61
+ if (!isNaN(date)) {
62
+ return Math.max(0, date - Date.now());
63
+ }
64
+ }
65
+ }
66
+
67
+ return calculateBackoffDelay(attempt, options);
68
+ }
69
+
70
+ /**
71
+ * Extract rate limit info from response headers
72
+ */
73
+ export function extractRateLimitInfo(
74
+ response: Response,
75
+ requestUrl: string,
76
+ attempt: number
77
+ ): RateLimitInfo {
78
+ const retryAfter = response.headers.get('Retry-After');
79
+
80
+ return {
81
+ timestamp: new Date(),
82
+ requestUrl,
83
+ httpStatus: response.status,
84
+ retryAfter: retryAfter ? parseInt(retryAfter, 10) : undefined,
85
+ cfRay: response.headers.get('cf-ray') || undefined,
86
+ tid: response.headers.get('tid') || undefined,
87
+ qbApiRay: response.headers.get('qb-api-ray') || undefined,
88
+ attempt,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Sleep for a given duration
94
+ */
95
+ export function sleep(ms: number): Promise<void> {
96
+ return new Promise((resolve) => setTimeout(resolve, ms));
97
+ }
98
+
99
+ /**
100
+ * Determine if a response status is retryable
101
+ */
102
+ export function isRetryableStatus(status: number): boolean {
103
+ return status === 429 || (status >= 500 && status < 600);
104
+ }
105
+
106
+ /**
107
+ * Execute a request with retry logic
108
+ */
109
+ export async function executeWithRetry<T>(
110
+ fn: (context: RequestContext) => Promise<T>,
111
+ context: RequestContext,
112
+ config: ResolvedConfig,
113
+ logger: Logger
114
+ ): Promise<T> {
115
+ const options: RetryOptions = config.retry;
116
+ let lastError: Error | null = null;
117
+
118
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt++) {
119
+ context.attempt = attempt;
120
+
121
+ try {
122
+ return await fn(context);
123
+ } catch (error) {
124
+ lastError = error as Error;
125
+
126
+ // Check if error is retryable
127
+ if (!isRetryableError(error)) {
128
+ throw error;
129
+ }
130
+
131
+ // Don't retry if we've exhausted attempts
132
+ if (attempt >= options.maxAttempts) {
133
+ throw error;
134
+ }
135
+
136
+ // Calculate retry delay
137
+ let delayMs: number;
138
+ let reason: string;
139
+
140
+ if (error instanceof RateLimitError) {
141
+ delayMs = error.retryAfter
142
+ ? error.retryAfter * 1000
143
+ : calculateBackoffDelay(attempt, options);
144
+ reason = `Rate limited (429)`;
145
+
146
+ // Notify callback
147
+ if (config.onRateLimit) {
148
+ config.onRateLimit(error.rateLimitInfo);
149
+ }
150
+ } else if (error instanceof ServerError) {
151
+ delayMs = calculateBackoffDelay(attempt, options);
152
+ reason = `Server error (${error.statusCode})`;
153
+ } else if (error instanceof TimeoutError) {
154
+ delayMs = calculateBackoffDelay(attempt, options);
155
+ reason = `Timeout`;
156
+ } else {
157
+ delayMs = calculateBackoffDelay(attempt, options);
158
+ reason = `Unknown retryable error`;
159
+ }
160
+
161
+ logger.retry(attempt, options.maxAttempts, delayMs, reason);
162
+
163
+ await sleep(delayMs);
164
+ }
165
+ }
166
+
167
+ // This should never be reached, but TypeScript needs it
168
+ throw lastError || new Error('Retry loop exited unexpectedly');
169
+ }