@37signals/basecamp 0.9.0 → 0.11.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 (122) hide show
  1. package/README.md +147 -15
  2. package/dist/client.d.ts +8 -5
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +237 -144
  5. package/dist/client.js.map +1 -1
  6. package/dist/generated/metadata.d.ts.map +1 -1
  7. package/dist/generated/metadata.js +280 -12
  8. package/dist/generated/metadata.js.map +1 -1
  9. package/dist/generated/metadata.ts +280 -12
  10. package/dist/generated/openapi-stripped.json +4403 -2124
  11. package/dist/generated/path-mapping.d.ts.map +1 -1
  12. package/dist/generated/path-mapping.js +22 -5
  13. package/dist/generated/path-mapping.js.map +1 -1
  14. package/dist/generated/path-mapping.ts +22 -5
  15. package/dist/generated/schema.d.ts +2983 -1220
  16. package/dist/generated/services/boosts.d.ts +13 -10
  17. package/dist/generated/services/boosts.d.ts.map +1 -1
  18. package/dist/generated/services/boosts.js +5 -5
  19. package/dist/generated/services/boosts.js.map +1 -1
  20. package/dist/generated/services/boosts.ts +12 -10
  21. package/dist/generated/services/cards.d.ts +4 -4
  22. package/dist/generated/services/cards.d.ts.map +1 -1
  23. package/dist/generated/services/cards.js +2 -2
  24. package/dist/generated/services/cards.js.map +1 -1
  25. package/dist/generated/services/cards.ts +4 -4
  26. package/dist/generated/services/everything.d.ts +303 -0
  27. package/dist/generated/services/everything.d.ts.map +1 -0
  28. package/dist/generated/services/everything.js +359 -0
  29. package/dist/generated/services/everything.js.map +1 -0
  30. package/dist/generated/services/everything.ts +594 -0
  31. package/dist/generated/services/index.d.ts +2 -1
  32. package/dist/generated/services/index.d.ts.map +1 -1
  33. package/dist/generated/services/index.js +2 -1
  34. package/dist/generated/services/index.js.map +1 -1
  35. package/dist/generated/services/index.ts +2 -1
  36. package/dist/generated/services/message-types.d.ts +15 -10
  37. package/dist/generated/services/message-types.d.ts.map +1 -1
  38. package/dist/generated/services/message-types.js +35 -18
  39. package/dist/generated/services/message-types.js.map +1 -1
  40. package/dist/generated/services/message-types.ts +34 -18
  41. package/dist/generated/services/my-notifications.d.ts +27 -0
  42. package/dist/generated/services/my-notifications.d.ts.map +1 -1
  43. package/dist/generated/services/my-notifications.js +23 -1
  44. package/dist/generated/services/my-notifications.js.map +1 -1
  45. package/dist/generated/services/my-notifications.ts +46 -1
  46. package/dist/generated/services/recordings.d.ts +2 -2
  47. package/dist/generated/services/recordings.d.ts.map +1 -1
  48. package/dist/generated/services/recordings.js +1 -1
  49. package/dist/generated/services/recordings.js.map +1 -1
  50. package/dist/generated/services/recordings.ts +2 -2
  51. package/dist/generated/services/schedules.d.ts +9 -1
  52. package/dist/generated/services/schedules.d.ts.map +1 -1
  53. package/dist/generated/services/schedules.js.map +1 -1
  54. package/dist/generated/services/schedules.ts +9 -1
  55. package/dist/generated/services/tools.d.ts +2 -0
  56. package/dist/generated/services/tools.d.ts.map +1 -1
  57. package/dist/generated/services/tools.js +1 -0
  58. package/dist/generated/services/tools.js.map +1 -1
  59. package/dist/generated/services/tools.ts +3 -0
  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 +9 -3
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +13 -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/exchange.d.ts.map +1 -1
  80. package/dist/oauth/exchange.js +75 -9
  81. package/dist/oauth/exchange.js.map +1 -1
  82. package/dist/oauth/index.d.ts +4 -1
  83. package/dist/oauth/index.d.ts.map +1 -1
  84. package/dist/oauth/index.js +4 -0
  85. package/dist/oauth/index.js.map +1 -1
  86. package/dist/oauth/limits.d.ts +15 -0
  87. package/dist/oauth/limits.d.ts.map +1 -0
  88. package/dist/oauth/limits.js +15 -0
  89. package/dist/oauth/limits.js.map +1 -0
  90. package/dist/oauth/token-manager.d.ts.map +1 -1
  91. package/dist/oauth/token-manager.js +15 -4
  92. package/dist/oauth/token-manager.js.map +1 -1
  93. package/dist/oauth/token-store.d.ts.map +1 -1
  94. package/dist/oauth/token-store.js +6 -0
  95. package/dist/oauth/token-store.js.map +1 -1
  96. package/dist/oauth/types.d.ts +35 -3
  97. package/dist/oauth/types.d.ts.map +1 -1
  98. package/dist/pagination.d.ts +5 -1
  99. package/dist/pagination.d.ts.map +1 -1
  100. package/dist/pagination.js.map +1 -1
  101. package/dist/services/base.d.ts +3 -1
  102. package/dist/services/base.d.ts.map +1 -1
  103. package/dist/services/base.js +16 -5
  104. package/dist/services/base.js.map +1 -1
  105. package/dist/services/cards-extensions.d.ts +76 -0
  106. package/dist/services/cards-extensions.d.ts.map +1 -0
  107. package/dist/services/cards-extensions.js +67 -0
  108. package/dist/services/cards-extensions.js.map +1 -0
  109. package/package.json +2 -2
  110. package/src/generated/metadata.ts +280 -12
  111. package/src/generated/openapi-stripped.json +4403 -2124
  112. package/src/generated/path-mapping.ts +22 -5
  113. package/src/generated/schema.d.ts +2983 -1220
  114. package/src/generated/services/boosts.ts +12 -10
  115. package/src/generated/services/cards.ts +4 -4
  116. package/src/generated/services/everything.ts +594 -0
  117. package/src/generated/services/index.ts +2 -1
  118. package/src/generated/services/message-types.ts +34 -18
  119. package/src/generated/services/my-notifications.ts +46 -1
  120. package/src/generated/services/recordings.ts +2 -2
  121. package/src/generated/services/schedules.ts +9 -1
  122. package/src/generated/services/tools.ts +3 -0
