@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.
Files changed (121) hide show
  1. package/README.md +146 -14
  2. package/dist/client.d.ts +16 -4
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +272 -161
  5. package/dist/client.js.map +1 -1
  6. package/dist/errors.d.ts +1 -1
  7. package/dist/errors.js +1 -1
  8. package/dist/generated/metadata.d.ts.map +1 -1
  9. package/dist/generated/metadata.js +178 -17
  10. package/dist/generated/metadata.js.map +1 -1
  11. package/dist/generated/metadata.ts +178 -17
  12. package/dist/generated/openapi-stripped.json +2126 -309
  13. package/dist/generated/path-mapping.d.ts.map +1 -1
  14. package/dist/generated/path-mapping.js +13 -1
  15. package/dist/generated/path-mapping.js.map +1 -1
  16. package/dist/generated/path-mapping.ts +13 -1
  17. package/dist/generated/schema.d.ts +1416 -176
  18. package/dist/generated/services/bookmarks.d.ts +76 -0
  19. package/dist/generated/services/bookmarks.d.ts.map +1 -0
  20. package/dist/generated/services/bookmarks.js +114 -0
  21. package/dist/generated/services/bookmarks.js.map +1 -0
  22. package/dist/generated/services/bookmarks.ts +158 -0
  23. package/dist/generated/services/calendars.d.ts +47 -0
  24. package/dist/generated/services/calendars.d.ts.map +1 -0
  25. package/dist/generated/services/calendars.js +73 -0
  26. package/dist/generated/services/calendars.js.map +1 -0
  27. package/dist/generated/services/calendars.ts +102 -0
  28. package/dist/generated/services/drafts.d.ts +38 -0
  29. package/dist/generated/services/drafts.d.ts.map +1 -0
  30. package/dist/generated/services/drafts.js +40 -0
  31. package/dist/generated/services/drafts.js.map +1 -0
  32. package/dist/generated/services/drafts.ts +67 -0
  33. package/dist/generated/services/everything.d.ts +69 -22
  34. package/dist/generated/services/everything.d.ts.map +1 -1
  35. package/dist/generated/services/everything.js +23 -35
  36. package/dist/generated/services/everything.js.map +1 -1
  37. package/dist/generated/services/everything.ts +86 -49
  38. package/dist/generated/services/index.d.ts +6 -2
  39. package/dist/generated/services/index.d.ts.map +1 -1
  40. package/dist/generated/services/index.js +6 -2
  41. package/dist/generated/services/index.js.map +1 -1
  42. package/dist/generated/services/index.ts +6 -2
  43. package/dist/generated/services/my-assignments.d.ts +52 -0
  44. package/dist/generated/services/my-assignments.d.ts.map +1 -1
  45. package/dist/generated/services/my-assignments.js +71 -0
  46. package/dist/generated/services/my-assignments.js.map +1 -1
  47. package/dist/generated/services/my-assignments.ts +104 -0
  48. package/dist/generated/services/my-notes.d.ts +45 -0
  49. package/dist/generated/services/my-notes.d.ts.map +1 -0
  50. package/dist/generated/services/my-notes.js +62 -0
  51. package/dist/generated/services/my-notes.js.map +1 -0
  52. package/dist/generated/services/my-notes.ts +92 -0
  53. package/dist/generated/services/todos.d.ts +33 -0
  54. package/dist/generated/services/todos.d.ts.map +1 -1
  55. package/dist/generated/services/todos.js +46 -0
  56. package/dist/generated/services/todos.js.map +1 -1
  57. package/dist/generated/services/todos.ts +71 -0
  58. package/dist/hooks/otel.d.ts +1 -1
  59. package/dist/hooks/otel.js +1 -1
  60. package/dist/hooks.d.ts.map +1 -1
  61. package/dist/hooks.js +4 -1
  62. package/dist/hooks.js.map +1 -1
  63. package/dist/index.d.ts +12 -4
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +12 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/oauth/device-errors.d.ts +29 -0
  68. package/dist/oauth/device-errors.d.ts.map +1 -0
  69. package/dist/oauth/device-errors.js +52 -0
  70. package/dist/oauth/device-errors.js.map +1 -0
  71. package/dist/oauth/device-login.d.ts +50 -0
  72. package/dist/oauth/device-login.d.ts.map +1 -0
  73. package/dist/oauth/device-login.js +118 -0
  74. package/dist/oauth/device-login.js.map +1 -0
  75. package/dist/oauth/device.d.ts +112 -0
  76. package/dist/oauth/device.d.ts.map +1 -0
  77. package/dist/oauth/device.js +746 -0
  78. package/dist/oauth/device.js.map +1 -0
  79. package/dist/oauth/discovery.d.ts.map +1 -1
  80. package/dist/oauth/discovery.js +1 -0
  81. package/dist/oauth/discovery.js.map +1 -1
  82. package/dist/oauth/exchange.d.ts.map +1 -1
  83. package/dist/oauth/exchange.js +75 -9
  84. package/dist/oauth/exchange.js.map +1 -1
  85. package/dist/oauth/index.d.ts +4 -1
  86. package/dist/oauth/index.d.ts.map +1 -1
  87. package/dist/oauth/index.js +4 -0
  88. package/dist/oauth/index.js.map +1 -1
  89. package/dist/oauth/limits.d.ts +15 -0
  90. package/dist/oauth/limits.d.ts.map +1 -0
  91. package/dist/oauth/limits.js +15 -0
  92. package/dist/oauth/limits.js.map +1 -0
  93. package/dist/oauth/token-manager.d.ts.map +1 -1
  94. package/dist/oauth/token-manager.js +15 -4
  95. package/dist/oauth/token-manager.js.map +1 -1
  96. package/dist/oauth/token-store.d.ts.map +1 -1
  97. package/dist/oauth/token-store.js +6 -0
  98. package/dist/oauth/token-store.js.map +1 -1
  99. package/dist/oauth/types.d.ts +35 -3
  100. package/dist/oauth/types.d.ts.map +1 -1
  101. package/dist/pagination.d.ts +6 -2
  102. package/dist/pagination.d.ts.map +1 -1
  103. package/dist/pagination.js +1 -1
  104. package/dist/pagination.js.map +1 -1
  105. package/dist/services/base.d.ts +3 -1
  106. package/dist/services/base.d.ts.map +1 -1
  107. package/dist/services/base.js +16 -5
  108. package/dist/services/base.js.map +1 -1
  109. package/package.json +1 -1
  110. package/src/generated/metadata.ts +178 -17
  111. package/src/generated/openapi-stripped.json +2126 -309
  112. package/src/generated/path-mapping.ts +13 -1
  113. package/src/generated/schema.d.ts +1416 -176
  114. package/src/generated/services/bookmarks.ts +158 -0
  115. package/src/generated/services/calendars.ts +102 -0
  116. package/src/generated/services/drafts.ts +67 -0
  117. package/src/generated/services/everything.ts +86 -49
  118. package/src/generated/services/index.ts +6 -2
  119. package/src/generated/services/my-assignments.ts +104 -0
  120. package/src/generated/services/my-notes.ts +92 -0
  121. 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.10.0";
