@37signals/basecamp 0.10.0 → 0.12.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/README.md +146 -14
- package/dist/client.d.ts +16 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +272 -161
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/generated/metadata.d.ts.map +1 -1
- package/dist/generated/metadata.js +178 -17
- package/dist/generated/metadata.js.map +1 -1
- package/dist/generated/metadata.ts +178 -17
- package/dist/generated/openapi-stripped.json +2126 -309
- package/dist/generated/path-mapping.d.ts.map +1 -1
- package/dist/generated/path-mapping.js +13 -1
- package/dist/generated/path-mapping.js.map +1 -1
- package/dist/generated/path-mapping.ts +13 -1
- package/dist/generated/schema.d.ts +1416 -176
- package/dist/generated/services/bookmarks.d.ts +76 -0
- package/dist/generated/services/bookmarks.d.ts.map +1 -0
- package/dist/generated/services/bookmarks.js +114 -0
- package/dist/generated/services/bookmarks.js.map +1 -0
- package/dist/generated/services/bookmarks.ts +158 -0
- package/dist/generated/services/calendars.d.ts +47 -0
- package/dist/generated/services/calendars.d.ts.map +1 -0
- package/dist/generated/services/calendars.js +73 -0
- package/dist/generated/services/calendars.js.map +1 -0
- package/dist/generated/services/calendars.ts +102 -0
- package/dist/generated/services/drafts.d.ts +38 -0
- package/dist/generated/services/drafts.d.ts.map +1 -0
- package/dist/generated/services/drafts.js +40 -0
- package/dist/generated/services/drafts.js.map +1 -0
- package/dist/generated/services/drafts.ts +67 -0
- package/dist/generated/services/everything.d.ts +69 -22
- package/dist/generated/services/everything.d.ts.map +1 -1
- package/dist/generated/services/everything.js +23 -35
- package/dist/generated/services/everything.js.map +1 -1
- package/dist/generated/services/everything.ts +86 -49
- package/dist/generated/services/index.d.ts +6 -2
- package/dist/generated/services/index.d.ts.map +1 -1
- package/dist/generated/services/index.js +6 -2
- package/dist/generated/services/index.js.map +1 -1
- package/dist/generated/services/index.ts +6 -2
- package/dist/generated/services/my-assignments.d.ts +52 -0
- package/dist/generated/services/my-assignments.d.ts.map +1 -1
- package/dist/generated/services/my-assignments.js +71 -0
- package/dist/generated/services/my-assignments.js.map +1 -1
- package/dist/generated/services/my-assignments.ts +104 -0
- package/dist/generated/services/my-notes.d.ts +45 -0
- package/dist/generated/services/my-notes.d.ts.map +1 -0
- package/dist/generated/services/my-notes.js +62 -0
- package/dist/generated/services/my-notes.js.map +1 -0
- package/dist/generated/services/my-notes.ts +92 -0
- package/dist/generated/services/todos.d.ts +33 -0
- package/dist/generated/services/todos.d.ts.map +1 -1
- package/dist/generated/services/todos.js +46 -0
- package/dist/generated/services/todos.js.map +1 -1
- package/dist/generated/services/todos.ts +71 -0
- package/dist/hooks/otel.d.ts +1 -1
- package/dist/hooks/otel.js +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +4 -1
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/oauth/device-errors.d.ts +29 -0
- package/dist/oauth/device-errors.d.ts.map +1 -0
- package/dist/oauth/device-errors.js +52 -0
- package/dist/oauth/device-errors.js.map +1 -0
- package/dist/oauth/device-login.d.ts +50 -0
- package/dist/oauth/device-login.d.ts.map +1 -0
- package/dist/oauth/device-login.js +118 -0
- package/dist/oauth/device-login.js.map +1 -0
- package/dist/oauth/device.d.ts +112 -0
- package/dist/oauth/device.d.ts.map +1 -0
- package/dist/oauth/device.js +746 -0
- package/dist/oauth/device.js.map +1 -0
- package/dist/oauth/discovery.d.ts.map +1 -1
- package/dist/oauth/discovery.js +1 -0
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/oauth/exchange.d.ts.map +1 -1
- package/dist/oauth/exchange.js +75 -9
- package/dist/oauth/exchange.js.map +1 -1
- package/dist/oauth/index.d.ts +4 -1
- package/dist/oauth/index.d.ts.map +1 -1
- package/dist/oauth/index.js +4 -0
- package/dist/oauth/index.js.map +1 -1
- package/dist/oauth/limits.d.ts +15 -0
- package/dist/oauth/limits.d.ts.map +1 -0
- package/dist/oauth/limits.js +15 -0
- package/dist/oauth/limits.js.map +1 -0
- package/dist/oauth/token-manager.d.ts.map +1 -1
- package/dist/oauth/token-manager.js +15 -4
- package/dist/oauth/token-manager.js.map +1 -1
- package/dist/oauth/token-store.d.ts.map +1 -1
- package/dist/oauth/token-store.js +6 -0
- package/dist/oauth/token-store.js.map +1 -1
- package/dist/oauth/types.d.ts +35 -3
- package/dist/oauth/types.d.ts.map +1 -1
- package/dist/pagination.d.ts +6 -2
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +1 -1
- package/dist/pagination.js.map +1 -1
- package/dist/services/base.d.ts +3 -1
- package/dist/services/base.d.ts.map +1 -1
- package/dist/services/base.js +16 -5
- package/dist/services/base.js.map +1 -1
- package/package.json +1 -1
- package/src/generated/metadata.ts +178 -17
- package/src/generated/openapi-stripped.json +2126 -309
- package/src/generated/path-mapping.ts +13 -1
- package/src/generated/schema.d.ts +1416 -176
- package/src/generated/services/bookmarks.ts +158 -0
- package/src/generated/services/calendars.ts +102 -0
- package/src/generated/services/drafts.ts +67 -0
- package/src/generated/services/everything.ts +86 -49
- package/src/generated/services/index.ts +6 -2
- package/src/generated/services/my-assignments.ts +104 -0
- package/src/generated/services/my-notes.ts +92 -0
- package/src/generated/services/todos.ts +71 -0
package/dist/client.js
CHANGED
|
@@ -38,6 +38,10 @@ import { ClientApprovalsService } from "./generated/services/client-approvals.js
|
|
|
38
38
|
import { ClientCorrespondencesService } from "./generated/services/client-correspondences.js";
|
|
39
39
|
import { ClientRepliesService } from "./generated/services/client-replies.js";
|
|
40
40
|
import { WebhooksService } from "./generated/services/webhooks.js";
|
|
41
|
+
import { BookmarksService } from "./generated/services/bookmarks.js";
|
|
42
|
+
import { DraftsService } from "./generated/services/drafts.js";
|
|
43
|
+
import { CalendarsService } from "./generated/services/calendars.js";
|
|
44
|
+
import { MyNotesService } from "./generated/services/my-notes.js";
|
|
41
45
|
import { SubscriptionsService } from "./generated/services/subscriptions.js";
|
|
42
46
|
import { AttachmentsService } from "./generated/services/attachments.js";
|
|
43
47
|
import { VaultsService } from "./generated/services/vaults.js";
|
|
@@ -72,8 +76,8 @@ import { AuthorizationService } from "./services/authorization.js";
|
|
|
72
76
|
* a TimeoutOverflowWarning rather than honored.
|
|
73
77
|
*/
|
|
74
78
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
75
|
-
export const VERSION = "0.
|
|
76
|
-
export const API_VERSION = "2026-07-
|
|
79
|
+
export const VERSION = "0.12.0";
|
|
80
|
+
export const API_VERSION = "2026-07-31";
|
|
77
81
|
const DEFAULT_USER_AGENT = `basecamp-sdk-ts/${VERSION} (api:${API_VERSION})`;
|
|
78
82
|
/**
|
|
79
83
|
* Creates a type-safe Basecamp API client with built-in middleware for:
|
|
@@ -130,18 +134,31 @@ export function createBasecampClient(options) {
|
|
|
130
134
|
throw new BasecampError("usage", `Invalid base URL: ${baseUrl}`);
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
|
-
|
|
134
|
-
//
|
|
137
|
+
// One lifecycle, shared between the retrying fetch and the lifecycle
|
|
138
|
+
// middleware: the fetch owns every attempt's start and each abandoned
|
|
139
|
+
// attempt's end; the middleware finalizes the terminal outcome.
|
|
140
|
+
const lifecycle = new RequestLifecycle(hooks);
|
|
141
|
+
// The retry loop lives BENEATH the middleware chain, as the client's custom
|
|
142
|
+
// fetch: openapi-fetch calls it exactly once per logical request, after every
|
|
143
|
+
// onRequest middleware and before every onError/onResponse. Installed
|
|
144
|
+
// unconditionally — with retry disabled it degenerates to a single attempt —
|
|
145
|
+
// so hook emission is single-sourced regardless of config.
|
|
146
|
+
const client = createClient({
|
|
147
|
+
baseUrl,
|
|
148
|
+
fetch: createRetryingFetch(lifecycle, authStrategy, enableRetry),
|
|
149
|
+
});
|
|
150
|
+
// Apply middleware in order: auth, lifecycle, cache.
|
|
151
|
+
// onRequest runs in this order; onResponse and onError run in reverse, so the
|
|
152
|
+
// lifecycle middleware finalizes the terminal attempt only after the cache
|
|
153
|
+
// middleware has had its chance to transform the response.
|
|
135
154
|
client.use(createAuthMiddleware(authStrategy, userAgent, requestTimeoutMs, baseUrl));
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
// Registered even when no hooks are configured. It emits nothing in that case
|
|
156
|
+
// (every call is optional-chained), but registering it unconditionally keeps
|
|
157
|
+
// the finalize path identical whether or not anyone is listening.
|
|
158
|
+
client.use(createLifecycleMiddleware(lifecycle));
|
|
139
159
|
if (enableCache) {
|
|
140
160
|
client.use(createCacheMiddleware());
|
|
141
161
|
}
|
|
142
|
-
if (enableRetry) {
|
|
143
|
-
client.use(createRetryMiddleware(hooks, authStrategy));
|
|
144
|
-
}
|
|
145
162
|
// Create enhanced client with additional properties
|
|
146
163
|
const enhancedClient = client;
|
|
147
164
|
Object.defineProperty(enhancedClient, "raw", {
|
|
@@ -227,6 +244,10 @@ export function createBasecampClient(options) {
|
|
|
227
244
|
defineService("clientCorrespondences", () => new ClientCorrespondencesService(client, hooks, fetchPage, maxPages));
|
|
228
245
|
defineService("clientReplies", () => new ClientRepliesService(client, hooks, fetchPage, maxPages));
|
|
229
246
|
defineService("webhooks", () => new WebhooksService(client, hooks, fetchPage, maxPages));
|
|
247
|
+
defineService("bookmarks", () => new BookmarksService(client, hooks, fetchPage, maxPages));
|
|
248
|
+
defineService("drafts", () => new DraftsService(client, hooks, fetchPage, maxPages));
|
|
249
|
+
defineService("calendars", () => new CalendarsService(client, hooks, fetchPage, maxPages));
|
|
250
|
+
defineService("myNotes", () => new MyNotesService(client, hooks, fetchPage, maxPages));
|
|
230
251
|
defineService("subscriptions", () => new SubscriptionsService(client, hooks, fetchPage, maxPages));
|
|
231
252
|
defineService("attachments", () => new AttachmentsService(client, hooks, fetchPage, maxPages));
|
|
232
253
|
defineService("vaults", () => new VaultsService(client, hooks, fetchPage, maxPages));
|
|
@@ -290,60 +311,104 @@ function createAuthMiddleware(authStrategy, userAgent, requestTimeoutMs, baseUrl
|
|
|
290
311
|
},
|
|
291
312
|
};
|
|
292
313
|
}
|
|
293
|
-
/**
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Owns the onRequestStart / onRequestEnd / onRetry lifecycle for every attempt
|
|
316
|
+
* of every in-flight request, keyed by the final Request object openapi-fetch
|
|
317
|
+
* hands to its custom fetch.
|
|
318
|
+
*
|
|
319
|
+
* openapi-fetch passes that same object to onResponse and onError, so identity
|
|
320
|
+
* holds from the retrying fetch (which begins every attempt) through to the
|
|
321
|
+
* lifecycle middleware (which finalizes the terminal one). Nothing ever goes on
|
|
322
|
+
* the wire — this keying replaced the old `X-SDK-Request-Id` and `X-Request-Id`
|
|
323
|
+
* correlation headers. (`X-Request-Id` in particular is a spec-reserved BC3
|
|
324
|
+
* *response* header; sending an SDK-internal value under that name was a
|
|
325
|
+
* collision.)
|
|
326
|
+
*
|
|
327
|
+
* A WeakMap makes the release bookkeeping structural: once a request settles
|
|
328
|
+
* and its Request is unreachable, the state is collectable — there is no
|
|
329
|
+
* release() to forget.
|
|
330
|
+
*/
|
|
331
|
+
class RequestLifecycle {
|
|
332
|
+
hooks;
|
|
333
|
+
states = new WeakMap();
|
|
334
|
+
constructor(hooks) {
|
|
335
|
+
this.hooks = hooks;
|
|
336
|
+
}
|
|
337
|
+
/** Begin an attempt and fire onRequestStart for it. */
|
|
338
|
+
begin(request, method, url, attempt) {
|
|
339
|
+
this.states.set(request, { startTime: performance.now(), attempt, finalized: false });
|
|
340
|
+
this.emit(() => this.hooks?.onRequestStart?.({ method, url, attempt }));
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Fire onRequestEnd for the in-flight attempt, exactly once.
|
|
344
|
+
*
|
|
345
|
+
* A network failure or timeout carries statusCode 0 and the originating error,
|
|
346
|
+
* matching the multipart transport in services/base.ts and SPEC section 7.
|
|
347
|
+
*/
|
|
348
|
+
finalize(request, method, url, outcome) {
|
|
349
|
+
const state = this.states.get(request);
|
|
350
|
+
if (!state || state.finalized)
|
|
351
|
+
return;
|
|
352
|
+
state.finalized = true;
|
|
353
|
+
const durationMs = Math.round(performance.now() - state.startTime);
|
|
354
|
+
const result = {
|
|
355
|
+
statusCode: outcome.statusCode,
|
|
356
|
+
durationMs,
|
|
357
|
+
fromCache: outcome.fromCache ?? false,
|
|
358
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
359
|
+
};
|
|
360
|
+
this.emit(() => this.hooks?.onRequestEnd?.({ method, url, attempt: state.attempt }, result));
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Fire onRetry between two attempts.
|
|
364
|
+
*
|
|
365
|
+
* SPEC section 7 splits the two numbers: RequestInfo.attempt is the attempt that
|
|
366
|
+
* just FAILED, while the standalone argument is the UPCOMING attempt. Go,
|
|
367
|
+
* Python, Ruby and Kotlin all pass (failed, failed + 1).
|
|
368
|
+
*/
|
|
369
|
+
retrying(method, url, failedAttempt, error, delayMs) {
|
|
370
|
+
this.emit(() => this.hooks?.onRetry?.({ method, url, attempt: failedAttempt }, failedAttempt + 1, error, delayMs));
|
|
371
|
+
}
|
|
372
|
+
emit(fn) {
|
|
373
|
+
try {
|
|
374
|
+
fn();
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// Hooks must never interrupt the request.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function createLifecycleMiddleware(lifecycle) {
|
|
298
382
|
return {
|
|
299
|
-
async onRequest({ request }) {
|
|
300
|
-
// Generate unique request ID to handle concurrent identical requests
|
|
301
|
-
const requestId = `${++requestIdCounter}`;
|
|
302
|
-
request.headers.set("X-SDK-Request-Id", requestId);
|
|
303
|
-
const attemptHeader = request.headers.get("X-Retry-Attempt");
|
|
304
|
-
const attempt = attemptHeader ? parseInt(attemptHeader, 10) + 1 : 1;
|
|
305
|
-
timings.set(requestId, { startTime: performance.now(), attempt });
|
|
306
|
-
const info = {
|
|
307
|
-
method: request.method,
|
|
308
|
-
url: request.url,
|
|
309
|
-
attempt,
|
|
310
|
-
};
|
|
311
|
-
try {
|
|
312
|
-
hooks.onRequestStart?.(info);
|
|
313
|
-
}
|
|
314
|
-
catch {
|
|
315
|
-
// Hooks should not interrupt the request
|
|
316
|
-
}
|
|
317
|
-
return request;
|
|
318
|
-
},
|
|
319
383
|
async onResponse({ request, response }) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const fromCache = fromCacheHeader === "1" ||
|
|
333
|
-
response.status === 304;
|
|
334
|
-
const result = {
|
|
384
|
+
// The authoritative end for the terminal attempt: the retrying fetch
|
|
385
|
+
// begins every attempt but deliberately does not finalize the response it
|
|
386
|
+
// returns, so that this pass can record the outcome the cache middleware
|
|
387
|
+
// has since transformed (a 304 rewritten into a cached 200). finalize
|
|
388
|
+
// stays idempotent as a backstop.
|
|
389
|
+
//
|
|
390
|
+
// fromCache means "served out of the ETag cache", and only the header the
|
|
391
|
+
// cache middleware sets proves that. A bare 304 does NOT: it reaches here
|
|
392
|
+
// when the cache is disabled, or is enabled but holds no entry for this key,
|
|
393
|
+
// and in both cases the caller's own conditional request went to the server.
|
|
394
|
+
const fromCache = response.headers.get("X-From-Cache") === "1";
|
|
395
|
+
lifecycle.finalize(request, request.method, request.url, {
|
|
335
396
|
statusCode: response.status,
|
|
336
|
-
durationMs,
|
|
337
397
|
fromCache,
|
|
338
|
-
};
|
|
339
|
-
try {
|
|
340
|
-
hooks.onRequestEnd?.(info, result);
|
|
341
|
-
}
|
|
342
|
-
catch {
|
|
343
|
-
// Hooks should not interrupt the response
|
|
344
|
-
}
|
|
398
|
+
});
|
|
345
399
|
return response;
|
|
346
400
|
},
|
|
401
|
+
async onError({ request, error }) {
|
|
402
|
+
// A terminal throw from the retrying fetch — a network error, a timeout,
|
|
403
|
+
// or a retry's auth refresh — skips onResponse entirely, so this is the
|
|
404
|
+
// only place it can be observed.
|
|
405
|
+
lifecycle.finalize(request, request.method, request.url, {
|
|
406
|
+
statusCode: 0,
|
|
407
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
408
|
+
});
|
|
409
|
+
// Returning nothing preserves the original error's identity and rethrows it.
|
|
410
|
+
return undefined;
|
|
411
|
+
},
|
|
347
412
|
};
|
|
348
413
|
}
|
|
349
414
|
const MAX_CACHE_ENTRIES = 1000;
|
|
@@ -612,113 +677,138 @@ function getRetryConfigForRequest(method, url) {
|
|
|
612
677
|
}
|
|
613
678
|
return DEFAULT_RETRY_CONFIG;
|
|
614
679
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
680
|
+
/**
|
|
681
|
+
* The retry loop, installed beneath the middleware chain as the client's custom
|
|
682
|
+
* fetch (createClient's `fetch` option). openapi-fetch calls it exactly once
|
|
683
|
+
* per logical request, after every onRequest middleware and before every
|
|
684
|
+
* onError/onResponse — so the loop runs to the operation's full declared
|
|
685
|
+
* maxAttempts, and no attempt ever leaves the chain, because the chain sits
|
|
686
|
+
* above the loop.
|
|
687
|
+
*
|
|
688
|
+
* Division of labor with the lifecycle middleware: the loop begins EVERY
|
|
689
|
+
* attempt and finalizes the attempts it abandons (before the backoff sleep);
|
|
690
|
+
* the terminal outcome — the response it returns, or the error it throws — is
|
|
691
|
+
* deliberately NOT finalized here. It flows up through the cache middleware,
|
|
692
|
+
* which may rewrite a 304 into a cached 200 + X-From-Cache, to the lifecycle
|
|
693
|
+
* middleware, which records the post-transform result. Finalizing in the loop
|
|
694
|
+
* would freeze the pre-transformation status, and the idempotent finalize
|
|
695
|
+
* would keep the hooks pass from correcting it.
|
|
696
|
+
*
|
|
697
|
+
* With retry disabled the loop degenerates to a single attempt but still owns
|
|
698
|
+
* hook emission, so start/end events are single-sourced regardless of config.
|
|
699
|
+
*/
|
|
700
|
+
function createRetryingFetch(lifecycle, authStrategy, enableRetry) {
|
|
701
|
+
return async (request) => {
|
|
702
|
+
const { method, url } = request;
|
|
703
|
+
const retryConfig = getRetryConfigForRequest(method, url);
|
|
704
|
+
const maxAttempts = enableRetry ? retryConfig.maxAttempts : 1;
|
|
705
|
+
// Serialize the body once, before the first send, because Request.body is
|
|
706
|
+
// a stream that can only be consumed once and a retry needs to replay it.
|
|
707
|
+
// clone() tees the stream, so attempt 1 sends the original untouched and
|
|
708
|
+
// replays read from this buffer. Requests that can never retry — including
|
|
709
|
+
// every non-idempotent POST, via NO_RETRY_CONFIG — skip the buffering.
|
|
710
|
+
const upperMethod = method.toUpperCase();
|
|
711
|
+
let bodyBuffer = null;
|
|
712
|
+
if (maxAttempts > 1 &&
|
|
713
|
+
(upperMethod === "POST" || upperMethod === "PUT" || upperMethod === "PATCH") &&
|
|
714
|
+
request.body) {
|
|
715
|
+
bodyBuffer = await request.clone().arrayBuffer();
|
|
716
|
+
}
|
|
717
|
+
let attempt = 1;
|
|
718
|
+
let attemptRequest = request;
|
|
719
|
+
lifecycle.begin(request, method, url, attempt);
|
|
720
|
+
for (;;) {
|
|
721
|
+
if (attempt > 1) {
|
|
722
|
+
// Rebuild from the original request: the headers carry everything the
|
|
723
|
+
// onRequest middleware attached (auth, User-Agent, If-None-Match), and
|
|
724
|
+
// the signal keeps the caller's cancellation and the per-request
|
|
725
|
+
// timeout — one budget shared by all attempts and backoffs.
|
|
726
|
+
attemptRequest = new Request(url, {
|
|
727
|
+
method,
|
|
728
|
+
headers: new Headers(request.headers),
|
|
729
|
+
body: bodyBuffer,
|
|
730
|
+
signal: request.signal,
|
|
731
|
+
});
|
|
732
|
+
// Refresh auth (the token may have rotated since the last attempt).
|
|
733
|
+
// The attempt is already begun, so a throwing refresh lands on a live
|
|
734
|
+
// attempt and propagates to the lifecycle middleware's onError.
|
|
735
|
+
await authStrategy.authenticate(attemptRequest.headers);
|
|
635
736
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
737
|
+
let response;
|
|
738
|
+
try {
|
|
739
|
+
// globalThis.fetch resolved per attempt rather than captured at client
|
|
740
|
+
// creation, so test interceptors that patch it (MSW) are honored.
|
|
741
|
+
response = await globalThis.fetch(attemptRequest);
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
// An abort is terminal no matter what the budget says: a caller
|
|
745
|
+
// cancellation must not re-send, and the per-request timeout is one
|
|
746
|
+
// budget shared by every attempt and backoff — once it fires, a retry
|
|
747
|
+
// would instantly re-reject. Terminal errors are rethrown as-is so
|
|
748
|
+
// their identity survives to the lifecycle middleware's onError (and
|
|
749
|
+
// to the caller).
|
|
750
|
+
//
|
|
751
|
+
// The signal is the authoritative abort test: a caller can abort with
|
|
752
|
+
// a CUSTOM reason — AbortController.abort(reason) — and fetch then
|
|
753
|
+
// rejects with that reason, not a DOMException named AbortError. The
|
|
754
|
+
// DOMException check remains for abort-shaped rejections that arrive
|
|
755
|
+
// without an aborted request signal.
|
|
756
|
+
const isAbort = request.signal?.aborted === true ||
|
|
757
|
+
(error instanceof DOMException &&
|
|
758
|
+
(error.name === "AbortError" || error.name === "TimeoutError"));
|
|
759
|
+
if (isAbort || attempt >= maxAttempts) {
|
|
760
|
+
throw error;
|
|
761
|
+
}
|
|
762
|
+
// Network-error retry rides the same per-operation gate as status
|
|
763
|
+
// retry: a non-idempotent POST resolves NO_RETRY_CONFIG (maxAttempts
|
|
764
|
+
// 1), so it is rethrown above after its single attempt.
|
|
765
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
766
|
+
const delay = calculateBackoffDelay(retryConfig, attempt - 1);
|
|
767
|
+
lifecycle.finalize(request, method, url, { statusCode: 0, error: cause });
|
|
768
|
+
lifecycle.retrying(method, url, attempt, cause, delay);
|
|
769
|
+
await sleep(delay, request.signal);
|
|
770
|
+
// Same placement rationale as the status path below.
|
|
771
|
+
attempt += 1;
|
|
772
|
+
lifecycle.begin(request, method, url, attempt);
|
|
773
|
+
continue;
|
|
651
774
|
}
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
// With maxAttempts=3: attempt 0 (initial), 1 (retry 1), 2 (retry 2) = 3 total
|
|
657
|
-
if (attempt >= retryConfig.maxAttempts - 1) {
|
|
658
|
-
cleanupBody();
|
|
775
|
+
// Terminal: a status outside the operation's declared retryOn set, or a
|
|
776
|
+
// spent budget — maxAttempts is a total attempt count, so attempt N is
|
|
777
|
+
// terminal when it equals the cap.
|
|
778
|
+
if (!retryConfig.retryOn.includes(response.status) || attempt >= maxAttempts) {
|
|
659
779
|
return response;
|
|
660
780
|
}
|
|
661
|
-
//
|
|
781
|
+
// For 429, respect Retry-After; otherwise back off.
|
|
662
782
|
let delay;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const seconds = parseInt(retryAfter, 10);
|
|
668
|
-
if (!isNaN(seconds)) {
|
|
669
|
-
delay = seconds * 1000;
|
|
670
|
-
}
|
|
671
|
-
else {
|
|
672
|
-
delay = calculateBackoffDelay(retryConfig, attempt);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
else {
|
|
676
|
-
delay = calculateBackoffDelay(retryConfig, attempt);
|
|
677
|
-
}
|
|
783
|
+
const retryAfter = response.status === 429 ? response.headers.get("Retry-After") : null;
|
|
784
|
+
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : NaN;
|
|
785
|
+
if (!isNaN(retryAfterSeconds)) {
|
|
786
|
+
delay = retryAfterSeconds * 1000;
|
|
678
787
|
}
|
|
679
788
|
else {
|
|
680
|
-
delay = calculateBackoffDelay(retryConfig, attempt);
|
|
681
|
-
}
|
|
682
|
-
// Notify hooks of retry
|
|
683
|
-
if (hooks?.onRetry) {
|
|
684
|
-
const info = {
|
|
685
|
-
method: request.method,
|
|
686
|
-
url: request.url,
|
|
687
|
-
attempt: attempt + 1,
|
|
688
|
-
};
|
|
689
|
-
const error = new Error(`HTTP ${response.status}: ${response.statusText || "Request failed"}`);
|
|
690
|
-
try {
|
|
691
|
-
hooks.onRetry(info, attempt + 1, error, delay);
|
|
692
|
-
}
|
|
693
|
-
catch {
|
|
694
|
-
// Hooks should not interrupt the retry
|
|
695
|
-
}
|
|
789
|
+
delay = calculateBackoffDelay(retryConfig, attempt - 1);
|
|
696
790
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
}
|
|
719
|
-
// Retry using native fetch
|
|
720
|
-
return fetch(retryRequest);
|
|
721
|
-
},
|
|
791
|
+
const statusError = new Error(`HTTP ${response.status}: ${response.statusText || "Request failed"}`);
|
|
792
|
+
// End the failed attempt before sleeping, so a slow backoff cannot leave
|
|
793
|
+
// an attempt open, then announce the upcoming one.
|
|
794
|
+
lifecycle.finalize(request, method, url, { statusCode: response.status });
|
|
795
|
+
lifecycle.retrying(method, url, attempt, statusError, delay);
|
|
796
|
+
// This response is being discarded, so release its stream before we sleep
|
|
797
|
+
// rather than leaving it open across the backoff — otherwise a throttled
|
|
798
|
+
// client holds a connection per in-flight retry and cannot reuse any of
|
|
799
|
+
// them. The multipart transport in services/base.ts already does this.
|
|
800
|
+
// Errors are ignored: the body may already be consumed or closed.
|
|
801
|
+
void response.body?.cancel().catch(() => { });
|
|
802
|
+
await sleep(delay, request.signal);
|
|
803
|
+
// Begun after the backoff but before any work that can throw. After, so
|
|
804
|
+
// the attempt's duration measures the request rather than the sleep;
|
|
805
|
+
// before, so that if the auth refresh or the fetch throws, the lifecycle
|
|
806
|
+
// middleware's onError still finds a live attempt to finalize. Starting
|
|
807
|
+
// it later would let onRetry announce an attempt and then never account
|
|
808
|
+
// for it.
|
|
809
|
+
attempt += 1;
|
|
810
|
+
lifecycle.begin(request, method, url, attempt);
|
|
811
|
+
}
|
|
722
812
|
};
|
|
723
813
|
}
|
|
724
814
|
function calculateBackoffDelay(config, attempt) {
|
|
@@ -739,8 +829,29 @@ function calculateBackoffDelay(config, attempt) {
|
|
|
739
829
|
const jitter = Math.random() * MAX_JITTER_MS;
|
|
740
830
|
return delay + jitter;
|
|
741
831
|
}
|
|
742
|
-
|
|
743
|
-
|
|
832
|
+
/**
|
|
833
|
+
* Signal-aware sleep for backoff waits: resolves after `ms`, or rejects with
|
|
834
|
+
* the signal's abort reason the moment it fires. Without this, a caller abort
|
|
835
|
+
* or the request-timeout budget expiring during a backoff would leave the
|
|
836
|
+
* request pending for the full delay and then start another attempt — begin,
|
|
837
|
+
* auth refresh, fetch — against an already-aborted signal.
|
|
838
|
+
*/
|
|
839
|
+
function sleep(ms, signal) {
|
|
840
|
+
return new Promise((resolve, reject) => {
|
|
841
|
+
if (signal?.aborted) {
|
|
842
|
+
reject(signal.reason);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const onAbort = () => {
|
|
846
|
+
clearTimeout(timer);
|
|
847
|
+
reject(signal.reason);
|
|
848
|
+
};
|
|
849
|
+
const timer = setTimeout(() => {
|
|
850
|
+
signal?.removeEventListener("abort", onAbort);
|
|
851
|
+
resolve();
|
|
852
|
+
}, ms);
|
|
853
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
854
|
+
});
|
|
744
855
|
}
|
|
745
856
|
// =============================================================================
|
|
746
857
|
// Pagination Helper
|
|
@@ -755,7 +866,7 @@ function sleep(ms) {
|
|
|
755
866
|
*
|
|
756
867
|
* const allProjects = await fetchAllPages(
|
|
757
868
|
* response.response,
|
|
758
|
-
* (r) => r.json()
|
|
869
|
+
* (r) => r.json() as Promise<any[]>
|
|
759
870
|
* );
|
|
760
871
|
* ```
|
|
761
872
|
*/
|
|
@@ -788,7 +899,7 @@ export async function fetchAllPages(initialResponse, parse, authHeader) {
|
|
|
788
899
|
*
|
|
789
900
|
* @example
|
|
790
901
|
* ```ts
|
|
791
|
-
* for await (const page of paginateAll(response.response, (r) => r.json())) {
|
|
902
|
+
* for await (const page of paginateAll(response.response, (r) => r.json() as Promise<any[]>)) {
|
|
792
903
|
* console.log(`Processing ${page.length} items`);
|
|
793
904
|
* }
|
|
794
905
|
* ```
|