@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.
- package/README.md +147 -15
- package/dist/client.d.ts +8 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +237 -144
- package/dist/client.js.map +1 -1
- package/dist/generated/metadata.d.ts.map +1 -1
- package/dist/generated/metadata.js +280 -12
- package/dist/generated/metadata.js.map +1 -1
- package/dist/generated/metadata.ts +280 -12
- package/dist/generated/openapi-stripped.json +4403 -2124
- package/dist/generated/path-mapping.d.ts.map +1 -1
- package/dist/generated/path-mapping.js +22 -5
- package/dist/generated/path-mapping.js.map +1 -1
- package/dist/generated/path-mapping.ts +22 -5
- package/dist/generated/schema.d.ts +2983 -1220
- package/dist/generated/services/boosts.d.ts +13 -10
- package/dist/generated/services/boosts.d.ts.map +1 -1
- package/dist/generated/services/boosts.js +5 -5
- package/dist/generated/services/boosts.js.map +1 -1
- package/dist/generated/services/boosts.ts +12 -10
- package/dist/generated/services/cards.d.ts +4 -4
- package/dist/generated/services/cards.d.ts.map +1 -1
- package/dist/generated/services/cards.js +2 -2
- package/dist/generated/services/cards.js.map +1 -1
- package/dist/generated/services/cards.ts +4 -4
- package/dist/generated/services/everything.d.ts +303 -0
- package/dist/generated/services/everything.d.ts.map +1 -0
- package/dist/generated/services/everything.js +359 -0
- package/dist/generated/services/everything.js.map +1 -0
- package/dist/generated/services/everything.ts +594 -0
- package/dist/generated/services/index.d.ts +2 -1
- package/dist/generated/services/index.d.ts.map +1 -1
- package/dist/generated/services/index.js +2 -1
- package/dist/generated/services/index.js.map +1 -1
- package/dist/generated/services/index.ts +2 -1
- package/dist/generated/services/message-types.d.ts +15 -10
- package/dist/generated/services/message-types.d.ts.map +1 -1
- package/dist/generated/services/message-types.js +35 -18
- package/dist/generated/services/message-types.js.map +1 -1
- package/dist/generated/services/message-types.ts +34 -18
- package/dist/generated/services/my-notifications.d.ts +27 -0
- package/dist/generated/services/my-notifications.d.ts.map +1 -1
- package/dist/generated/services/my-notifications.js +23 -1
- package/dist/generated/services/my-notifications.js.map +1 -1
- package/dist/generated/services/my-notifications.ts +46 -1
- package/dist/generated/services/recordings.d.ts +2 -2
- package/dist/generated/services/recordings.d.ts.map +1 -1
- package/dist/generated/services/recordings.js +1 -1
- package/dist/generated/services/recordings.js.map +1 -1
- package/dist/generated/services/recordings.ts +2 -2
- package/dist/generated/services/schedules.d.ts +9 -1
- package/dist/generated/services/schedules.d.ts.map +1 -1
- package/dist/generated/services/schedules.js.map +1 -1
- package/dist/generated/services/schedules.ts +9 -1
- package/dist/generated/services/tools.d.ts +2 -0
- package/dist/generated/services/tools.d.ts.map +1 -1
- package/dist/generated/services/tools.js +1 -0
- package/dist/generated/services/tools.js.map +1 -1
- package/dist/generated/services/tools.ts +3 -0
- 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 +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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/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 +5 -1
- package/dist/pagination.d.ts.map +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/dist/services/cards-extensions.d.ts +76 -0
- package/dist/services/cards-extensions.d.ts.map +1 -0
- package/dist/services/cards-extensions.js +67 -0
- package/dist/services/cards-extensions.js.map +1 -0
- package/package.json +2 -2
- package/src/generated/metadata.ts +280 -12
- package/src/generated/openapi-stripped.json +4403 -2124
- package/src/generated/path-mapping.ts +22 -5
- package/src/generated/schema.d.ts +2983 -1220
- package/src/generated/services/boosts.ts +12 -10
- package/src/generated/services/cards.ts +4 -4
- package/src/generated/services/everything.ts +594 -0
- package/src/generated/services/index.ts +2 -1
- package/src/generated/services/message-types.ts +34 -18
- package/src/generated/services/my-notifications.ts +46 -1
- package/src/generated/services/recordings.ts +2 -2
- package/src/generated/services/schedules.ts +9 -1
- 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 "./
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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(
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
297
|
+
signal,
|
|
268
298
|
duplex: request.body ? "half" : undefined,
|
|
269
299
|
});
|
|
270
300
|
},
|
|
271
301
|
};
|
|
272
302
|
}
|
|
273
|
-
/**
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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(
|
|
596
|
-
//
|
|
597
|
-
//
|
|
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(
|
|
697
|
+
bodyCache.set(id, await cloned.arrayBuffer());
|
|
611
698
|
}
|
|
612
699
|
else {
|
|
613
|
-
bodyCache.set(
|
|
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(
|
|
621
|
-
|
|
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
|
-
|
|
711
|
+
bodyCache.delete(id);
|
|
630
712
|
return response;
|
|
631
713
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
//
|
|
722
|
+
// For 429, respect Retry-After; otherwise back off.
|
|
642
723
|
let delay;
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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,
|
|
730
|
+
delay = calculateBackoffDelay(retryConfig, failedAttempt - 1);
|
|
661
731
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
catch {
|
|
674
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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
|
* ```
|