76
- export const API_VERSION = "2026-07-28";
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
- const client = createClient({ baseUrl });
134
- // Apply middleware in order: auth first, then hooks, then cache, then retry
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
- if (hooks) {
137
- client.use(createHooksMiddleware(hooks));
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
- /** Counter for generating unique request IDs */
294
- let requestIdCounter = 0;
295
- function createHooksMiddleware(hooks) {
296
- // Track request timing by unique request ID
297
- const timings = new Map();
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
- const requestId = request.headers.get("X-SDK-Request-Id") ?? "";
321
- const timing = timings.get(requestId);
322
- const durationMs = timing ? Math.round(performance.now() - timing.startTime) : 0;
323
- const attempt = timing?.attempt ?? 1;
324
- timings.delete(requestId);
325
- const info = {
326
- method: request.method,
327
- url: request.url,
328
- attempt,
329
- };
330
- // Check for cache hit via header set by cache middleware
331
- const fromCacheHeader = response.headers.get("X-From-Cache");
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
- function createRetryMiddleware(hooks, authStrategy) {
616
- // Store request body clones keyed by a request identifier
617
- // This is needed because Request.body can only be read once
618
- const bodyCache = new Map();
619
- return {
620
- async onRequest({ request }) {
621
- // For methods that may have a body, clone it before the initial fetch
622
- // so we can use it for retries. Request.body can only be consumed once.
623
- const method = request.method.toUpperCase();
624
- if (method === "POST" || method === "PUT" || method === "PATCH") {
625
- const requestId = `${method}:${request.url}:${Date.now()}`;
626
- request.headers.set("X-Request-Id", requestId);
627
- if (request.body) {
628
- // Clone the body before it gets consumed
629
- const cloned = request.clone();
630
- bodyCache.set(requestId, await cloned.arrayBuffer());
631
- }
632
- else {
633
- bodyCache.set(requestId, null);
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
- return request;
637
- },
638
- async onResponse({ request, response }) {
639
- // Get operation-specific retry config from metadata
640
- const retryConfig = getRetryConfigForRequest(request.method, request.url);
641
- const requestId = request.headers.get("X-Request-Id");
642
- // Helper to clean up cached body
643
- const cleanupBody = () => {
644
- if (requestId)
645
- bodyCache.delete(requestId);
646
- };
647
- // Check if status code should trigger retry
648
- if (!retryConfig.retryOn.includes(response.status)) {
649
- cleanupBody();
650
- return response;
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
- // Extract current retry attempt from custom header
653
- const attemptHeader = request.headers.get("X-Retry-Attempt");
654
- const attempt = attemptHeader ? parseInt(attemptHeader, 10) : 0;
655
- // Check if we've exhausted retries (maxAttempts is total attempts, not retries)
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
- // Calculate delay
781
+ // For 429, respect Retry-After; otherwise back off.
662
782
  let delay;
663
- // For 429, respect Retry-After header
664
- if (response.status === 429) {
665
- const retryAfter = response.headers.get("Retry-After");
666
- if (retryAfter) {
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
- // Wait before retry
698
- await sleep(delay);
699
- // Get cached body for methods that may have one
700
- let body = null;
701
- if (requestId && bodyCache.has(requestId)) {
702
- const cachedBody = bodyCache.get(requestId);
703
- if (cachedBody) {
704
- body = cachedBody;
705
- }
706
- }
707
- // Create retry request with fresh body
708
- const retryRequest = new Request(request.url, {
709
- method: request.method,
710
- headers: new Headers(request.headers),
711
- body,
712
- signal: request.signal,
713
- });
714
- retryRequest.headers.set("X-Retry-Attempt", String(attempt + 1));
715
- // Refresh auth header for retry (token may have been refreshed since initial request)
716
- if (authStrategy) {
717
- await authStrategy.authenticate(retryRequest.headers);
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
- function sleep(ms) {
743
- return new Promise((resolve) => setTimeout(resolve, ms));
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
  * ```