package/dist/client.js CHANGED
@@ -26,7 +26,7 @@ import { MessagesService } from "./generated/services/messages.js";
26
26
  import { CommentsService } from "./generated/services/comments.js";
27
27
  import { CampfiresService } from "./generated/services/campfires.js";
28
28
  import { CardTablesService } from "./generated/services/card-tables.js";
29
- import { CardsService } from "./generated/services/cards.js";
29
+ import { CardsService } from "./services/cards-extensions.js";
30
30
  import { CardColumnsService } from "./generated/services/card-columns.js";
31
31
  import { CardStepsService } from "./generated/services/card-steps.js";
32
32
  import { WormholesService } from "./generated/services/wormholes.js";
@@ -55,6 +55,7 @@ import { TodolistGroupsService } from "./generated/services/todolist-groups.js";
55
55
  import { ToolsService } from "./generated/services/tools.js";
56
56
  import { TimesheetsService } from "./generated/services/timesheets.js";
57
57
  import { TimelineService } from "./generated/services/timeline.js";
58
+ import { EverythingService } from "./generated/services/everything.js";
58
59
  import { ClientVisibilityService } from "./generated/services/client-visibility.js";
59
60
  import { BoostsService } from "./generated/services/boosts.js";
60
61
  import { AccountService } from "./generated/services/account.js";
@@ -65,8 +66,14 @@ import { MyNotificationsService } from "./generated/services/my-notifications.js
65
66
  // Services - Hand-written (not spec-driven, e.g., OAuth flows)
66
67
  // ============================================================================
