adapty 0.8.4 → 0.8.6
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 +7 -2
- package/bin/dev.js +2 -2
- package/bin/run.js +2 -2
- package/dist/cli/base/adapty/adapty-command.d.ts +21 -0
- package/dist/cli/base/adapty/adapty-command.js +47 -0
- package/dist/cli/base/adapty/build.d.ts +11 -0
- package/dist/cli/base/adapty/build.js +13 -0
- package/dist/cli/base/adapty/index.d.ts +5 -0
- package/dist/cli/base/adapty/index.js +3 -0
- package/dist/cli/base/adapty/openSession.d.ts +22 -0
- package/dist/cli/base/adapty/openSession.js +25 -0
- package/dist/cli/base/base-command.d.ts +30 -0
- package/dist/cli/base/base-command.js +64 -0
- package/dist/cli/commands/apps/create.d.ts +19 -0
- package/dist/cli/commands/apps/create.js +68 -0
- package/dist/cli/commands/apps/get.d.ts +10 -0
- package/dist/cli/commands/apps/get.js +14 -0
- package/dist/cli/commands/apps/list.d.ts +11 -0
- package/dist/cli/commands/apps/list.js +14 -0
- package/dist/cli/commands/apps/update.d.ts +15 -0
- package/dist/cli/commands/apps/update.js +30 -0
- package/dist/cli/commands/auth/login.d.ts +11 -0
- package/dist/cli/commands/auth/login.js +68 -0
- package/dist/cli/commands/auth/logout.d.ts +12 -0
- package/dist/cli/commands/auth/logout.js +28 -0
- package/dist/cli/commands/auth/revoke.d.ts +13 -0
- package/dist/cli/commands/auth/revoke.js +32 -0
- package/dist/cli/commands/auth/status/index.d.ts +7 -0
- package/dist/cli/commands/auth/status/index.js +22 -0
- package/dist/cli/commands/auth/status/lib/render.d.ts +3 -0
- package/dist/cli/commands/auth/status/lib/render.js +14 -0
- package/dist/cli/commands/auth/status/lib/result.d.ts +15 -0
- package/dist/cli/commands/auth/status/lib/result.js +1 -0
- package/dist/cli/commands/auth/whoami.d.ts +10 -0
- package/dist/cli/commands/auth/whoami.js +16 -0
- package/dist/cli/errors.d.ts +39 -0
- package/dist/cli/errors.js +92 -0
- package/dist/cli/flags.d.ts +16 -0
- package/dist/cli/flags.js +27 -0
- package/dist/cli/views/list.d.ts +6 -0
- package/dist/cli/views/list.js +10 -0
- package/dist/cli/views/record.d.ts +9 -0
- package/dist/cli/views/record.js +14 -0
- package/dist/commands/access-levels/create.js +1 -1
- package/dist/commands/access-levels/list.d.ts +1 -1
- package/dist/commands/apps/create.d.ts +1 -14
- package/dist/commands/apps/create.js +2 -54
- package/dist/commands/apps/get.d.ts +1 -11
- package/dist/commands/apps/get.js +2 -22
- package/dist/commands/apps/list.d.ts +1 -13
- package/dist/commands/apps/list.js +2 -19
- package/dist/commands/apps/update.d.ts +1 -16
- package/dist/commands/apps/update.js +2 -38
- package/dist/commands/asa/ad-groups/create.js +9 -7
- package/dist/commands/asa/ad-groups/get.js +3 -2
- package/dist/commands/asa/ad-groups/list.d.ts +1 -1
- package/dist/commands/asa/ad-groups/list.js +1 -1
- package/dist/commands/asa/ad-groups/update.js +22 -12
- package/dist/commands/asa/ads/create.js +7 -5
- package/dist/commands/asa/ads/get.js +3 -2
- package/dist/commands/asa/ads/list.d.ts +1 -1
- package/dist/commands/asa/ads/list.js +1 -1
- package/dist/commands/asa/ads/update.js +9 -5
- package/dist/commands/asa/apps/list.d.ts +1 -1
- package/dist/commands/asa/apps/list.js +1 -1
- package/dist/commands/asa/automations/create.js +9 -6
- package/dist/commands/asa/automations/get.js +3 -2
- package/dist/commands/asa/automations/list.d.ts +1 -1
- package/dist/commands/asa/automations/list.js +1 -1
- package/dist/commands/asa/automations/run.js +5 -3
- package/dist/commands/asa/automations/runs.d.ts +1 -1
- package/dist/commands/asa/automations/runs.js +4 -4
- package/dist/commands/asa/automations/update.js +17 -9
- package/dist/commands/asa/campaigns/bulk-create.js +31 -18
- package/dist/commands/asa/campaigns/bulk-list.js +7 -5
- package/dist/commands/asa/campaigns/bulk-status.js +3 -2
- package/dist/commands/asa/campaigns/create.js +10 -8
- package/dist/commands/asa/campaigns/get.js +3 -2
- package/dist/commands/asa/campaigns/list.d.ts +1 -1
- package/dist/commands/asa/campaigns/list.js +1 -1
- package/dist/commands/asa/campaigns/update.js +25 -15
- package/dist/commands/asa/competitors/summary.js +5 -5
- package/dist/commands/asa/connect.js +5 -3
- package/dist/commands/asa/creatives/list.d.ts +1 -1
- package/dist/commands/asa/creatives/list.js +1 -1
- package/dist/commands/asa/keywords/add.js +13 -10
- package/dist/commands/asa/keywords/list.d.ts +1 -1
- package/dist/commands/asa/keywords/list.js +1 -1
- package/dist/commands/asa/keywords/update.js +15 -10
- package/dist/commands/asa/metrics/index.d.ts +2 -2
- package/dist/commands/asa/metrics/index.js +22 -8
- package/dist/commands/asa/metrics/overview.js +3 -3
- package/dist/commands/asa/negative-keywords/add.js +10 -7
- package/dist/commands/asa/negative-keywords/list.d.ts +1 -1
- package/dist/commands/asa/negative-keywords/list.js +1 -1
- package/dist/commands/asa/orgs/list.d.ts +1 -1
- package/dist/commands/asa/orgs/list.js +1 -1
- package/dist/commands/asa/product-pages/list.d.ts +1 -1
- package/dist/commands/asa/product-pages/list.js +1 -1
- package/dist/commands/asa/product-pages/sync.js +1 -1
- package/dist/commands/asa/search-terms/list.d.ts +1 -1
- package/dist/commands/asa/search-terms/list.js +1 -1
- package/dist/commands/asa/whoami.js +13 -4
- package/dist/commands/auth/login.d.ts +1 -8
- package/dist/commands/auth/login.js +2 -120
- package/dist/commands/auth/logout.d.ts +1 -9
- package/dist/commands/auth/logout.js +2 -18
- package/dist/commands/auth/revoke.d.ts +1 -9
- package/dist/commands/auth/revoke.js +2 -21
- package/dist/commands/auth/status.d.ts +1 -7
- package/dist/commands/auth/status.js +2 -26
- package/dist/commands/auth/whoami.d.ts +1 -7
- package/dist/commands/auth/whoami.js +2 -15
- package/dist/commands/flows/config/get.js +2 -2
- package/dist/commands/flows/config/preview.d.ts +2 -2
- package/dist/commands/flows/config/preview.js +6 -4
- package/dist/commands/flows/config/update.js +5 -3
- package/dist/commands/flows/config/validate.js +5 -3
- package/dist/commands/flows/list.d.ts +1 -1
- package/dist/commands/flows/media/upload.js +1 -1
- package/dist/commands/flows/publish.js +5 -5
- package/dist/commands/paywalls/create.js +1 -1
- package/dist/commands/paywalls/list.d.ts +1 -1
- package/dist/commands/paywalls/placements.d.ts +2 -2
- package/dist/commands/paywalls/update.js +1 -1
- package/dist/commands/placements/create.js +17 -12
- package/dist/commands/placements/list.d.ts +1 -1
- package/dist/commands/placements/update.js +20 -15
- package/dist/commands/products/create.js +20 -13
- package/dist/commands/products/list.d.ts +1 -1
- package/dist/commands/products/update.js +1 -1
- package/dist/commands/segments/list.d.ts +1 -1
- package/dist/lib/api-client.d.ts +10 -6
- package/dist/lib/api-client.js +56 -35
- package/dist/lib/api-schemas.d.ts +63 -59
- package/dist/lib/app-url.js +2 -1
- package/dist/lib/asa-client.d.ts +13 -8
- package/dist/lib/asa-client.js +9 -4
- package/dist/lib/asa-flags.d.ts +19 -19
- package/dist/lib/asa-flags.js +38 -25
- package/dist/lib/asa-keyword-action.d.ts +10 -10
- package/dist/lib/asa-keyword-action.js +40 -25
- package/dist/lib/asa-schemas.d.ts +101 -81
- package/dist/lib/auth.js +2 -1
- package/dist/lib/client-from-config.d.ts +1 -1
- package/dist/lib/client-from-config.js +2 -1
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/confirm.d.ts +4 -4
- package/dist/lib/confirm.js +12 -7
- package/dist/lib/errors.d.ts +18 -18
- package/dist/lib/errors.js +12 -9
- package/dist/lib/flags.d.ts +3 -3
- package/dist/lib/flags.js +2 -1
- package/dist/lib/flow-help.js +11 -6
- package/dist/lib/output.js +23 -12
- package/dist/lib/preview.d.ts +5 -5
- package/dist/lib/preview.js +2 -1
- package/dist/sdk/adapty/access-levels.d.ts +15 -0
- package/dist/sdk/adapty/access-levels.js +4 -0
- package/dist/sdk/adapty/apps/create.d.ts +26 -0
- package/dist/sdk/adapty/apps/create.js +36 -0
- package/dist/sdk/adapty/apps/index.d.ts +13 -0
- package/dist/sdk/adapty/apps/index.js +9 -0
- package/dist/sdk/adapty/apps/model.d.ts +24 -0
- package/dist/sdk/adapty/apps/model.js +10 -0
- package/dist/sdk/adapty/apps/resource.d.ts +19 -0
- package/dist/sdk/adapty/apps/resource.js +23 -0
- package/dist/sdk/adapty/apps/update.d.ts +16 -0
- package/dist/sdk/adapty/apps/update.js +25 -0
- package/dist/sdk/adapty/auth/device-code.d.ts +8 -0
- package/dist/sdk/adapty/auth/device-code.js +19 -0
- package/dist/sdk/adapty/auth/index.d.ts +7 -0
- package/dist/sdk/adapty/auth/index.js +5 -0
- package/dist/sdk/adapty/auth/model.d.ts +15 -0
- package/dist/sdk/adapty/auth/model.js +6 -0
- package/dist/sdk/adapty/auth/poll-token.d.ts +8 -0
- package/dist/sdk/adapty/auth/poll-token.js +58 -0
- package/dist/sdk/adapty/auth/resource.d.ts +17 -0
- package/dist/sdk/adapty/auth/resource.js +21 -0
- package/dist/sdk/adapty/errors.d.ts +10 -0
- package/dist/sdk/adapty/errors.js +38 -0
- package/dist/sdk/adapty/index.d.ts +36 -0
- package/dist/sdk/adapty/index.js +27 -0
- package/dist/sdk/adapty/pagination.d.ts +23 -0
- package/dist/sdk/adapty/pagination.js +1 -0
- package/dist/sdk/core/auth/device-flow.d.ts +46 -0
- package/dist/sdk/core/auth/device-flow.js +62 -0
- package/dist/sdk/core/clock.d.ts +7 -0
- package/dist/sdk/core/clock.js +21 -0
- package/dist/sdk/core/errors.d.ts +81 -0
- package/dist/sdk/core/errors.js +84 -0
- package/dist/sdk/core/http/client.d.ts +46 -0
- package/dist/sdk/core/http/client.js +127 -0
- package/dist/sdk/core/http/index.d.ts +11 -0
- package/dist/sdk/core/http/index.js +7 -0
- package/dist/sdk/core/http/policies.d.ts +14 -0
- package/dist/sdk/core/http/policies.js +29 -0
- package/dist/sdk/core/http/retry.d.ts +27 -0
- package/dist/sdk/core/http/retry.js +22 -0
- package/dist/sdk/core/http/url.d.ts +3 -0
- package/dist/sdk/core/http/url.js +19 -0
- package/dist/sdk/core/session.d.ts +20 -0
- package/dist/sdk/core/session.js +69 -0
- package/dist/sdk/core/testing.d.ts +25 -0
- package/dist/sdk/core/testing.js +46 -0
- package/dist/sdk/core/validation.d.ts +10 -0
- package/dist/sdk/core/validation.js +14 -0
- package/oclif.manifest.json +766 -766
- package/package.json +19 -9
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHttp } from '../core/http/index.js';
|
|
2
|
+
import { accessLevels } from './access-levels.js';
|
|
3
|
+
import { apps } from './apps/index.js';
|
|
4
|
+
import { auth } from './auth/index.js';
|
|
5
|
+
import { developerErrorParser } from './errors.js';
|
|
6
|
+
export { platforms } from './apps/index.js';
|
|
7
|
+
export { developerErrorParser } from './errors.js';
|
|
8
|
+
/** Exported because the adapter compares the resolved URL with it and warns when they differ. */
|
|
9
|
+
export const DEFAULT_ADAPTY_API_URL = 'https://api-admin.adapty.io/api/v1/developer';
|
|
10
|
+
/** The assembly point of the developer API: one transport, resources on top of it. */
|
|
11
|
+
export const createAdapty = (options = {}) => {
|
|
12
|
+
const http = createHttp({
|
|
13
|
+
baseUrl: options.baseUrl ?? DEFAULT_ADAPTY_API_URL,
|
|
14
|
+
clock: options.clock,
|
|
15
|
+
fetch: options.fetch,
|
|
16
|
+
headers: options.userAgent === undefined ? undefined : { 'user-agent': options.userAgent },
|
|
17
|
+
onRetry: options.onRetry,
|
|
18
|
+
parseError: developerErrorParser,
|
|
19
|
+
signal: options.signal,
|
|
20
|
+
token: options.token,
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
accessLevels: accessLevels(http),
|
|
24
|
+
apps: apps(http),
|
|
25
|
+
auth: auth(http),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { QueryParams } from '../core/http/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The paginated answer, and the query that asks for a page. Product and not core: `meta.pagination`
|
|
4
|
+
* and the bracketed `page[...]` names are this API's convention, not the transport's business. ASA
|
|
5
|
+
* follows the same convention today, so sdk/asa can import this until the two actually diverge.
|
|
6
|
+
*/
|
|
7
|
+
export type Pagination = {
|
|
8
|
+
count: number;
|
|
9
|
+
page: number;
|
|
10
|
+
pages: number;
|
|
11
|
+
};
|
|
12
|
+
export type Paginated<T> = {
|
|
13
|
+
data: T[];
|
|
14
|
+
meta: {
|
|
15
|
+
pagination: Pagination;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/** Page selection as we take it: camelCase in, the server's names on the wire. */
|
|
19
|
+
export type PageParams = {
|
|
20
|
+
page?: number | undefined;
|
|
21
|
+
pageSize?: number | undefined;
|
|
22
|
+
};
|
|
23
|
+
export declare const pageQuery: (params: PageParams | undefined) => QueryParams;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const pageQuery = (params) => ({ 'page[number]': params?.page, 'page[size]': params?.pageSize });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Clock } from '../clock.js';
|
|
2
|
+
export type DeviceCode = {
|
|
3
|
+
deviceCode: string;
|
|
4
|
+
expiresInSec: number;
|
|
5
|
+
intervalSec: number;
|
|
6
|
+
userCode: string;
|
|
7
|
+
verificationUri: string;
|
|
8
|
+
verificationUriComplete: string | undefined;
|
|
9
|
+
};
|
|
10
|
+
export type PollResult<TToken> = {
|
|
11
|
+
status: 'authorized';
|
|
12
|
+
token: TToken;
|
|
13
|
+
} | {
|
|
14
|
+
status: 'denied';
|
|
15
|
+
} | {
|
|
16
|
+
status: 'expired';
|
|
17
|
+
} | {
|
|
18
|
+
status: 'pending';
|
|
19
|
+
} | {
|
|
20
|
+
status: 'slow_down';
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The device flow port (RFC 8628) — all the orchestration below knows. What sits inside a token
|
|
24
|
+
* (for Adapty, an access_token plus the user) is the port's business.
|
|
25
|
+
*/
|
|
26
|
+
export type DeviceAuthApi<TToken> = {
|
|
27
|
+
pollToken(deviceCode: string): Promise<PollResult<TToken>>;
|
|
28
|
+
requestDeviceCode(): Promise<DeviceCode>;
|
|
29
|
+
};
|
|
30
|
+
export type DeviceFlowDeps = {
|
|
31
|
+
clock: Clock;
|
|
32
|
+
maxConsecutiveErrors?: number | undefined;
|
|
33
|
+
/** Do not poll faster than this, even if the server allows it. */
|
|
34
|
+
minIntervalMs?: number | undefined;
|
|
35
|
+
/** Show the code and the link to the user. Sdk prints nothing itself. */
|
|
36
|
+
onCode: (code: DeviceCode) => void;
|
|
37
|
+
/** One poll failed (network, odd answer). The flow goes on up to maxConsecutiveErrors. */
|
|
38
|
+
onTransientError?: ((error: unknown, consecutive: number) => void) | undefined;
|
|
39
|
+
signal?: AbortSignal | undefined;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Device flow orchestration with every effect kept outside: the network behind the api port, time
|
|
43
|
+
* behind clock, cancellation behind signal. That is what makes expiry, slow_down, poll failures
|
|
44
|
+
* and Ctrl+C unit-testable.
|
|
45
|
+
*/
|
|
46
|
+
export declare const runDeviceFlow: <TToken>(api: DeviceAuthApi<TToken>, deps: DeviceFlowDeps) => Promise<TToken>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { throwIfAborted } from '../clock.js';
|
|
2
|
+
import { CancelledError, DeviceFlowDeniedError, DeviceFlowExpiredError } from '../errors.js';
|
|
3
|
+
/** RFC 8628, 3.5: on slow_down the interval grows by 5 seconds. */
|
|
4
|
+
const SLOW_DOWN_STEP_MS = 5000;
|
|
5
|
+
const DEFAULT_MIN_INTERVAL_MS = 5000;
|
|
6
|
+
const DEFAULT_MAX_CONSECUTIVE_ERRORS = 10;
|
|
7
|
+
/**
|
|
8
|
+
* Device flow orchestration with every effect kept outside: the network behind the api port, time
|
|
9
|
+
* behind clock, cancellation behind signal. That is what makes expiry, slow_down, poll failures
|
|
10
|
+
* and Ctrl+C unit-testable.
|
|
11
|
+
*/
|
|
12
|
+
export const runDeviceFlow = async (api, deps) => {
|
|
13
|
+
const { clock, signal } = deps;
|
|
14
|
+
const maxConsecutiveErrors = deps.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS;
|
|
15
|
+
throwIfAborted(signal);
|
|
16
|
+
const code = await api.requestDeviceCode();
|
|
17
|
+
deps.onCode(code);
|
|
18
|
+
const deadline = clock.now() + code.expiresInSec * 1000;
|
|
19
|
+
let intervalMs = Math.max(code.intervalSec * 1000, deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS);
|
|
20
|
+
let consecutiveErrors = 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (clock.now() >= deadline) {
|
|
23
|
+
throw new DeviceFlowExpiredError();
|
|
24
|
+
}
|
|
25
|
+
await clock.sleep(intervalMs, signal);
|
|
26
|
+
throwIfAborted(signal);
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = await api.pollToken(code.deviceCode);
|
|
30
|
+
consecutiveErrors = 0;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof CancelledError) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
consecutiveErrors += 1;
|
|
37
|
+
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
deps.onTransientError?.(error, consecutiveErrors);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
switch (result.status) {
|
|
44
|
+
case 'authorized': {
|
|
45
|
+
return result.token;
|
|
46
|
+
}
|
|
47
|
+
case 'pending': {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'slow_down': {
|
|
51
|
+
intervalMs += SLOW_DOWN_STEP_MS;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'expired': {
|
|
55
|
+
throw new DeviceFlowExpiredError();
|
|
56
|
+
}
|
|
57
|
+
case 'denied': {
|
|
58
|
+
throw new DeviceFlowDeniedError();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Time as a dependency: device flow and retry take a Clock, so tests never wait for real. */
|
|
2
|
+
export type Clock = {
|
|
3
|
+
now(): number;
|
|
4
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
export declare const systemClock: Clock;
|
|
7
|
+
export declare const throwIfAborted: (signal: AbortSignal | undefined) => void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { CancelledError, isAbortError } from './errors.js';
|
|
3
|
+
export const systemClock = {
|
|
4
|
+
now: () => Date.now(),
|
|
5
|
+
sleep: async (ms, signal) => {
|
|
6
|
+
try {
|
|
7
|
+
await delay(ms, undefined, signal ? { signal } : {});
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (isAbortError(error)) {
|
|
11
|
+
throw new CancelledError();
|
|
12
|
+
}
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
export const throwIfAborted = (signal) => {
|
|
18
|
+
if (signal?.aborted === true) {
|
|
19
|
+
throw new CancelledError();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sdk errors carry no user-facing text and no exit code: the adapter assigns both
|
|
3
|
+
* (src/cli/errors.ts, and a future MCP server).
|
|
4
|
+
*
|
|
5
|
+
* Every error has a stable `kind`, and the adapter switches over it. An instanceof chain would
|
|
6
|
+
* let a new error class fall through to the fallback and become exit 1 with no readable text.
|
|
7
|
+
*/
|
|
8
|
+
export type SdkErrorKind = 'api' | 'auth_required' | 'cancelled' | 'device_flow_denied' | 'device_flow_expired' | 'network' | 'storage' | 'validation';
|
|
9
|
+
/** Abstract on purpose: `new SdkError(...)` would be an error without a kind, unrecognisable. */
|
|
10
|
+
export declare abstract class SdkError extends Error {
|
|
11
|
+
abstract readonly kind: SdkErrorKind;
|
|
12
|
+
constructor(message: string, options?: ErrorOptions);
|
|
13
|
+
}
|
|
14
|
+
/** No response at all: DNS, refused connection, a dropped socket. */
|
|
15
|
+
export declare class NetworkError extends SdkError {
|
|
16
|
+
readonly kind = "network";
|
|
17
|
+
readonly url: string;
|
|
18
|
+
constructor(url: string, cause: unknown);
|
|
19
|
+
}
|
|
20
|
+
/** The server answered with an error. `code` and `details` follow the service's own format. */
|
|
21
|
+
export declare class ApiError extends SdkError {
|
|
22
|
+
readonly kind = "api";
|
|
23
|
+
readonly code: string | undefined;
|
|
24
|
+
readonly details: unknown;
|
|
25
|
+
readonly retryAfterMs: number | undefined;
|
|
26
|
+
readonly status: number;
|
|
27
|
+
constructor(init: {
|
|
28
|
+
code?: string | undefined;
|
|
29
|
+
details?: unknown;
|
|
30
|
+
message: string;
|
|
31
|
+
retryAfterMs?: number | undefined;
|
|
32
|
+
status: number;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** missing: no token stored locally. rejected: the server answered 401. */
|
|
36
|
+
export type AuthRequiredReason = 'missing' | 'rejected';
|
|
37
|
+
export declare class AuthRequiredError extends SdkError {
|
|
38
|
+
readonly kind = "auth_required";
|
|
39
|
+
readonly reason: AuthRequiredReason;
|
|
40
|
+
constructor(reason: AuthRequiredReason);
|
|
41
|
+
}
|
|
42
|
+
export declare class CancelledError extends SdkError {
|
|
43
|
+
readonly kind = "cancelled";
|
|
44
|
+
constructor();
|
|
45
|
+
}
|
|
46
|
+
export type Issue = {
|
|
47
|
+
message: string;
|
|
48
|
+
/** The input field the problem belongs to. No path: the input as a whole is wrong. */
|
|
49
|
+
path?: string | undefined;
|
|
50
|
+
};
|
|
51
|
+
/** A business rule broken before any network call. */
|
|
52
|
+
export declare class ValidationError extends SdkError {
|
|
53
|
+
readonly kind = "validation";
|
|
54
|
+
readonly issues: readonly Issue[];
|
|
55
|
+
constructor(issues: readonly Issue[]);
|
|
56
|
+
}
|
|
57
|
+
export declare class DeviceFlowExpiredError extends SdkError {
|
|
58
|
+
readonly kind = "device_flow_expired";
|
|
59
|
+
constructor();
|
|
60
|
+
}
|
|
61
|
+
export declare class DeviceFlowDeniedError extends SdkError {
|
|
62
|
+
readonly kind = "device_flow_denied";
|
|
63
|
+
constructor();
|
|
64
|
+
}
|
|
65
|
+
/** The session file is there but unusable: corrupted JSON, or something else in its place. */
|
|
66
|
+
export declare class StorageError extends SdkError {
|
|
67
|
+
readonly kind = "storage";
|
|
68
|
+
readonly path: string;
|
|
69
|
+
constructor(message: string, path: string, options?: ErrorOptions);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Every concrete error: a switch over `kind` narrows to the class. instanceof on the abstract
|
|
73
|
+
* base gives the base, without .status, .url or .issues.
|
|
74
|
+
*/
|
|
75
|
+
export type AnySdkError = ApiError | AuthRequiredError | CancelledError | DeviceFlowDeniedError | DeviceFlowExpiredError | NetworkError | StorageError | ValidationError;
|
|
76
|
+
export declare const isSdkError: (error: unknown) => error is AnySdkError;
|
|
77
|
+
type Assert<T extends true> = T;
|
|
78
|
+
/** Safety net: a kind whose class never made it into AnySdkError breaks the build here. */
|
|
79
|
+
export type AllKindsCovered = Assert<SdkErrorKind extends AnySdkError['kind'] ? true : false>;
|
|
80
|
+
export declare const isAbortError: (error: unknown) => boolean;
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sdk errors carry no user-facing text and no exit code: the adapter assigns both
|
|
3
|
+
* (src/cli/errors.ts, and a future MCP server).
|
|
4
|
+
*
|
|
5
|
+
* Every error has a stable `kind`, and the adapter switches over it. An instanceof chain would
|
|
6
|
+
* let a new error class fall through to the fallback and become exit 1 with no readable text.
|
|
7
|
+
*/
|
|
8
|
+
/** Abstract on purpose: `new SdkError(...)` would be an error without a kind, unrecognisable. */
|
|
9
|
+
export class SdkError extends Error {
|
|
10
|
+
constructor(message, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.name = new.target.name;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** No response at all: DNS, refused connection, a dropped socket. */
|
|
16
|
+
export class NetworkError extends SdkError {
|
|
17
|
+
kind = 'network';
|
|
18
|
+
url;
|
|
19
|
+
constructor(url, cause) {
|
|
20
|
+
super(`Request to ${url} failed`, { cause });
|
|
21
|
+
this.url = url;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The server answered with an error. `code` and `details` follow the service's own format. */
|
|
25
|
+
export class ApiError extends SdkError {
|
|
26
|
+
kind = 'api';
|
|
27
|
+
code;
|
|
28
|
+
details;
|
|
29
|
+
retryAfterMs;
|
|
30
|
+
status;
|
|
31
|
+
constructor(init) {
|
|
32
|
+
super(init.message);
|
|
33
|
+
this.status = init.status;
|
|
34
|
+
this.code = init.code;
|
|
35
|
+
this.details = init.details;
|
|
36
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class AuthRequiredError extends SdkError {
|
|
40
|
+
kind = 'auth_required';
|
|
41
|
+
reason;
|
|
42
|
+
constructor(reason) {
|
|
43
|
+
super(reason === 'missing' ? 'Not authenticated' : 'Token expired or invalid');
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class CancelledError extends SdkError {
|
|
48
|
+
kind = 'cancelled';
|
|
49
|
+
constructor() {
|
|
50
|
+
super('Operation cancelled');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** A business rule broken before any network call. */
|
|
54
|
+
export class ValidationError extends SdkError {
|
|
55
|
+
kind = 'validation';
|
|
56
|
+
issues;
|
|
57
|
+
constructor(issues) {
|
|
58
|
+
super(issues.map(issue => (issue.path === undefined ? issue.message : `${issue.path}: ${issue.message}`)).join('; '));
|
|
59
|
+
this.issues = issues;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class DeviceFlowExpiredError extends SdkError {
|
|
63
|
+
kind = 'device_flow_expired';
|
|
64
|
+
constructor() {
|
|
65
|
+
super('Device code expired before authorization');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class DeviceFlowDeniedError extends SdkError {
|
|
69
|
+
kind = 'device_flow_denied';
|
|
70
|
+
constructor() {
|
|
71
|
+
super('Authorization denied');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The session file is there but unusable: corrupted JSON, or something else in its place. */
|
|
75
|
+
export class StorageError extends SdkError {
|
|
76
|
+
kind = 'storage';
|
|
77
|
+
path;
|
|
78
|
+
constructor(message, path, options) {
|
|
79
|
+
super(message, options);
|
|
80
|
+
this.path = path;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export const isSdkError = (error) => error instanceof SdkError;
|
|
84
|
+
export const isAbortError = (error) => error instanceof Error && error.name === 'AbortError';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Clock } from '../clock.js';
|
|
2
|
+
import type { ErrorParser, ShouldRetry } from './policies.js';
|
|
3
|
+
import type { RetryAttempt, RetryPolicy } from './retry.js';
|
|
4
|
+
import type { QueryParams } from './url.js';
|
|
5
|
+
export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
|
|
6
|
+
export type RequestOptions = {
|
|
7
|
+
/** A JSON-serializable value, or FormData for file uploads. */
|
|
8
|
+
body?: unknown;
|
|
9
|
+
headers?: Record<string, string> | undefined;
|
|
10
|
+
/** Retry on 429, 5xx and network errors. On by default for GET only. */
|
|
11
|
+
idempotent?: boolean | undefined;
|
|
12
|
+
/** Response headers, e.g. an ETag for optimistic locking. */
|
|
13
|
+
onResponse?: ((headers: Headers) => void) | undefined;
|
|
14
|
+
query?: QueryParams | undefined;
|
|
15
|
+
signal?: AbortSignal | undefined;
|
|
16
|
+
};
|
|
17
|
+
type BodylessOptions = Omit<RequestOptions, 'body'>;
|
|
18
|
+
export type Http = {
|
|
19
|
+
delete<T>(path: string, options?: BodylessOptions): Promise<T>;
|
|
20
|
+
get<T>(path: string, options?: BodylessOptions): Promise<T>;
|
|
21
|
+
patch<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
22
|
+
post<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
23
|
+
put<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
24
|
+
request<T>(method: HttpMethod, path: string, options?: RequestOptions): Promise<T>;
|
|
25
|
+
};
|
|
26
|
+
export type HttpOptions = {
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
clock?: Clock | undefined;
|
|
29
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
30
|
+
headers?: Record<string, string> | undefined;
|
|
31
|
+
onRetry?: ((info: RetryAttempt) => void) | undefined;
|
|
32
|
+
parseError?: ErrorParser | undefined;
|
|
33
|
+
retry?: RetryPolicy | undefined;
|
|
34
|
+
/** When to retry an idempotent request. */
|
|
35
|
+
shouldRetry?: ShouldRetry | undefined;
|
|
36
|
+
signal?: AbortSignal | undefined;
|
|
37
|
+
token?: string | undefined;
|
|
38
|
+
/** Trailing slash in paths (Django style). On by default. */
|
|
39
|
+
trailingSlash?: boolean | undefined;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The transport: base URL, bearer token, JSON both ways, responses mapped to sdk errors,
|
|
43
|
+
* retry for idempotent requests. It knows nothing about resources or about the CLI.
|
|
44
|
+
*/
|
|
45
|
+
export declare const createHttp: (options: HttpOptions) => Http;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { systemClock } from '../clock.js';
|
|
2
|
+
import { ApiError, AuthRequiredError, CancelledError, isAbortError, NetworkError } from '../errors.js';
|
|
3
|
+
import { defaultErrorParser, defaultShouldRetry } from './policies.js';
|
|
4
|
+
import { defaultRetryPolicy, retry } from './retry.js';
|
|
5
|
+
import { buildUrl } from './url.js';
|
|
6
|
+
/**
|
|
7
|
+
* The transport: base URL, bearer token, JSON both ways, responses mapped to sdk errors,
|
|
8
|
+
* retry for idempotent requests. It knows nothing about resources or about the CLI.
|
|
9
|
+
*/
|
|
10
|
+
export const createHttp = (options) => {
|
|
11
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
12
|
+
const clock = options.clock ?? systemClock;
|
|
13
|
+
const policy = options.retry ?? defaultRetryPolicy;
|
|
14
|
+
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
|
|
15
|
+
const parseError = options.parseError ?? defaultErrorParser;
|
|
16
|
+
const trailingSlash = options.trailingSlash ?? true;
|
|
17
|
+
const send = async (method, path, req) => {
|
|
18
|
+
const url = buildUrl(options.baseUrl, path, req.query, trailingSlash);
|
|
19
|
+
const signal = combineSignals(options.signal, req.signal);
|
|
20
|
+
const headers = new Headers({ accept: 'application/json', ...options.headers, ...req.headers });
|
|
21
|
+
if (options.token !== undefined) {
|
|
22
|
+
headers.set('authorization', `Bearer ${options.token}`);
|
|
23
|
+
}
|
|
24
|
+
const init = { headers, method };
|
|
25
|
+
if (req.body instanceof FormData) {
|
|
26
|
+
init.body = req.body;
|
|
27
|
+
}
|
|
28
|
+
else if (req.body !== undefined) {
|
|
29
|
+
headers.set('content-type', 'application/json');
|
|
30
|
+
init.body = JSON.stringify(req.body);
|
|
31
|
+
}
|
|
32
|
+
if (signal) {
|
|
33
|
+
init.signal = signal;
|
|
34
|
+
}
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetchImpl(url, init);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (signal?.aborted === true || isAbortError(error)) {
|
|
41
|
+
throw new CancelledError();
|
|
42
|
+
}
|
|
43
|
+
throw new NetworkError(url, error);
|
|
44
|
+
}
|
|
45
|
+
req.onResponse?.(response.headers);
|
|
46
|
+
// fetch resolves at the headers; reading the body can still fail or be cancelled.
|
|
47
|
+
// Keep the caller's onResponse callback outside this network-error conversion.
|
|
48
|
+
let payload;
|
|
49
|
+
try {
|
|
50
|
+
payload = await readBody(response);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (signal?.aborted === true || isAbortError(error)) {
|
|
54
|
+
throw new CancelledError();
|
|
55
|
+
}
|
|
56
|
+
throw new NetworkError(url, error);
|
|
57
|
+
}
|
|
58
|
+
if (response.ok) {
|
|
59
|
+
return payload;
|
|
60
|
+
}
|
|
61
|
+
if (response.status === 401) {
|
|
62
|
+
throw new AuthRequiredError('rejected');
|
|
63
|
+
}
|
|
64
|
+
const parsed = parseError(response.status, payload);
|
|
65
|
+
throw new ApiError({
|
|
66
|
+
code: parsed.code ?? `http_${response.status}`,
|
|
67
|
+
details: payload,
|
|
68
|
+
message: parsed.message ?? `${method} ${path} failed with HTTP ${response.status}`,
|
|
69
|
+
retryAfterMs: parseRetryAfter(response.headers.get('retry-after'), clock),
|
|
70
|
+
status: response.status,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const request = (method, path, req = {}) => {
|
|
74
|
+
const idempotent = req.idempotent ?? method === 'GET';
|
|
75
|
+
if (!idempotent) {
|
|
76
|
+
return send(method, path, req);
|
|
77
|
+
}
|
|
78
|
+
return retry(() => send(method, path, req), {
|
|
79
|
+
clock,
|
|
80
|
+
onRetry: options.onRetry,
|
|
81
|
+
policy,
|
|
82
|
+
shouldRetry,
|
|
83
|
+
signal: combineSignals(options.signal, req.signal),
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
request,
|
|
88
|
+
delete: (path, req) => request('DELETE', path, req),
|
|
89
|
+
get: (path, req) => request('GET', path, req),
|
|
90
|
+
patch: (path, body, req) => request('PATCH', path, { ...req, body }),
|
|
91
|
+
post: (path, body, req) => request('POST', path, { ...req, body }),
|
|
92
|
+
put: (path, body, req) => request('PUT', path, { ...req, body }),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
const combineSignals = (...signals) => {
|
|
96
|
+
const present = signals.filter((signal) => signal !== undefined);
|
|
97
|
+
if (present.length <= 1) {
|
|
98
|
+
return present[0];
|
|
99
|
+
}
|
|
100
|
+
return AbortSignal.any(present);
|
|
101
|
+
};
|
|
102
|
+
const readBody = async (response) => {
|
|
103
|
+
if (response.status === 204) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
if (text.length === 0) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(text);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return text;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const parseRetryAfter = (header, clock) => {
|
|
118
|
+
if (header === null) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const seconds = Number(header);
|
|
122
|
+
if (Number.isFinite(seconds)) {
|
|
123
|
+
return Math.max(0, seconds * 1000);
|
|
124
|
+
}
|
|
125
|
+
const date = Date.parse(header);
|
|
126
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - clock.now());
|
|
127
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport as a module: one door out. An eslint zone fences off the internals (client,
|
|
3
|
+
* policies, retry, url), so they can be rearranged without touching products.
|
|
4
|
+
*/
|
|
5
|
+
export { createHttp } from './client.js';
|
|
6
|
+
export type { Http, HttpMethod, HttpOptions, RequestOptions } from './client.js';
|
|
7
|
+
export { defaultErrorParser, defaultShouldRetry } from './policies.js';
|
|
8
|
+
export type { ErrorParser, ShouldRetry } from './policies.js';
|
|
9
|
+
export { defaultRetryPolicy, retry } from './retry.js';
|
|
10
|
+
export type { RetryAttempt, RetryDecision, RetryOptions, RetryPolicy } from './retry.js';
|
|
11
|
+
export type { QueryParams, QueryValue } from './url.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport as a module: one door out. An eslint zone fences off the internals (client,
|
|
3
|
+
* policies, retry, url), so they can be rearranged without touching products.
|
|
4
|
+
*/
|
|
5
|
+
export { createHttp } from './client.js';
|
|
6
|
+
export { defaultErrorParser, defaultShouldRetry } from './policies.js';
|
|
7
|
+
export { defaultRetryPolicy, retry } from './retry.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RetryDecision } from './retry.js';
|
|
2
|
+
/** How to pull code and message out of an error body. Developer API and ASA differ, so it is a parameter. */
|
|
3
|
+
export type ErrorParser = (status: number, body: unknown) => {
|
|
4
|
+
code?: string | undefined;
|
|
5
|
+
message?: string | undefined;
|
|
6
|
+
};
|
|
7
|
+
export type ShouldRetry = (error: unknown, attempt: number) => RetryDecision;
|
|
8
|
+
/** Default: network errors with backoff, 429 and 5xx after the delay the server asked for, if any. */
|
|
9
|
+
export declare const defaultShouldRetry: ShouldRetry;
|
|
10
|
+
/**
|
|
11
|
+
* The default error format, tolerant of the three common body shapes:
|
|
12
|
+
* { error: 'code', error_description }, { error: { code, message } }, { code, message | detail }.
|
|
13
|
+
*/
|
|
14
|
+
export declare const defaultErrorParser: ErrorParser;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ApiError, NetworkError } from '../errors.js';
|
|
2
|
+
/** Default: network errors with backoff, 429 and 5xx after the delay the server asked for, if any. */
|
|
3
|
+
export const defaultShouldRetry = (error) => {
|
|
4
|
+
if (error instanceof NetworkError) {
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
if (error instanceof ApiError && (error.status === 429 || error.status >= 500)) {
|
|
8
|
+
return { delayMs: error.retryAfterMs };
|
|
9
|
+
}
|
|
10
|
+
return false;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The default error format, tolerant of the three common body shapes:
|
|
14
|
+
* { error: 'code', error_description }, { error: { code, message } }, { code, message | detail }.
|
|
15
|
+
*/
|
|
16
|
+
export const defaultErrorParser = (_status, body) => {
|
|
17
|
+
if (!isRecord(body)) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
if (typeof body.error === 'string') {
|
|
21
|
+
return { code: body.error, message: asString(body.error_description ?? body.message) };
|
|
22
|
+
}
|
|
23
|
+
if (isRecord(body.error)) {
|
|
24
|
+
return { code: asString(body.error.code), message: asString(body.error.message) };
|
|
25
|
+
}
|
|
26
|
+
return { code: asString(body.code), message: asString(body.message ?? body.detail) };
|
|
27
|
+
};
|
|
28
|
+
const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
29
|
+
const asString = (value) => (typeof value === 'string' ? value : undefined);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Clock } from '../clock.js';
|
|
2
|
+
export type RetryPolicy = {
|
|
3
|
+
/** Total attempts, the first one included. */
|
|
4
|
+
attempts: number;
|
|
5
|
+
baseDelayMs: number;
|
|
6
|
+
maxDelayMs: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const defaultRetryPolicy: RetryPolicy;
|
|
9
|
+
/** false: do not retry. An object: retry, optionally after the delay the server suggested. */
|
|
10
|
+
export type RetryDecision = false | {
|
|
11
|
+
delayMs?: number | undefined;
|
|
12
|
+
};
|
|
13
|
+
export type RetryAttempt = {
|
|
14
|
+
/** Number of the failed attempt, starting at 1. */
|
|
15
|
+
attempt: number;
|
|
16
|
+
delayMs: number;
|
|
17
|
+
error: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type RetryOptions = {
|
|
20
|
+
clock: Clock;
|
|
21
|
+
/** Called before the pause. The sdk never prints: cli warns, MCP stays quiet. */
|
|
22
|
+
onRetry?: ((info: RetryAttempt) => void) | undefined;
|
|
23
|
+
policy: RetryPolicy;
|
|
24
|
+
shouldRetry: (error: unknown, attempt: number) => RetryDecision;
|
|
25
|
+
signal?: AbortSignal | undefined;
|
|
26
|
+
};
|
|
27
|
+
export declare const retry: <T>(fn: () => Promise<T>, options: RetryOptions) => Promise<T>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { throwIfAborted } from '../clock.js';
|
|
2
|
+
export const defaultRetryPolicy = { attempts: 3, baseDelayMs: 500, maxDelayMs: 8000 };
|
|
3
|
+
export const retry = async (fn, options) => {
|
|
4
|
+
const { clock, policy, signal } = options;
|
|
5
|
+
for (let attempt = 1;; attempt += 1) {
|
|
6
|
+
throwIfAborted(signal);
|
|
7
|
+
try {
|
|
8
|
+
return await fn();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const decision = attempt < policy.attempts ? options.shouldRetry(error, attempt) : false;
|
|
12
|
+
if (decision === false) {
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
const delayMs = decision.delayMs ?? backoffDelay(policy, attempt);
|
|
16
|
+
options.onRetry?.({ attempt, delayMs, error });
|
|
17
|
+
await clock.sleep(delayMs, signal);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** Exponential backoff without jitter: deterministic tests are worth more than spread here. */
|
|
22
|
+
const backoffDelay = (policy, attempt) => Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
|