67
68
  import { AuthorizationService } from "./services/authorization.js";
68
- export const VERSION = "0.9.0";
69
- export const API_VERSION = "2026-07-24";
69
+ /**
70
+ * Largest delay `AbortSignal.timeout` schedules faithfully: Node's timers are
71
+ * backed by a signed 32-bit int, and anything above this is clamped to 1ms with
72
+ * a TimeoutOverflowWarning rather than honored.
73
+ */
74
+ const MAX_TIMEOUT_MS = 2_147_483_647;
75
+ export const VERSION = "0.11.0";
76
+ export const API_VERSION = "2026-07-30";
70
77
  const DEFAULT_USER_AGENT = `basecamp-sdk-ts/${VERSION} (api:${API_VERSION})`;
71
78
  /**
72
79
  * Creates a type-safe Basecamp API client with built-in middleware for:
@@ -95,6 +102,19 @@ export function createBasecampClient(options) {
95
102
  if (!auth && !accessToken) {
96
103
  throw new BasecampError("usage", "Either 'auth' or 'accessToken' is required");
97
104
  }
105
+ // AbortSignal.timeout accepts only a non-negative integer that fits a signed
106
+ // 32-bit timer. Outside that range Node either throws a bare RangeError
107
+ // (fractional, negative, or > 2^32-1) or — worse — silently clamps to 1ms with
108
+ // a TimeoutOverflowWarning (2^31 .. 2^32-1), which would abort every request
109
+ // almost immediately. Both would surface per request, far from the call that
110
+ // misconfigured it, and both are behavior changes from the old setTimeout,
111
+ // which coerced such values to 0. Fail fast at construction instead, like the
112
+ // other config checks.
113
+ if (!Number.isInteger(requestTimeoutMs) ||
114
+ requestTimeoutMs < 0 ||
115
+ requestTimeoutMs > MAX_TIMEOUT_MS) {
116
+ throw new BasecampError("usage", `'requestTimeoutMs' must be an integer between 0 and ${MAX_TIMEOUT_MS}, got ${requestTimeoutMs}`);
117
+ }
98
118
  const authStrategy = auth ?? bearerAuth(accessToken);
99
119
  // Validate configuration (skip HTTPS check for localhost in dev/test)
100
120
  if (baseUrl) {
@@ -111,16 +131,26 @@ export function createBasecampClient(options) {
111
131
  }
112
132
  }
113
133
  const client = createClient({ baseUrl });
114
- // Apply middleware in order: auth first, then hooks, then cache, then retry
134
+ // Apply middleware in order: auth, lifecycle, cache, retry.
135
+ // onRequest runs in this order; onResponse and onError run in reverse, so the
136
+ // retry middleware sees a response first and the lifecycle middleware last.
115
137
  client.use(createAuthMiddleware(authStrategy, userAgent, requestTimeoutMs, baseUrl));
116
- if (hooks) {
117
- client.use(createHooksMiddleware(hooks));
118
- }
138
+ // One lifecycle, shared with the retry middleware: the retry loop lives inside a
139
+ // single middleware callback, so per-attempt hooks have to be emitted from there
140
+ // rather than by a middleware that is only visited once per request.
141
+ const lifecycle = new RequestLifecycle(hooks);
142
+ // Registered even when no hooks are configured. It emits nothing in that case
143
+ // (every call is optional-chained), but it still owns releasing per-request
144
+ // state — and the retry middleware records an attempt whether or not anyone is
145
+ // listening, so skipping this would strand one map entry per retried request.
146
+ client.use(createLifecycleMiddleware(lifecycle));
119
147
  if (enableCache) {
120
148
  client.use(createCacheMiddleware());
121
149
  }
150
+ // Registered last so that on the reverse-order response pass it runs first,
151
+ // before the cache and lifecycle middleware see the response.
122
152
  if (enableRetry) {
123
- client.use(createRetryMiddleware(hooks, authStrategy));
153
+ client.use(createRetryMiddleware(lifecycle, authStrategy));
124
154
  }
125
155
  // Create enhanced client with additional properties
126
156
  const enhancedClient = client;
@@ -227,6 +257,7 @@ export function createBasecampClient(options) {
227
257
  defineService("tools", () => new ToolsService(client, hooks, fetchPage, maxPages));
228
258
  defineService("timesheets", () => new TimesheetsService(client, hooks, fetchPage, maxPages));
229
259
  defineService("timeline", () => new TimelineService(client, hooks, fetchPage, maxPages));
260
+ defineService("everything", () => new EverythingService(client, hooks, fetchPage, maxPages));
230
261
  defineService("clientVisibility", () => new ClientVisibilityService(client, hooks, fetchPage, maxPages));
231
262
  defineService("boosts", () => new BoostsService(client, hooks, fetchPage, maxPages));
232
263
  defineService("account", () => new AccountService(client, hooks, fetchPage, maxPages, authenticatedFetch, baseUrl));
@@ -252,78 +283,134 @@ function createAuthMiddleware(authStrategy, userAgent, requestTimeoutMs, baseUrl
252
283
  request.headers.set("Content-Type", "application/json");
253
284
  }
254
285
  request.headers.set("Accept", "application/json");
255
- // Apply request timeout (Node 18-compatible: no AbortSignal.any)
256
- const controller = new AbortController();
257
- setTimeout(() => controller.abort(), requestTimeoutMs);
258
- if (request.signal) {
259
- request.signal.addEventListener("abort", () => controller.abort(), {
260
- once: true,
261
- });
262
- }
286
+ // Apply request timeout, preserving any caller-supplied signal.
287
+ // AbortSignal.timeout's timer is unref'd, so unlike a bare setTimeout it
288
+ // never holds the event loop open after the request settles.
289
+ const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
290
+ const signal = request.signal
291
+ ? AbortSignal.any([request.signal, timeoutSignal])
292
+ : timeoutSignal;
263
293
  return new Request(request.url, {
264
294
  method: request.method,
265
295
  headers: request.headers,
266
296
  body: request.body,
267
- signal: controller.signal,
297
+ signal,
268
298
  duplex: request.body ? "half" : undefined,
269
299
  });
270
300
  },
271
301
  };
272
302
  }
273
- /** Counter for generating unique request IDs */
274
- let requestIdCounter = 0;
275
- function createHooksMiddleware(hooks) {
276
- // Track request timing by unique request ID
277
- const timings = new Map();
303
+ /**
304
+ * Owns the onRequestStart / onRequestEnd / onRetry lifecycle for every attempt of
305
+ * every in-flight request, keyed by openapi-fetch's per-request `id`.
306
+ *
307
+ * The id is minted once per logical request (one client.GET/POST/... call) and is
308
+ * passed unchanged to onRequest, onResponse and onError, so it survives a
309
+ * middleware replacing the Request object. It deliberately does NOT distinguish
310
+ * attempts — per-attempt data lives nested under it here — and it never goes on
311
+ * the wire, which is why this replaces the old `X-SDK-Request-Id` and
312
+ * `X-Request-Id` correlation headers. (`X-Request-Id` in particular is a
313
+ * spec-reserved BC3 *response* header; sending an SDK-internal value under that
314
+ * name was a collision.)
315
+ *
316
+ * Shared with the retry middleware because the retry loop lives inside
317
+ * one middleware callback: openapi-fetch visits each middleware once per request,
318
+ * so per-attempt hooks cannot be emitted by a separate middleware.
319
+ */
320
+ class RequestLifecycle {
321
+ hooks;
322
+ states = new Map();
323
+ constructor(hooks) {
324
+ this.hooks = hooks;
325
+ }
326
+ /** Begin an attempt and fire onRequestStart for it. */
327
+ begin(id, method, url, attempt) {
328
+ this.states.set(id, { startTime: performance.now(), attempt, finalized: false });
329
+ this.emit(() => this.hooks?.onRequestStart?.({ method, url, attempt }));
330
+ }
331
+ /** The attempt currently in flight, or 1 if we have no record. */
332
+ attemptOf(id) {
333
+ return this.states.get(id)?.attempt ?? 1;
334
+ }
335
+ /**
336
+ * Fire onRequestEnd for the in-flight attempt, exactly once.
337
+ *
338
+ * A network failure or timeout carries statusCode 0 and the originating error,
339
+ * matching the multipart transport in services/base.ts and SPEC section 7.
340
+ */
341
+ finalize(id, method, url, outcome) {
342
+ const state = this.states.get(id);
343
+ if (!state || state.finalized)
344
+ return;
345
+ state.finalized = true;
346
+ const durationMs = Math.round(performance.now() - state.startTime);
347
+ const result = {
348
+ statusCode: outcome.statusCode,
349
+ durationMs,
350
+ fromCache: outcome.fromCache ?? false,
351
+ ...(outcome.error ? { error: outcome.error } : {}),
352
+ };
353
+ this.emit(() => this.hooks?.onRequestEnd?.({ method, url, attempt: state.attempt }, result));
354
+ }
355
+ /**
356
+ * Fire onRetry between two attempts.
357
+ *
358
+ * SPEC section 7 splits the two numbers: RequestInfo.attempt is the attempt that
359
+ * just FAILED, while the standalone argument is the UPCOMING attempt. Go,
360
+ * Python, Ruby and Kotlin all pass (failed, failed + 1).
361
+ */
362
+ retrying(id, method, url, failedAttempt, error, delayMs) {
363
+ this.emit(() => this.hooks?.onRetry?.({ method, url, attempt: failedAttempt }, failedAttempt + 1, error, delayMs));
364
+ }
365
+ /** Drop all state for a logical request. Safe to call more than once. */
366
+ release(id) {
367
+ this.states.delete(id);
368
+ }
369
+ emit(fn) {
370
+ try {
371
+ fn();
372
+ }
373
+ catch {
374
+ // Hooks must never interrupt the request.
375
+ }
376
+ }
377
+ }
378
+ function createLifecycleMiddleware(lifecycle) {
278
379
  return {
279
- async onRequest({ request }) {
280
- // Generate unique request ID to handle concurrent identical requests
281
- const requestId = `${++requestIdCounter}`;
282
- request.headers.set("X-SDK-Request-Id", requestId);
283
- const attemptHeader = request.headers.get("X-Retry-Attempt");
284
- const attempt = attemptHeader ? parseInt(attemptHeader, 10) + 1 : 1;
285
- timings.set(requestId, { startTime: performance.now(), attempt });
286
- const info = {
287
- method: request.method,
288
- url: request.url,
289
- attempt,
290
- };
291
- try {
292
- hooks.onRequestStart?.(info);
293
- }
294
- catch {
295
- // Hooks should not interrupt the request
296
- }
380
+ async onRequest({ request, id }) {
381
+ lifecycle.begin(id, request.method, request.url, 1);
297
382
  return request;
298
383
  },
299
- async onResponse({ request, response }) {
300
- const requestId = request.headers.get("X-SDK-Request-Id") ?? "";
301
- const timing = timings.get(requestId);
302
- const durationMs = timing ? Math.round(performance.now() - timing.startTime) : 0;
303
- const attempt = timing?.attempt ?? 1;
304
- timings.delete(requestId);
305
- const info = {
306
- method: request.method,
307
- url: request.url,
308
- attempt,
309
- };
310
- // Check for cache hit via header set by cache middleware
311
- const fromCacheHeader = response.headers.get("X-From-Cache");
312
- const fromCache = fromCacheHeader === "1" ||
313
- response.status === 304;
314
- const result = {
384
+ async onResponse({ request, response, id }) {
385
+ // The authoritative end for whichever attempt is in flight: attempt 1 for a
386
+ // single-attempt request, or attempt 2 after a retry — the retry middleware
387
+ // deliberately does not finalize a successful attempt, so that the cache
388
+ // middleware (which runs between the two) can transform the response first.
389
+ // finalize stays idempotent as a backstop.
390
+ //
391
+ // fromCache means "served out of the ETag cache", and only the header the
392
+ // cache middleware sets proves that. A bare 304 does NOT: it reaches here
393
+ // when the cache is disabled, or is enabled but holds no entry for this key,
394
+ // and in both cases the caller's own conditional request went to the server.
395
+ const fromCache = response.headers.get("X-From-Cache") === "1";
396
+ lifecycle.finalize(id, request.method, request.url, {
315
397
  statusCode: response.status,
316
- durationMs,
317
398
  fromCache,
318
- };
319
- try {
320
- hooks.onRequestEnd?.(info, result);
321
- }
322
- catch {
323
- // Hooks should not interrupt the response
324
- }
399
+ });
400
+ lifecycle.release(id);
325
401
  return response;
326
402
  },
403
+ async onError({ request, error, id }) {
404
+ // openapi-fetch skips onResponse entirely when the initial fetch rejects,
405
+ // so this is the only place a network error or timeout can be observed.
406
+ lifecycle.finalize(id, request.method, request.url, {
407
+ statusCode: 0,
408
+ error: error instanceof Error ? error : new Error(String(error)),
409
+ });
410
+ lifecycle.release(id);
411
+ // Returning nothing preserves the original error's identity and rethrows it.
412
+ return undefined;
413
+ },
327
414
  };
328
415
  }
329
416
  const MAX_CACHE_ENTRIES = 1000;
@@ -520,7 +607,7 @@ export function normalizeUrlPath(url) {
520
607
  // override the placeholder from the default idMapping. This handles cases like
521
608
  // /buckets/{id}/webhooks → {bucketId} vs /buckets/{id}/timeline → {projectId}.
522
609
  const contextOverrides = {
523
- buckets: { webhooks: "{bucketId}" },
610
+ buckets: { webhooks: "{bucketId}", categories: "{bucketId}" },
524
611
  };
525
612
  // Build normalized path by replacing IDs and dates based on context
526
613
  const normalized = [];
@@ -592,112 +679,118 @@ function getRetryConfigForRequest(method, url) {
592
679
  }
593
680
  return DEFAULT_RETRY_CONFIG;
594
681
  }
595
- function createRetryMiddleware(hooks, authStrategy) {
596
- // Store request body clones keyed by a request identifier
597
- // This is needed because Request.body can only be read once
682
+ function createRetryMiddleware(lifecycle, authStrategy) {
683
+ // Serialized request bodies, keyed by openapi-fetch's per-request id, because
684
+ // Request.body can only be read once and the retry needs to replay it. Keyed on
685
+ // the id rather than method+url+timestamp: two concurrent mutations to the same
686
+ // URL used to collide on one slot and replay each other's bytes.
598
687
  const bodyCache = new Map();
599
688
  return {
600
- async onRequest({ request }) {
689
+ async onRequest({ request, id }) {
601
690
  // For methods that may have a body, clone it before the initial fetch
602
691
  // so we can use it for retries. Request.body can only be consumed once.
603
692
  const method = request.method.toUpperCase();
604
693
  if (method === "POST" || method === "PUT" || method === "PATCH") {
605
- const requestId = `${method}:${request.url}:${Date.now()}`;
606
- request.headers.set("X-Request-Id", requestId);
607
694
  if (request.body) {
608
695
  // Clone the body before it gets consumed
609
696
  const cloned = request.clone();
610
- bodyCache.set(requestId, await cloned.arrayBuffer());
697
+ bodyCache.set(id, await cloned.arrayBuffer());
611
698
  }
612
699
  else {
613
- bodyCache.set(requestId, null);
700
+ bodyCache.set(id, null);
614
701
  }
615
702
  }
616
703
  return request;
617
704
  },
618
- async onResponse({ request, response }) {
705
+ async onResponse({ request, response, id }) {
706
+ const { method, url } = request;
619
707
  // Get operation-specific retry config from metadata
620
- const retryConfig = getRetryConfigForRequest(request.method, request.url);
621
- const requestId = request.headers.get("X-Request-Id");
622
- // Helper to clean up cached body
623
- const cleanupBody = () => {
624
- if (requestId)
625
- bodyCache.delete(requestId);
626
- };
627
- // Check if status code should trigger retry
708
+ const retryConfig = getRetryConfigForRequest(method, url);
709
+ // Not retrying: let the lifecycle middleware end the attempt, and drop the body.
628
710
  if (!retryConfig.retryOn.includes(response.status)) {
629
- cleanupBody();
711
+ bodyCache.delete(id);
630
712
  return response;
631
713
  }
632
- // Extract current retry attempt from custom header
633
- const attemptHeader = request.headers.get("X-Retry-Attempt");
634
- const attempt = attemptHeader ? parseInt(attemptHeader, 10) : 0;
635
- // Check if we've exhausted retries (maxAttempts is total attempts, not retries)
636
- // With maxAttempts=3: attempt 0 (initial), 1 (retry 1), 2 (retry 2) = 3 total
637
- if (attempt >= retryConfig.maxAttempts - 1) {
638
- cleanupBody();
714
+ const failedAttempt = lifecycle.attemptOf(id);
715
+ // maxAttempts is a total attempt count, so attempt N is terminal when it
716
+ // equals the cap. TypeScript additionally chains at most one retry, since
717
+ // the retry below leaves the middleware chain (SPEC waiver 2B.1).
718
+ if (failedAttempt >= retryConfig.maxAttempts) {
719
+ bodyCache.delete(id);
639
720
  return response;
640
721
  }
641
- // Calculate delay
722
+ // For 429, respect Retry-After; otherwise back off.
642
723
  let delay;
643
- // For 429, respect Retry-After header
644
- if (response.status === 429) {
645
- const retryAfter = response.headers.get("Retry-After");
646
- if (retryAfter) {
647
- const seconds = parseInt(retryAfter, 10);
648
- if (!isNaN(seconds)) {
649
- delay = seconds * 1000;
650
- }
651
- else {
652
- delay = calculateBackoffDelay(retryConfig, attempt);
653
- }
654
- }
655
- else {
656
- delay = calculateBackoffDelay(retryConfig, attempt);
657
- }
724
+ const retryAfter = response.status === 429 ? response.headers.get("Retry-After") : null;
725
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : NaN;
726
+ if (!isNaN(retryAfterSeconds)) {
727
+ delay = retryAfterSeconds * 1000;
658
728
  }
659
729
  else {
660
- delay = calculateBackoffDelay(retryConfig, attempt);
730
+ delay = calculateBackoffDelay(retryConfig, failedAttempt - 1);
661
731
  }
662
- // Notify hooks of retry
663
- if (hooks?.onRetry) {
664
- const info = {
665
- method: request.method,
666
- url: request.url,
667
- attempt: attempt + 1,
668
- };
669
- const error = new Error(`HTTP ${response.status}: ${response.statusText || "Request failed"}`);
670
- try {
671
- hooks.onRetry(info, attempt + 1, error, delay);
672
- }
673
- catch {
674
- // Hooks should not interrupt the retry
732
+ const statusError = new Error(`HTTP ${response.status}: ${response.statusText || "Request failed"}`);
733
+ try {
734
+ // End the failed attempt before sleeping, so a slow backoff cannot leave
735
+ // an attempt open, then announce the upcoming one.
736
+ lifecycle.finalize(id, method, url, { statusCode: response.status });
737
+ lifecycle.retrying(id, method, url, failedAttempt, statusError, delay);
738
+ // This response is being discarded, so release its stream before we sleep
739
+ // rather than leaving it open across the backoff — otherwise a throttled
740
+ // client holds a connection per in-flight retry and cannot reuse any of
741
+ // them. The multipart transport in services/base.ts already does this.
742
+ // Errors are ignored: the body may already be consumed or closed.
743
+ void response.body?.cancel().catch(() => { });
744
+ await sleep(delay);
745
+ // The raw fetch below bypasses the middleware chain, so no downstream
746
+ // middleware sees this attempt begin — start it here.
747
+ //
748
+ // Started after the backoff but before any work that can throw. After, so
749
+ // the attempt's duration measures the request rather than the sleep;
750
+ // before, so that if the auth refresh or the fetch throws, the catch has a
751
+ // live attempt to finalize. Starting it later would let onRetry announce
752
+ // attempt 2 and then never account for it.
753
+ lifecycle.begin(id, method, url, failedAttempt + 1);
754
+ const body = bodyCache.get(id) ?? null;
755
+ const retryRequest = new Request(url, {
756
+ method,
757
+ headers: new Headers(request.headers),
758
+ body,
759
+ signal: request.signal,
760
+ });
761
+ // Refresh auth header for retry (token may have been refreshed since initial request)
762
+ if (authStrategy) {
763
+ await authStrategy.authenticate(retryRequest.headers);
675
764
  }
765
+ // Deliberately NOT finalized here. The returned response still flows
766
+ // through the cache middleware, which may rewrite a 304 into a cached 200;
767
+ // finalizing now would freeze the pre-transformation status and
768
+ // fromCache: false, and an idempotent finalize means the hooks pass could
769
+ // not correct it. The lifecycle middleware ends this attempt instead, reading
770
+ // attempt 2 from the state begun above.
771
+ return await fetch(retryRequest);
676
772
  }
677
- // Wait before retry
678
- await sleep(delay);
679
- // Get cached body for methods that may have one
680
- let body = null;
681
- if (requestId && bodyCache.has(requestId)) {
682
- const cachedBody = bodyCache.get(requestId);
683
- if (cachedBody) {
684
- body = cachedBody;
685
- }
773
+ catch (error) {
774
+ // openapi-fetch routes only the INITIAL fetch through onError, so anything
775
+ // thrown in here — the retry fetch, the auth refresh, the sleep — would
776
+ // otherwise escape with the attempt still open and its state stranded.
777
+ lifecycle.finalize(id, method, url, {
778
+ statusCode: 0,
779
+ error: error instanceof Error ? error : new Error(String(error)),
780
+ });
781
+ lifecycle.release(id);
782
+ // Rethrow the original value so its identity survives.
783
+ throw error;
686
784
  }
687
- // Create retry request with fresh body
688
- const retryRequest = new Request(request.url, {
689
- method: request.method,
690
- headers: new Headers(request.headers),
691
- body,
692
- signal: request.signal,
693
- });
694
- retryRequest.headers.set("X-Retry-Attempt", String(attempt + 1));
695
- // Refresh auth header for retry (token may have been refreshed since initial request)
696
- if (authStrategy) {
697
- await authStrategy.authenticate(retryRequest.headers);
785
+ finally {
786
+ bodyCache.delete(id);
698
787
  }
699
- // Retry using native fetch
700
- return fetch(retryRequest);
788
+ },
789
+ async onError({ id }) {
790
+ // The initial fetch rejected, so onResponse never ran and the cached body
791
+ // would leak. The lifecycle middleware owns ending the attempt.
792
+ bodyCache.delete(id);
793
+ return undefined;
701
794
  },
702
795
  };
703
796
  }
@@ -735,7 +828,7 @@ function sleep(ms) {
735
828
  *
736
829
  * const allProjects = await fetchAllPages(
737
830
  * response.response,
738
- * (r) => r.json()
831
+ * (r) => r.json() as Promise<any[]>
739
832
  * );
740
833
  * ```
741
834
  */
@@ -768,7 +861,7 @@ export async function fetchAllPages(initialResponse, parse, authHeader) {
768
861
  *
769
862
  * @example
770
863
  * ```ts
771
- * for await (const page of paginateAll(response.response, (r) => r.json())) {
864
+ * for await (const page of paginateAll(response.response, (r) => r.json() as Promise<any[]>)) {
772
865
  * console.log(`Processing ${page.length} items`);
773
866
  * }
774
867
  * ```